From ba4c54c030bcde5c0e00f98cc2e001cc1efc1b86 Mon Sep 17 00:00:00 2001 From: Yan <61414485+yanthomasdev@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:50:24 -0300 Subject: [PATCH 1/2] feat(i18n): add Lunaria for tracking --- .github/workflows/lunaria.yml | 35 +++ lunaria.config.ts | 42 ++++ lunaria/components.ts | 449 ++++++++++++++++++++++++++++++++++ lunaria/lunaria.ts | 11 + lunaria/styles.ts | 344 ++++++++++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 60 +++++ 7 files changed, 943 insertions(+) create mode 100644 .github/workflows/lunaria.yml create mode 100644 lunaria.config.ts create mode 100644 lunaria/components.ts create mode 100644 lunaria/lunaria.ts create mode 100644 lunaria/styles.ts diff --git a/.github/workflows/lunaria.yml b/.github/workflows/lunaria.yml new file mode 100644 index 0000000000..f5bda174b5 --- /dev/null +++ b/.github/workflows/lunaria.yml @@ -0,0 +1,35 @@ +name: Lunaria + +on: + # Trigger the workflow every time a pull request is opened or synchronized at the target `main` branch + pull_request_target: + types: [opened, synchronize] + +# Automatically cancel in-progress actions on the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }} + cancel-in-progress: true + +# Allow this job to clone the repository and comment on the pull request +permissions: + contents: read + pull-requests: write + +jobs: + lunaria-overview: + name: Generate Lunaria Overview + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Necessary for Lunaria to work properly + # Makes the action clone the entire git history + fetch-depth: 0 + + - name: Install Tools & Dependencies + uses: ./.github/actions/install + + - name: Generate Lunaria Overview + uses: lunariajs/action@v1-prerelease \ No newline at end of file diff --git a/lunaria.config.ts b/lunaria.config.ts new file mode 100644 index 0000000000..62c92311b3 --- /dev/null +++ b/lunaria.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from '@lunariajs/core/config' + +export default defineConfig({ + repository: { + name: 'npmx-dev/npmx.dev', + }, + sourceLocale: { + label: 'English', + lang: 'en', + }, + locales: [ + { + label: 'Français', + lang: 'fr', + }, + { + label: 'Italiano', + lang: 'it', + }, + { + label: '简体中文', + lang: 'zh-CN', + }, + ], + files: [ + { + include: ['i18n/locales/en.json'], + pattern: 'i18n/locales/@lang.json', + type: 'dictionary', + }, + ], + tracking: { + ignoredKeywords: [ + 'lunaria-ignore', + 'typo', + 'en-only', + 'broken link', + 'i18nReady', + 'i18nIgnore', + ], + }, +}) diff --git a/lunaria/components.ts b/lunaria/components.ts new file mode 100644 index 0000000000..fd36ea6f82 --- /dev/null +++ b/lunaria/components.ts @@ -0,0 +1,449 @@ +import { + type createLunaria, + type Locale, + type LunariaConfig, + type LunariaStatus, + type StatusEntry, +} from '@lunariajs/core' +import { BaseStyles, CustomStyles } from './styles.ts' + +export function html( + strings: TemplateStringsArray, + ...values: ((string | number) | (string | number)[])[] +) { + const treatedValues = values.map(value => (Array.isArray(value) ? value.join('') : value)) + + return String.raw({ raw: strings }, ...treatedValues) +} + +type LunariaInstance = Awaited> + +function collapsePath(path: string) { + const basesToHide = ['src/content/docs/en/', 'src/i18n/en/', 'src/content/docs/', 'src/content/'] + + for (const base of basesToHide) { + const newPath = path.replace(base, '') + + if (newPath === path) continue + return newPath + } + + return path +} + +export const Page = ( + config: LunariaConfig, + status: LunariaStatus, + lunaria: LunariaInstance, +): string => { + return html` + + + + ${Meta} ${BaseStyles} ${CustomStyles} + + + ${Body(config, status, lunaria)} + + + ` +} + +export const Meta = html` + + + npmx - Translation Status + + + + + + + + + + + + +` + +export const Body = ( + config: LunariaConfig, + status: LunariaStatus, + lunaria: LunariaInstance, +): string => { + return html` +
+
+

npmx Translation Status

+ ${TitleParagraph} ${StatusByLocale(config, status, lunaria)} +
+ ${StatusByFile(config, status, lunaria)} +
+ ` +} + +export const StatusByLocale = ( + config: LunariaConfig, + status: LunariaStatus, + lunaria: LunariaInstance, +): string => { + const { locales } = config + return html` +

+ Translation progress by locale +

+ ${locales.map(locale => LocaleDetails(status, locale, lunaria))} + ` +} + +export const LocaleDetails = ( + status: LunariaStatus, + locale: Locale, + lunaria: LunariaInstance, +): string => { + const { label, lang } = locale + + const missingFiles = status.filter( + file => + file.localizations.find(localization => localization.lang === lang)?.status === 'missing', + ) + const outdatedFiles = status.filter(file => { + const localization = file.localizations.find(localization => localization.lang === lang) + + if (!localization || localization.status === 'missing') return false + if (file.type === 'dictionary') + return 'missingKeys' in localization ? localization.missingKeys.length > 0 : false + + return ( + localization.status === 'outdated' || + ('missingKeys' in localization && localization.missingKeys.length > 0) + ) + }) + + const doneLength = status.length - outdatedFiles.length - missingFiles.length + + const links = lunaria.gitHostingLinks() + + return html` +
+ + ${label} (${lang}) +
+ + ${doneLength.toString()} done, ${outdatedFiles.length.toString()} outdated, + ${missingFiles.length.toString()} missing + +
+ ${ProgressBar(status.length, outdatedFiles.length, missingFiles.length)} +
+ ${outdatedFiles.length > 0 ? OutdatedFiles(outdatedFiles, lang, lunaria) : ''} + ${ + missingFiles.length > 0 + ? html`

Missing

+ ` + : '' + } + ${ + missingFiles.length == 0 && outdatedFiles.length == 0 + ? html` +

This translation is complete, amazing job! 🎉

+ ` + : '' + } +
+ ` +} + +export const OutdatedFiles = ( + outdatedFiles: LunariaStatus, + lang: string, + lunaria: LunariaInstance, +): string => { + return html` +

Outdated

+ + ` +} + +export const StatusByFile = ( + config: LunariaConfig, + status: LunariaStatus, + lunaria: LunariaInstance, +): string => { + const { locales } = config + return html` +

+ Translation status by file +

+ + + + ${['File', ...locales.map(({ lang }) => lang)].map(col => html``)} + + + ${TableBody(status, locales, lunaria)} +
${col}
+ ❌ missing   🔄 outdated   ✔ done + ` +} + +export const TableBody = ( + status: LunariaStatus, + locales: Locale[], + lunaria: LunariaInstance, +): string => { + const links = lunaria.gitHostingLinks() + + return html` + + ${status.map( + file => + html` + + ${Link(links.source(file.source.path), collapsePath(file.source.path))} + ${locales.map(({ lang }) => { + return TableContentStatus(file.localizations, lang, lunaria) + })} + + `, + )} + + ` +} + +export const TableContentStatus = ( + localizations: StatusEntry['localizations'], + lang: string, + lunaria: LunariaInstance, +): string => { + const localization = localizations.find(localization => localization.lang === lang)! + const isMissingKeys = 'missingKeys' in localization && localization.missingKeys.length > 0 + const status = isMissingKeys ? 'outdated' : localization.status + const links = lunaria.gitHostingLinks() + const link = + status === 'missing' ? links.create(localization.path) : links.source(localization.path) + return html`${EmojiFileLink(link, status)}` +} + +export const ContentDetailsLinks = ( + fileStatus: StatusEntry, + lang: string, + lunaria: LunariaInstance, +): string => { + const localization = fileStatus.localizations.find(localization => localization.lang === lang)! + const isMissingKeys = + localization.status !== 'missing' && + 'missingKeys' in localization && + localization.missingKeys.length > 0 + + const links = lunaria.gitHostingLinks() + + return html` + ${Link(links.source(fileStatus.source.path), collapsePath(fileStatus.source.path))} + (${Link( + links.source(localization.path), + isMissingKeys ? 'incomplete translation' : 'outdated translation', + )}, + ${Link( + links.history( + fileStatus.source.path, + 'git' in localization + ? new Date(localization.git.latestTrackedCommit.date).toISOString() + : undefined, + ), + 'source change history', + )}) + ` +} + +export const EmojiFileLink = ( + href: string | null, + type: 'missing' | 'outdated' | 'up-to-date', +): string => { + const statusTextOpts = { + 'missing': 'missing', + 'outdated': 'outdated', + 'up-to-date': 'done', + } as const + + const statusEmojiOpts = { + 'missing': '❌', + 'outdated': '🔄', + 'up-to-date': '✔', + } as const + + return href + ? html` + + ` + : html` + + ` +} + +export const Link = (href: string, text: string): string => { + return html`${text}` +} + +export const CreateFileLink = (href: string, text: string): string => { + return html`${text}` +} + +export const ProgressBar = ( + total: number, + outdated: number, + missing: number, + { size = 20 }: { size?: number } = {}, +): string => { + const outdatedSize = Math.round((outdated / total) * size) + const missingSize = Math.round((missing / total) * size) + const doneSize = size - outdatedSize - missingSize + + const getBlocks = (size: number, type: 'missing' | 'outdated' | 'up-to-date') => { + const items = [] + for (let i = 0; i < size; i++) { + items.push(html`
`) + } + return items + } + + return html` + + ` +} + +export const TitleParagraph = html` +

+ If you're interested in helping us translate + npmx.dev into one of the languages listed below, you've come to + the right place! This auto-updating page always lists all the content that could use your help + right now. +

+

+ Before starting, please read our + localization (i18n) guide + to learn about our translation process and how you can get involved. +

+` + +/** + * Build an SVG file showing a summary of each language’s translation progress. + */ +export const SvgSummary = (config: LunariaConfig, status: LunariaStatus): string => { + const localeHeight = 56 // Each locale’s summary is 56px high. + const svgHeight = localeHeight * Math.ceil(config.locales.length / 2) + return html` + ${config.locales + .map(locale => SvgLocaleSummary(status, locale)) + .sort((a, b) => b.progress - a.progress) + .map( + ({ svg }, index) => + html`${svg}`, + )} + ` +} + +function SvgLocaleSummary( + status: LunariaStatus, + { label, lang }: Locale, +): { svg: string; progress: number } { + const missingFiles = status.filter( + file => + file.localizations.find(localization => localization.lang === lang)?.status === 'missing', + ) + const outdatedFiles = status.filter(file => { + const localization = file.localizations.find(localization => localization.lang === lang) + if (!localization || localization.status === 'missing') { + return false + } else if (file.type === 'dictionary') { + return 'missingKeys' in localization ? localization.missingKeys.length > 0 : false + } else { + return ( + localization.status === 'outdated' || + ('missingKeys' in localization && localization.missingKeys.length > 0) + ) + } + }) + + const doneLength = status.length - outdatedFiles.length - missingFiles.length + const barWidth = 184 + const doneFraction = doneLength / status.length + const outdatedFraction = outdatedFiles.length / status.length + const doneWidth = (doneFraction * barWidth).toFixed(2) + const outdatedWidth = ((outdatedFraction + doneFraction) * barWidth).toFixed(2) + + return { + progress: doneFraction, + svg: html`${label} (${lang}) + + ${ + missingFiles.length == 0 && outdatedFiles.length == 0 + ? '100% complete, amazing job! 🎉' + : html`${doneLength} done, ${outdatedFiles.length} outdated, ${missingFiles.length} + missing` + } + + + + `, + } +} diff --git a/lunaria/lunaria.ts b/lunaria/lunaria.ts new file mode 100644 index 0000000000..c735b63e65 --- /dev/null +++ b/lunaria/lunaria.ts @@ -0,0 +1,11 @@ +import { createLunaria } from '@lunariajs/core' +import { mkdirSync, writeFileSync } from 'node:fs' +import { Page } from './components.ts' + +const lunaria = await createLunaria() +const status = await lunaria.getFullStatus() + +const html = Page(lunaria.config, status, lunaria) + +mkdirSync('dist/lunaria', { recursive: true }) +writeFileSync('dist/lunaria/index.html', html) diff --git a/lunaria/styles.ts b/lunaria/styles.ts new file mode 100644 index 0000000000..7acce11ea4 --- /dev/null +++ b/lunaria/styles.ts @@ -0,0 +1,344 @@ +import { html } from './components.ts' + +export const BaseStyles = html` + +` + +export const CustomStyles = html` + +` diff --git a/package.json b/package.json index d95d4d6622..983811418b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "build": "nuxt build", + "build:lunaria": "node --experimental-transform-types ./lunaria/lunaria.ts", "dev": "nuxt dev", "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", "lint": "vite lint && vite fmt --check", @@ -30,6 +31,7 @@ "@deno/doc": "jsr:^0.189.1", "@iconify-json/simple-icons": "^1.2.67", "@iconify-json/vscode-icons": "^1.2.40", + "@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3", "@nuxt/a11y": "1.0.0-alpha.1", "@nuxt/fonts": "^0.13.0", "@nuxt/scripts": "^0.13.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6bf8b6f88..8e39737047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@iconify-json/vscode-icons': specifier: ^1.2.40 version: 1.2.40 + '@lunariajs/core': + specifier: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3 + version: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3 '@nuxt/a11y': specifier: 1.0.0-alpha.1 version: 1.0.0-alpha.1(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) @@ -1575,6 +1578,11 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3': + resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3} + version: 0.1.1 + engines: {node: '>=18.17.0'} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -6018,6 +6026,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.3.3: + resolution: {integrity: sha512-EX4oNDwcXSivPrw2qKH2LB5PoFxEvgtv2JgwW0bU858HoLQ+kutSvjLMUqBd0PeJYEinLWhoI9Ol0eYMqj/wNQ==} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -6685,6 +6697,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + nitropack@2.13.1: resolution: {integrity: sha512-2dDj89C4wC2uzG7guF3CnyG+zwkZosPEp7FFBGHB3AJo11AywOolWhyQJFHDzve8COvGxJaqscye9wW2IrUsNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6916,6 +6932,10 @@ packages: oxlint-tsgolint: optional: true + p-all@5.0.1: + resolution: {integrity: sha512-LMT7WX9ZSaq3J1zjloApkIVmtz0ZdMFSIqbuiEa3txGYPLjUPOvgOPOx3nFjo+f37ZYL+1aY666I2SG7GVwLOA==} + engines: {node: '>=16'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -6924,6 +6944,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@6.0.0: + resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==} + engines: {node: '>=16'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -6996,6 +7020,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -8191,6 +8218,9 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + ultramatter@0.0.4: + resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -10316,6 +10346,22 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3': + dependencies: + consola: 3.4.2 + jiti: 2.3.3 + js-yaml: 4.1.1 + neotraverse: 0.6.18 + p-all: 5.0.1 + path-to-regexp: 6.3.0 + picomatch: 4.0.3 + simple-git: 3.30.0 + tinyglobby: 0.2.15 + ultramatter: 0.0.4 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -15546,6 +15592,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.3.3: {} + jiti@2.6.1: {} jose@6.1.3: {} @@ -16339,6 +16387,8 @@ snapshots: neo-async@2.6.2: {} + neotraverse@0.6.18: {} + nitropack@2.13.1(better-sqlite3@12.5.0)(rolldown@1.0.0-rc.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -16943,6 +16993,10 @@ snapshots: '@oxlint/win32-x64': 1.42.0 oxlint-tsgolint: 0.11.2 + p-all@5.0.1: + dependencies: + p-map: 6.0.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -16951,6 +17005,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@6.0.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -17021,6 +17077,8 @@ snapshots: lru-cache: 11.2.5 minipass: 7.1.2 + path-to-regexp@6.3.0: {} + path-to-regexp@8.3.0: {} pathe@1.1.2: {} @@ -18471,6 +18529,8 @@ snapshots: ultrahtml@1.6.0: {} + ultramatter@0.0.4: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 From 8f43a434c80e5542c3a7b3e601c3c5ac568fd878 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 28 Jan 2026 16:10:26 +0000 Subject: [PATCH 2/2] chore: lint --- .github/workflows/lunaria.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lunaria.yml b/.github/workflows/lunaria.yml index f5bda174b5..5349aa37b2 100644 --- a/.github/workflows/lunaria.yml +++ b/.github/workflows/lunaria.yml @@ -32,4 +32,4 @@ jobs: uses: ./.github/actions/install - name: Generate Lunaria Overview - uses: lunariajs/action@v1-prerelease \ No newline at end of file + uses: lunariajs/action@v1-prerelease