[READ-ONLY] a fast, modern browser for the npm registry

feat: display translation progress and info within ui (#289)

authored by

Daniel Roe and committed by
GitHub
e78f0539 003f95bc

+378 -10
+123
app/components/TranslationHelper.vue
··· 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>
+78
app/composables/useI18nStatus.ts
··· 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 + }
+16 -7
app/pages/settings.vue
··· 3 3 const { settings } = useSettings() 4 4 const { locale, locales, setLocale } = useI18n() 5 5 const colorMode = useColorMode() 6 + const { currentLocaleStatus, isSourceLocale } = useI18nStatus() 6 7 7 8 const availableLocales = computed(() => 8 9 locales.value.map(l => (typeof l === 'string' ? { code: l, name: l } : l)), ··· 97 98 <select 98 99 id="theme-select" 99 100 :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" 101 102 @change=" 102 103 colorMode.preference = ($event.target as HTMLSelectElement).value as 103 104 | 'light' ··· 119 120 {{ $t('settings.language') }} 120 121 </label> 121 122 </div> 122 - <div class="px-2 py-1"> 123 + <div class="px-2 py-1 space-y-2"> 123 124 <select 124 125 id="language-select" 125 126 :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" 127 128 @change="setLocale(($event.target as HTMLSelectElement).value as typeof locale)" 128 129 > 129 130 <option v-for="loc in availableLocales" :key="loc.code" :value="loc.code"> ··· 131 132 </option> 132 133 </select> 133 134 </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 --> 134 142 <a 143 + v-else 135 144 href="https://github.com/npmx-dev/npmx.dev/tree/main/i18n/locales" 136 145 target="_blank" 137 146 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" 139 148 > 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" /> 141 150 {{ $t('settings.help_translate') }} 142 151 </a> 143 152 </div> 144 153 145 154 <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"> 147 156 {{ $t('settings.accent_colors') }} 148 - </h2> 157 + </div> 149 158 <div class="px-2 py-2"> 150 159 <AccentColorPicker /> 151 160 </div>
+10 -1
i18n/locales/en.json
··· 56 56 "language": "Language", 57 57 "help_translate": "Help translate npmx", 58 58 "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" 60 69 }, 61 70 "common": { 62 71 "loading": "Loading...",
+10 -1
lunaria/files/en-US.json
··· 56 56 "language": "Language", 57 57 "help_translate": "Help translate npmx", 58 58 "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" 60 69 }, 61 70 "common": { 62 71 "loading": "Loading...",
+71 -1
lunaria/lunaria.ts
··· 1 1 import { createLunaria } from '@lunariajs/core' 2 - import { mkdirSync, writeFileSync } from 'node:fs' 2 + import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' 3 3 import { Page } from './components.ts' 4 4 import { prepareJsonFiles } from './prepare-json-files.ts' 5 + import type { I18nStatus } from '../shared/types/i18n-status.ts' 5 6 6 7 await prepareJsonFiles() 7 8 8 9 const lunaria = await createLunaria() 9 10 const status = await lunaria.getFullStatus() 10 11 12 + // Generate HTML dashboard 11 13 const html = Page(lunaria.config, status, lunaria) 12 14 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 + 13 79 mkdirSync('dist/lunaria', { recursive: true }) 14 80 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')
+36
modules/lunaria.ts
··· 1 + import { defineNuxtModule, useNuxt } from 'nuxt/kit' 2 + import { execSync } from 'node:child_process' 3 + import { join } from 'node:path' 4 + import { existsSync, mkdirSync } from 'node:fs' 5 + import { isCI } from 'std-env' 6 + 7 + export default defineNuxtModule({ 8 + meta: { 9 + name: 'lunaria', 10 + }, 11 + setup() { 12 + const nuxt = useNuxt() 13 + 14 + const lunariaDistPath = join(nuxt.options.rootDir, 'dist/lunaria/') 15 + 16 + nuxt.options.nitro.publicAssets ||= [] 17 + nuxt.options.nitro.publicAssets.push({ 18 + dir: lunariaDistPath, 19 + baseURL: '/lunaria/', 20 + maxAge: 60 * 60 * 24, // 1 day 21 + }) 22 + 23 + if (nuxt.options.dev || nuxt.options._prepare || nuxt.options.test) { 24 + return 25 + } 26 + 27 + if (!isCI || !existsSync(lunariaDistPath)) { 28 + mkdirSync(lunariaDistPath, { recursive: true }) 29 + nuxt.hook('nitro:build:before', async () => { 30 + execSync('node --experimental-transform-types ./lunaria/lunaria.ts', { 31 + cwd: nuxt.options.rootDir, 32 + }) 33 + }) 34 + } 35 + }, 36 + })
+33
shared/types/i18n-status.ts
··· 1 + /** 2 + * Translation status data generated by Lunaria build 3 + */ 4 + export interface I18nStatus { 5 + /** ISO timestamp when this status was generated */ 6 + generatedAt: string 7 + /** The source locale (English) */ 8 + sourceLocale: { 9 + lang: string 10 + label: string 11 + } 12 + /** Status for each target locale */ 13 + locales: I18nLocaleStatus[] 14 + } 15 + 16 + export interface I18nLocaleStatus { 17 + /** Locale code (e.g., 'fr', 'zh-CN') */ 18 + lang: string 19 + /** Display name (e.g., 'Français') */ 20 + label: string 21 + /** Total number of translation keys */ 22 + totalKeys: number 23 + /** Number of completed translations */ 24 + completedKeys: number 25 + /** List of missing key paths (dot notation) */ 26 + missingKeys: string[] 27 + /** Completion percentage (0-100) */ 28 + percentComplete: number 29 + /** GitHub URL to edit this locale file */ 30 + githubEditUrl: string 31 + /** GitHub URL to view history of this locale file */ 32 + githubHistoryUrl: string 33 + }
+1
shared/types/index.ts
··· 4 4 export * from './readme' 5 5 export * from './docs' 6 6 export * from './deno-doc' 7 + export * from './i18n-status'