Skip to content

Commit e78f053

Browse files
authored
feat: display translation progress and info within ui (#289)
1 parent 003f95b commit e78f053

9 files changed

Lines changed: 378 additions & 10 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<script setup lang="ts">
2+
import type { I18nLocaleStatus } from '#shared/types'
3+
4+
const props = defineProps<{
5+
status: I18nLocaleStatus
6+
}>()
7+
8+
// Show first N missing keys by default
9+
const INITIAL_SHOW_COUNT = 5
10+
const showAll = ref(false)
11+
12+
const missingKeysToShow = computed(() => {
13+
if (showAll.value || props.status.missingKeys.length <= INITIAL_SHOW_COUNT) {
14+
return props.status.missingKeys
15+
}
16+
return props.status.missingKeys.slice(0, INITIAL_SHOW_COUNT)
17+
})
18+
19+
const hasMoreKeys = computed(
20+
() => props.status.missingKeys.length > INITIAL_SHOW_COUNT && !showAll.value,
21+
)
22+
23+
const remainingCount = computed(() => props.status.missingKeys.length - INITIAL_SHOW_COUNT)
24+
25+
// Generate a GitHub URL that pre-fills the edit with guidance
26+
const contributionGuideUrl =
27+
'https://github.com/npmx-dev/npmx.dev/blob/main/CONTRIBUTING.md#localization-i18n'
28+
29+
// Copy missing keys as JSON template to clipboard
30+
const { copy, copied } = useClipboard()
31+
32+
function copyMissingKeysTemplate() {
33+
// Create a template showing what needs to be added
34+
const template = props.status.missingKeys.map(key => ` "${key}": ""`).join(',\n')
35+
36+
const fullTemplate = `// Missing translations for ${props.status.label} (${props.status.lang})
37+
// Add these keys to: i18n/locales/${props.status.lang}.json
38+
39+
${template}`
40+
41+
copy(fullTemplate)
42+
}
43+
</script>
44+
45+
<template>
46+
<div class="space-y-3">
47+
<!-- Progress section -->
48+
<div class="space-y-1.5">
49+
<div class="flex items-center justify-between text-xs text-fg-muted">
50+
<span>{{ $t('settings.translation_progress') }}</span>
51+
<span class="tabular-nums"
52+
>{{ status.completedKeys }}/{{ status.totalKeys }} ({{ status.percentComplete }}%)</span
53+
>
54+
</div>
55+
<div class="h-1.5 bg-bg rounded-full overflow-hidden">
56+
<div
57+
class="h-full bg-accent transition-all duration-300 motion-reduce:transition-none"
58+
:style="{ width: `${status.percentComplete}%` }"
59+
/>
60+
</div>
61+
</div>
62+
63+
<!-- Missing keys section -->
64+
<div v-if="status.missingKeys.length > 0" class="space-y-2">
65+
<div class="flex items-center justify-between">
66+
<h4 class="text-xs text-fg-muted font-medium">
67+
{{ $t('i18n.missing_keys', { count: status.missingKeys.length }) }}
68+
</h4>
69+
<button
70+
type="button"
71+
class="text-xs text-accent hover:underline rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
72+
@click="copyMissingKeysTemplate"
73+
>
74+
{{ copied ? $t('common.copied') : $t('i18n.copy_keys') }}
75+
</button>
76+
</div>
77+
78+
<ul class="space-y-1 text-xs font-mono bg-bg rounded-md p-2 max-h-32 overflow-y-auto">
79+
<li v-for="key in missingKeysToShow" :key="key" class="text-fg-muted truncate" :title="key">
80+
{{ key }}
81+
</li>
82+
</ul>
83+
84+
<button
85+
v-if="hasMoreKeys"
86+
type="button"
87+
class="text-xs text-fg-muted hover:text-fg rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
88+
@click="showAll = true"
89+
>
90+
{{ $t('i18n.show_more_keys', { count: remainingCount }) }}
91+
</button>
92+
</div>
93+
94+
<!-- Contribution guidance -->
95+
<div class="pt-2 border-t border-border space-y-2">
96+
<p class="text-xs text-fg-muted">
97+
{{ $t('i18n.contribute_hint') }}
98+
</p>
99+
100+
<div class="flex flex-wrap gap-2">
101+
<a
102+
:href="status.githubEditUrl"
103+
target="_blank"
104+
rel="noopener noreferrer"
105+
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs bg-bg hover:bg-bg-subtle border border-border rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
106+
>
107+
<span class="i-carbon-edit w-3.5 h-3.5" aria-hidden="true" />
108+
{{ $t('i18n.edit_on_github') }}
109+
</a>
110+
111+
<a
112+
:href="contributionGuideUrl"
113+
target="_blank"
114+
rel="noopener noreferrer"
115+
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-fg-muted hover:text-fg rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
116+
>
117+
<span class="i-carbon-document w-3.5 h-3.5" aria-hidden="true" />
118+
{{ $t('i18n.view_guide') }}
119+
</a>
120+
</div>
121+
</div>
122+
</div>
123+
</template>

app/composables/useI18nStatus.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { I18nStatus, I18nLocaleStatus } from '#shared/types'
2+
3+
/**
4+
* Composable for accessing translation status data from Lunaria.
5+
* Provides information about translation progress for each locale.
6+
* @public
7+
*/
8+
export function useI18nStatus() {
9+
const { locale } = useI18n()
10+
11+
const {
12+
data: status,
13+
status: fetchStatus,
14+
error,
15+
} = useFetch<I18nStatus>('/lunaria/status.json', {
16+
responseType: 'json',
17+
server: false,
18+
// Cache the result to avoid refetching on navigation
19+
getCachedData: key => useNuxtApp().payload.data[key] || useNuxtApp().static.data[key],
20+
})
21+
22+
/**
23+
* Get the translation status for a specific locale
24+
*/
25+
function getLocaleStatus(langCode: string): I18nLocaleStatus | null {
26+
if (!status.value) return null
27+
return status.value.locales.find(l => l.lang === langCode) ?? null
28+
}
29+
30+
/**
31+
* Translation status for the current locale
32+
*/
33+
const currentLocaleStatus = computed<I18nLocaleStatus | null>(() => {
34+
return getLocaleStatus(locale.value)
35+
})
36+
37+
/**
38+
* Whether the current locale's translation is 100% complete
39+
*/
40+
const isComplete = computed(() => {
41+
const localeStatus = currentLocaleStatus.value
42+
if (!localeStatus) return true // Assume complete if no data
43+
return localeStatus.percentComplete === 100
44+
})
45+
46+
/**
47+
* Whether the current locale is the source locale (English)
48+
*/
49+
const isSourceLocale = computed(() => {
50+
return locale.value === (status.value?.sourceLocale.lang ?? 'en-US')
51+
})
52+
53+
/**
54+
* GitHub URL to edit the current locale's translation file
55+
*/
56+
const githubEditUrl = computed(() => {
57+
return currentLocaleStatus.value?.githubEditUrl ?? null
58+
})
59+
60+
return {
61+
/** Full translation status data */
62+
status,
63+
/** Fetch status ('idle' | 'pending' | 'success' | 'error') */
64+
fetchStatus,
65+
/** Fetch error if any */
66+
error,
67+
/** Get status for a specific locale */
68+
getLocaleStatus,
69+
/** Status for the current locale */
70+
currentLocaleStatus,
71+
/** Whether current locale is 100% complete */
72+
isComplete,
73+
/** Whether current locale is the source (English) */
74+
isSourceLocale,
75+
/** GitHub edit URL for current locale */
76+
githubEditUrl,
77+
}
78+
}

app/pages/settings.vue

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const router = useRouter()
33
const { settings } = useSettings()
44
const { locale, locales, setLocale } = useI18n()
55
const colorMode = useColorMode()
6+
const { currentLocaleStatus, isSourceLocale } = useI18nStatus()
67
78
const availableLocales = computed(() =>
89
locales.value.map(l => (typeof l === 'string' ? { code: l, name: l } : l)),
@@ -97,7 +98,7 @@ useSeoMeta({
9798
<select
9899
id="theme-select"
99100
:value="colorMode.preference"
100-
class="w-full bg-bg-muted border border-border rounded-md px-2 py-1.5 text-sm text-fg focus:outline-none focus:ring-2 focus:ring-fg/50 cursor-pointer"
101+
class="w-full bg-bg-muted border border-border rounded-md px-2 py-1.5 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 cursor-pointer"
101102
@change="
102103
colorMode.preference = ($event.target as HTMLSelectElement).value as
103104
| 'light'
@@ -119,33 +120,41 @@ useSeoMeta({
119120
{{ $t('settings.language') }}
120121
</label>
121122
</div>
122-
<div class="px-2 py-1">
123+
<div class="px-2 py-1 space-y-2">
123124
<select
124125
id="language-select"
125126
:value="locale"
126-
class="w-full bg-bg-muted border border-border rounded-md px-2 py-1.5 text-sm text-fg focus:outline-none focus:ring-2 focus:ring-fg/50 cursor-pointer"
127+
class="w-full bg-bg-muted border border-border rounded-md px-2 py-1.5 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 cursor-pointer"
127128
@change="setLocale(($event.target as HTMLSelectElement).value as typeof locale)"
128129
>
129130
<option v-for="loc in availableLocales" :key="loc.code" :value="loc.code">
130131
{{ loc.name }}
131132
</option>
132133
</select>
133134
</div>
135+
136+
<!-- Translation helper for non-source locales -->
137+
<div v-if="currentLocaleStatus && !isSourceLocale" class="px-2 py-2">
138+
<TranslationHelper :status="currentLocaleStatus" />
139+
</div>
140+
141+
<!-- Simple help link for source locale -->
134142
<a
143+
v-else
135144
href="https://github.com/npmx-dev/npmx.dev/tree/main/i18n/locales"
136145
target="_blank"
137146
rel="noopener noreferrer"
138-
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-fg-muted hover:text-fg transition-colors"
147+
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-fg-muted hover:text-fg rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
139148
>
140-
<span class="i-carbon-translate w-3.5 h-3.5" aria-hidden="true" />
149+
<span class="i-carbon-logo-github w-3.5 h-3.5" aria-hidden="true" />
141150
{{ $t('settings.help_translate') }}
142151
</a>
143152
</div>
144153

145154
<div class="pt-2 mt-2 border-t border-border">
146-
<h2 class="text-xs text-fg-subtle uppercase tracking-wider px-2 py-1">
155+
<div class="text-xs text-fg-subtle uppercase tracking-wider px-2 py-1">
147156
{{ $t('settings.accent_colors') }}
148-
</h2>
157+
</div>
149158
<div class="px-2 py-2">
150159
<AccentColorPicker />
151160
</div>

i18n/locales/en.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,16 @@
5656
"language": "Language",
5757
"help_translate": "Help translate npmx",
5858
"accent_colors": "Accent colors",
59-
"clear_accent": "Clear accent color"
59+
"clear_accent": "Clear accent color",
60+
"translation_progress": "Translation progress"
61+
},
62+
"i18n": {
63+
"missing_keys": "{count} missing translation | {count} missing translations",
64+
"copy_keys": "Copy keys",
65+
"show_more_keys": "Show {count} more...",
66+
"contribute_hint": "Help improve this translation by adding the missing keys.",
67+
"edit_on_github": "Edit on GitHub",
68+
"view_guide": "Translation guide"
6069
},
6170
"common": {
6271
"loading": "Loading...",

lunaria/files/en-US.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,16 @@
5656
"language": "Language",
5757
"help_translate": "Help translate npmx",
5858
"accent_colors": "Accent colors",
59-
"clear_accent": "Clear accent color"
59+
"clear_accent": "Clear accent color",
60+
"translation_progress": "Translation progress"
61+
},
62+
"i18n": {
63+
"missing_keys": "{count} missing translation | {count} missing translations",
64+
"copy_keys": "Copy keys",
65+
"show_more_keys": "Show {count} more...",
66+
"contribute_hint": "Help improve this translation by adding the missing keys.",
67+
"edit_on_github": "Edit on GitHub",
68+
"view_guide": "Translation guide"
6069
},
6170
"common": {
6271
"loading": "Loading...",

lunaria/lunaria.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,84 @@
11
import { createLunaria } from '@lunariajs/core'
2-
import { mkdirSync, writeFileSync } from 'node:fs'
2+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
33
import { Page } from './components.ts'
44
import { prepareJsonFiles } from './prepare-json-files.ts'
5+
import type { I18nStatus } from '../shared/types/i18n-status.ts'
56

67
await prepareJsonFiles()
78

89
const lunaria = await createLunaria()
910
const status = await lunaria.getFullStatus()
1011

12+
// Generate HTML dashboard
1113
const html = Page(lunaria.config, status, lunaria)
1214

15+
// Generate JSON status for the app
16+
const { sourceLocale, locales } = lunaria.config
17+
const links = lunaria.gitHostingLinks()
18+
19+
// For dictionary files, we track the first (and only) entry
20+
const fileStatus = status[0]
21+
if (!fileStatus) {
22+
throw new Error('No file status found')
23+
}
24+
25+
// Count keys in a nested object
26+
function countKeys(obj: Record<string, unknown>): number {
27+
let count = 0
28+
for (const key in obj) {
29+
const value = obj[key]
30+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
31+
count += countKeys(value as Record<string, unknown>)
32+
} else {
33+
count++
34+
}
35+
}
36+
return count
37+
}
38+
39+
// Read source locale file from prepared files
40+
const englishFile = JSON.parse(readFileSync('lunaria/files/en-US.json', 'utf-8')) as Record<
41+
string,
42+
unknown
43+
>
44+
const totalKeys = countKeys(englishFile)
45+
46+
const jsonStatus: I18nStatus = {
47+
generatedAt: new Date().toISOString(),
48+
sourceLocale: {
49+
lang: sourceLocale.lang,
50+
label: sourceLocale.label,
51+
},
52+
locales: locales.map(locale => {
53+
const localization = fileStatus.localizations.find(l => l.lang === locale.lang)
54+
55+
// Get missing keys if available
56+
const missingKeys: string[] = []
57+
if (localization && 'missingKeys' in localization && localization.missingKeys) {
58+
for (const keyPath of localization.missingKeys) {
59+
missingKeys.push((keyPath as unknown as string[]).join('.'))
60+
}
61+
}
62+
63+
const completedKeys = totalKeys - missingKeys.length
64+
const localeFilePath = `i18n/locales/${locale.lang}.json`
65+
66+
return {
67+
lang: locale.lang,
68+
label: locale.label,
69+
totalKeys,
70+
completedKeys,
71+
missingKeys,
72+
percentComplete: totalKeys > 0 ? Math.round((completedKeys / totalKeys) * 100) : 100,
73+
githubEditUrl: links.source(localeFilePath),
74+
githubHistoryUrl: links.history(localeFilePath),
75+
}
76+
}),
77+
}
78+
1379
mkdirSync('dist/lunaria', { recursive: true })
1480
writeFileSync('dist/lunaria/index.html', html)
81+
writeFileSync('dist/lunaria/status.json', JSON.stringify(jsonStatus, null, 2))
82+
83+
// eslint-disable-next-line no-console
84+
console.log('Generated dist/lunaria/index.html and dist/lunaria/status.json')

0 commit comments

Comments
 (0)