Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions app/components/TranslationHelper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { I18nLocaleStatus } from '#shared/types'
const props = defineProps<{
status: I18nLocaleStatus
}>()
// Show first N missing keys by default
const INITIAL_SHOW_COUNT = 5
const showAll = ref(false)
const missingKeysToShow = computed(() => {
if (showAll.value || props.status.missingKeys.length <= INITIAL_SHOW_COUNT) {
return props.status.missingKeys
}
return props.status.missingKeys.slice(0, INITIAL_SHOW_COUNT)
})
const hasMoreKeys = computed(
() => props.status.missingKeys.length > INITIAL_SHOW_COUNT && !showAll.value,
)
const remainingCount = computed(() => props.status.missingKeys.length - INITIAL_SHOW_COUNT)
// Generate a GitHub URL that pre-fills the edit with guidance
const contributionGuideUrl =
'https://github.com/npmx-dev/npmx.dev/blob/main/CONTRIBUTING.md#localization-i18n'
// Copy missing keys as JSON template to clipboard
const { copy, copied } = useClipboard()
function copyMissingKeysTemplate() {
// Create a template showing what needs to be added
const template = props.status.missingKeys.map(key => ` "${key}": ""`).join(',\n')
const fullTemplate = `// Missing translations for ${props.status.label} (${props.status.lang})
// Add these keys to: i18n/locales/${props.status.lang}.json
${template}`
copy(fullTemplate)
}
</script>

<template>
<div class="space-y-3">
<!-- Progress section -->
<div class="space-y-1.5">
<div class="flex items-center justify-between text-xs text-fg-muted">
<span>{{ $t('settings.translation_progress') }}</span>
<span class="tabular-nums"
>{{ status.completedKeys }}/{{ status.totalKeys }} ({{ status.percentComplete }}%)</span
>
</div>
<div class="h-1.5 bg-bg rounded-full overflow-hidden">
<div
class="h-full bg-accent transition-all duration-300 motion-reduce:transition-none"
:style="{ width: `${status.percentComplete}%` }"
/>
</div>
</div>

<!-- Missing keys section -->
<div v-if="status.missingKeys.length > 0" class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-xs text-fg-muted font-medium">
{{ $t('i18n.missing_keys', { count: status.missingKeys.length }) }}
</h4>
<button
type="button"
class="text-xs text-accent hover:underline rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
@click="copyMissingKeysTemplate"
>
{{ copied ? $t('common.copied') : $t('i18n.copy_keys') }}
</button>
</div>

<ul class="space-y-1 text-xs font-mono bg-bg rounded-md p-2 max-h-32 overflow-y-auto">
<li v-for="key in missingKeysToShow" :key="key" class="text-fg-muted truncate" :title="key">
{{ key }}
</li>
</ul>

<button
v-if="hasMoreKeys"
type="button"
class="text-xs text-fg-muted hover:text-fg rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="showAll = true"
>
{{ $t('i18n.show_more_keys', { count: remainingCount }) }}
</button>
</div>

<!-- Contribution guidance -->
<div class="pt-2 border-t border-border space-y-2">
<p class="text-xs text-fg-muted">
{{ $t('i18n.contribute_hint') }}
</p>

<div class="flex flex-wrap gap-2">
<a
:href="status.githubEditUrl"
target="_blank"
rel="noopener noreferrer"
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"
>
<span class="i-carbon-edit w-3.5 h-3.5" aria-hidden="true" />
{{ $t('i18n.edit_on_github') }}
</a>

<a
:href="contributionGuideUrl"
target="_blank"
rel="noopener noreferrer"
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"
>
<span class="i-carbon-document w-3.5 h-3.5" aria-hidden="true" />
{{ $t('i18n.view_guide') }}
</a>
</div>
</div>
</div>
</template>
78 changes: 78 additions & 0 deletions app/composables/useI18nStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { I18nStatus, I18nLocaleStatus } from '#shared/types'

/**
* Composable for accessing translation status data from Lunaria.
* Provides information about translation progress for each locale.
* @public
*/
export function useI18nStatus() {
const { locale } = useI18n()

const {
data: status,
status: fetchStatus,
error,
} = useFetch<I18nStatus>('/lunaria/status.json', {
responseType: 'json',
server: false,
// Cache the result to avoid refetching on navigation
getCachedData: key => useNuxtApp().payload.data[key] || useNuxtApp().static.data[key],
})

/**
* Get the translation status for a specific locale
*/
function getLocaleStatus(langCode: string): I18nLocaleStatus | null {
if (!status.value) return null
return status.value.locales.find(l => l.lang === langCode) ?? null
}

/**
* Translation status for the current locale
*/
const currentLocaleStatus = computed<I18nLocaleStatus | null>(() => {
return getLocaleStatus(locale.value)
})

/**
* Whether the current locale's translation is 100% complete
*/
const isComplete = computed(() => {
const localeStatus = currentLocaleStatus.value
if (!localeStatus) return true // Assume complete if no data
return localeStatus.percentComplete === 100
})

/**
* Whether the current locale is the source locale (English)
*/
const isSourceLocale = computed(() => {
return locale.value === (status.value?.sourceLocale.lang ?? 'en-US')
})

/**
* GitHub URL to edit the current locale's translation file
*/
const githubEditUrl = computed(() => {
return currentLocaleStatus.value?.githubEditUrl ?? null
})

return {
/** Full translation status data */
status,
/** Fetch status ('idle' | 'pending' | 'success' | 'error') */
fetchStatus,
/** Fetch error if any */
error,
/** Get status for a specific locale */
getLocaleStatus,
/** Status for the current locale */
currentLocaleStatus,
/** Whether current locale is 100% complete */
isComplete,
/** Whether current locale is the source (English) */
isSourceLocale,
/** GitHub edit URL for current locale */
githubEditUrl,
}
}
23 changes: 16 additions & 7 deletions app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const router = useRouter()
const { settings } = useSettings()
const { locale, locales, setLocale } = useI18n()
const colorMode = useColorMode()
const { currentLocaleStatus, isSourceLocale } = useI18nStatus()
const availableLocales = computed(() =>
locales.value.map(l => (typeof l === 'string' ? { code: l, name: l } : l)),
Expand Down Expand Up @@ -97,7 +98,7 @@ useSeoMeta({
<select
id="theme-select"
:value="colorMode.preference"
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"
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"
@change="
colorMode.preference = ($event.target as HTMLSelectElement).value as
| 'light'
Expand All @@ -119,33 +120,41 @@ useSeoMeta({
{{ $t('settings.language') }}
</label>
</div>
<div class="px-2 py-1">
<div class="px-2 py-1 space-y-2">
<select
id="language-select"
:value="locale"
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"
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"
@change="setLocale(($event.target as HTMLSelectElement).value as typeof locale)"
>
<option v-for="loc in availableLocales" :key="loc.code" :value="loc.code">
{{ loc.name }}
</option>
</select>
</div>

<!-- Translation helper for non-source locales -->
<div v-if="currentLocaleStatus && !isSourceLocale" class="px-2 py-2">
<TranslationHelper :status="currentLocaleStatus" />
</div>

<!-- Simple help link for source locale -->
<a
v-else
href="https://github.com/npmx-dev/npmx.dev/tree/main/i18n/locales"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-fg-muted hover:text-fg transition-colors"
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"
>
<span class="i-carbon-translate w-3.5 h-3.5" aria-hidden="true" />
<span class="i-carbon-logo-github w-3.5 h-3.5" aria-hidden="true" />
{{ $t('settings.help_translate') }}
</a>
</div>

<div class="pt-2 mt-2 border-t border-border">
<h2 class="text-xs text-fg-subtle uppercase tracking-wider px-2 py-1">
<div class="text-xs text-fg-subtle uppercase tracking-wider px-2 py-1">
{{ $t('settings.accent_colors') }}
</h2>
</div>
<div class="px-2 py-2">
<AccentColorPicker />
</div>
Expand Down
11 changes: 10 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@
"language": "Language",
"help_translate": "Help translate npmx",
"accent_colors": "Accent colors",
"clear_accent": "Clear accent color"
"clear_accent": "Clear accent color",
"translation_progress": "Translation progress"
},
"i18n": {
"missing_keys": "{count} missing translation | {count} missing translations",
"copy_keys": "Copy keys",
"show_more_keys": "Show {count} more...",
"contribute_hint": "Help improve this translation by adding the missing keys.",
"edit_on_github": "Edit on GitHub",
"view_guide": "Translation guide"
},
"common": {
"loading": "Loading...",
Expand Down
11 changes: 10 additions & 1 deletion lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@
"language": "Language",
"help_translate": "Help translate npmx",
"accent_colors": "Accent colors",
"clear_accent": "Clear accent color"
"clear_accent": "Clear accent color",
"translation_progress": "Translation progress"
},
"i18n": {
"missing_keys": "{count} missing translation | {count} missing translations",
"copy_keys": "Copy keys",
"show_more_keys": "Show {count} more...",
"contribute_hint": "Help improve this translation by adding the missing keys.",
"edit_on_github": "Edit on GitHub",
"view_guide": "Translation guide"
},
"common": {
"loading": "Loading...",
Expand Down
72 changes: 71 additions & 1 deletion lunaria/lunaria.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,84 @@
import { createLunaria } from '@lunariajs/core'
import { mkdirSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { Page } from './components.ts'
import { prepareJsonFiles } from './prepare-json-files.ts'
import type { I18nStatus } from '../shared/types/i18n-status.ts'

await prepareJsonFiles()

const lunaria = await createLunaria()
const status = await lunaria.getFullStatus()

// Generate HTML dashboard
const html = Page(lunaria.config, status, lunaria)

// Generate JSON status for the app
const { sourceLocale, locales } = lunaria.config
const links = lunaria.gitHostingLinks()

// For dictionary files, we track the first (and only) entry
const fileStatus = status[0]
if (!fileStatus) {
throw new Error('No file status found')
}

// Count keys in a nested object
function countKeys(obj: Record<string, unknown>): number {
let count = 0
for (const key in obj) {
const value = obj[key]
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
count += countKeys(value as Record<string, unknown>)
} else {
count++
}
}
return count
}

// Read source locale file from prepared files
const englishFile = JSON.parse(readFileSync('lunaria/files/en-US.json', 'utf-8')) as Record<
string,
unknown
>
const totalKeys = countKeys(englishFile)

const jsonStatus: I18nStatus = {
generatedAt: new Date().toISOString(),
sourceLocale: {
lang: sourceLocale.lang,
label: sourceLocale.label,
},
locales: locales.map(locale => {
const localization = fileStatus.localizations.find(l => l.lang === locale.lang)

// Get missing keys if available
const missingKeys: string[] = []
if (localization && 'missingKeys' in localization && localization.missingKeys) {
for (const keyPath of localization.missingKeys) {
missingKeys.push((keyPath as unknown as string[]).join('.'))
}
}

const completedKeys = totalKeys - missingKeys.length
const localeFilePath = `i18n/locales/${locale.lang}.json`

return {
lang: locale.lang,
label: locale.label,
totalKeys,
completedKeys,
missingKeys,
percentComplete: totalKeys > 0 ? Math.round((completedKeys / totalKeys) * 100) : 100,
githubEditUrl: links.source(localeFilePath),
githubHistoryUrl: links.history(localeFilePath),
}
}),
}

mkdirSync('dist/lunaria', { recursive: true })
writeFileSync('dist/lunaria/index.html', html)
writeFileSync('dist/lunaria/status.json', JSON.stringify(jsonStatus, null, 2))

// eslint-disable-next-line no-console
console.log('Generated dist/lunaria/index.html and dist/lunaria/status.json')
Loading