-
Notifications
You must be signed in to change notification settings - Fork 974
Add Lunaria translation tracking and i18n dashboard #461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9d987c5
002a4ef
41fb5f5
9a60902
434cb20
0e291d4
aa168f1
0462095
6ef3082
71494df
7aaf339
30d4efb
8f324d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| name: Lunaria | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, synchronize] | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.head_ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| lunaria-overview: | ||
| name: Translation Overview | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| with: | ||
| fetch-depth: 0 | ||
| - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 | ||
| - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
| - run: pnpm install --frozen-lockfile | ||
| - uses: lunariajs/action@4911ad0736d1e3b20af4cb70f5079aea2327ed8e |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| dist/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import { mkdirSync, writeFileSync } from "node:fs"; | ||
|
|
||
| import { createLunaria } from "@lunariajs/core"; | ||
|
|
||
| const lunaria = await createLunaria(); | ||
| const status = await lunaria.getFullStatus(); | ||
| const { sourceLocale, locales } = lunaria.config; | ||
| const links = lunaria.gitHostingLinks(); | ||
|
|
||
| const fileStatus = status[0]; | ||
| if (!fileStatus) { | ||
| console.log("No tracked files found."); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| interface LocaleStatus { | ||
| lang: string; | ||
| label: string; | ||
| totalKeys: number; | ||
| completedKeys: number; | ||
| missingKeys: string[]; | ||
| percentComplete: number; | ||
| editUrl: string; | ||
| historyUrl: string; | ||
| } | ||
|
|
||
| const AMP = /&/g; | ||
| const LT = /</g; | ||
| const GT = />/g; | ||
| const QUOT = /"/g; | ||
|
|
||
| function countPoEntries(contents: string): number { | ||
| let count = 0; | ||
| for (const line of contents.split("\n")) { | ||
| if (line.startsWith("msgid ") && line !== 'msgid ""') { | ||
| count++; | ||
| } | ||
| } | ||
| return count; | ||
| } | ||
|
|
||
| const totalKeys = countPoEntries(fileStatus.source.contents); | ||
|
|
||
| const localeStatuses: LocaleStatus[] = locales.map((locale) => { | ||
| const localization = fileStatus.localizations.find((l) => l.lang === locale.lang); | ||
|
|
||
| const missingKeys: string[] = []; | ||
| if (localization && "missingKeys" in localization && localization.missingKeys) { | ||
| for (const keyPath of localization.missingKeys) { | ||
| missingKeys.push(Array.isArray(keyPath) ? keyPath.join(".") : String(keyPath)); | ||
| } | ||
| } | ||
|
|
||
| const completedKeys = totalKeys - missingKeys.length; | ||
| const editUrl = localization | ||
| ? links.source(localization.path) | ||
| : links.create(`packages/admin/src/locales/${locale.lang}/messages.po`); | ||
| const historyUrl = localization ? links.history(localization.path) : ""; | ||
|
|
||
| return { | ||
| lang: locale.lang, | ||
| label: locale.label, | ||
| totalKeys, | ||
| completedKeys, | ||
| missingKeys, | ||
| percentComplete: totalKeys > 0 ? Math.round((completedKeys / totalKeys) * 100) : 100, | ||
| editUrl, | ||
| historyUrl, | ||
| }; | ||
| }); | ||
|
|
||
| function barClass(percent: number): string { | ||
| if (percent >= 100) return "completed"; | ||
| if (percent > 90) return "very-good"; | ||
| if (percent > 75) return "good"; | ||
| if (percent > 50) return "help-needed"; | ||
| return "basic"; | ||
| } | ||
|
|
||
| function escapeHtml(s: string): string { | ||
| return s.replace(AMP, "&").replace(LT, "<").replace(GT, ">").replace(QUOT, """); | ||
| } | ||
|
|
||
| function localeCard(s: LocaleStatus): string { | ||
| return ` | ||
| <details class="locale"> | ||
| <summary> | ||
| <strong>${s.label} <span class="lang">${s.lang}</span></strong> | ||
| <span class="stats">${s.completedKeys}/${s.totalKeys} · ${s.percentComplete}%</span> | ||
| <div class="bar"><div class="fill ${barClass(s.percentComplete)}" style="width:${s.percentComplete}%"></div></div> | ||
| </summary> | ||
| <div class="links"> | ||
| <a href="${s.editUrl}">Edit translation</a> | ||
| ${s.historyUrl ? `· <a href="${s.historyUrl}">History</a>` : ""} | ||
| </div> | ||
| ${ | ||
| s.missingKeys.length > 0 | ||
| ? `<details class="missing"><summary>${s.missingKeys.length} missing keys</summary><ul>${s.missingKeys.map((k) => `<li>${escapeHtml(k)}</li>`).join("")}</ul></details>` | ||
| : `<p class="done">All strings translated 🎉</p>` | ||
| } | ||
| </details>`; | ||
| } | ||
|
|
||
| const html = `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>EmDash Translation Status</title> | ||
| <meta name="description" content="Translation progress for the EmDash admin UI. See what needs translating and get involved."> | ||
| <link rel="canonical" href="https://i18n.emdashcms.com/"> | ||
| <style> | ||
| *{box-sizing:border-box;margin:0} | ||
| :root{--bg:#fff;--fg:#111;--muted:#666;--border:#e5e5e5;--bar-bg:#eee} | ||
| @media(prefers-color-scheme:dark){:root{--bg:#111;--fg:#eee;--muted:#999;--border:#333;--bar-bg:#222}} | ||
| body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);max-width:640px;margin:0 auto;padding:2rem 1rem;line-height:1.5} | ||
| h1{font-size:1.5rem;margin-bottom:.25rem} | ||
| .subtitle{color:var(--muted);margin-bottom:2rem} | ||
| a{color:inherit} | ||
| .locale{border:1px solid var(--border);border-radius:8px;margin-bottom:.75rem;padding:0} | ||
| .locale summary{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding:.75rem 1rem;cursor:pointer;list-style:none} | ||
| .locale summary::-webkit-details-marker{display:none} | ||
| .locale summary strong{flex:1} | ||
| .lang{font-weight:400;color:var(--muted);font-size:.875rem} | ||
| .stats{font-size:.875rem;color:var(--muted)} | ||
| .bar{width:100%;height:6px;background:var(--bar-bg);border-radius:3px;overflow:hidden;flex-basis:100%} | ||
| .fill{height:100%;border-radius:3px;transition:width .3s} | ||
| .completed{background:#22c55e} | ||
| .very-good{background:#84cc16} | ||
| .good{background:#f59e0b} | ||
| .help-needed{background:#ef4444} | ||
| .basic{background:#991b1b} | ||
| .links,.missing,.done{padding:.5rem 1rem .75rem} | ||
| .links{font-size:.875rem} | ||
| .links a{text-decoration:underline} | ||
| .missing summary{font-size:.875rem;cursor:pointer;color:var(--muted)} | ||
| .missing ul{margin-top:.5rem;padding-left:1.5rem;font-size:.8rem;font-family:ui-monospace,monospace} | ||
| .missing li{margin-bottom:.125rem} | ||
| .done{color:var(--muted);font-size:.875rem} | ||
| .footer{margin-top:2rem;font-size:.75rem;color:var(--muted)} | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>EmDash Translation Status</h1> | ||
| <p class="subtitle">Admin UI · ${totalKeys} translatable strings</p> | ||
| ${localeStatuses.map(localeCard).join("\n")} | ||
| <p class="footer">Generated ${new Date().toISOString().split("T")[0]} · Powered by <a href="https://lunaria.dev">Lunaria</a></p> | ||
| </body> | ||
| </html>`; | ||
|
|
||
| const jsonStatus = { | ||
| generatedAt: new Date().toISOString(), | ||
| sourceLocale: { lang: sourceLocale.lang, label: sourceLocale.label, totalKeys }, | ||
| locales: localeStatuses, | ||
| }; | ||
|
|
||
| mkdirSync("i18n/dist", { recursive: true }); | ||
| writeFileSync("i18n/dist/index.html", html); | ||
| writeFileSync("i18n/dist/status.json", JSON.stringify(jsonStatus, null, "\t")); | ||
|
|
||
| console.log(`Generated dashboard: ${localeStatuses.length} locales, ${totalKeys} keys`); | ||
| for (const s of localeStatuses) { | ||
| console.log(` ${s.label} (${s.lang}): ${s.percentComplete}% — ${s.missingKeys.length} missing`); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "name": "@emdash-cms/i18n-dashboard", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "cd .. && node --experimental-strip-types i18n/build.ts", | ||
| "deploy": "node --run build && wrangler deploy" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "catalog:", | ||
| "wrangler": "catalog:" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "module": "preserve", | ||
| "moduleResolution": "bundler", | ||
| "target": "es2022", | ||
| "strict": true, | ||
| "types": ["node"] | ||
| }, | ||
| "include": ["build.ts"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "name": "emdash-i18n", | ||
| "compatibility_date": "2026-04-01", | ||
| "assets": { | ||
| "directory": "./dist", | ||
| }, | ||
| "routes": [ | ||
| { | ||
| "pattern": "i18n.emdashcms.com", | ||
| "zone_name": "emdashcms.com", | ||
| "custom_domain": true, | ||
| }, | ||
| ], | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { defineConfig } from "@lunariajs/core/config"; | ||
|
|
||
| export default defineConfig({ | ||
| repository: { | ||
| name: "emdash-cms/emdash", | ||
| branch: "main", | ||
| }, | ||
| sourceLocale: { | ||
| label: "English", | ||
| lang: "en", | ||
| }, | ||
| locales: [ | ||
| { | ||
| label: "Deutsch", | ||
| lang: "de", | ||
| }, | ||
| ], | ||
| files: [ | ||
| { | ||
| include: ["packages/admin/src/locales/en/messages.po"], | ||
| pattern: "packages/admin/src/locales/@lang/messages.po", | ||
| type: "dictionary", | ||
| }, | ||
| ], | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -24,7 +24,9 @@ | |||||
| "lint:fix": "oxlint --type-aware --fix", | ||||||
| "knip": "knip --no-exit-code --exclude unlisted,unresolved,exports,types,duplicates", | ||||||
| "new": "create-emdash", | ||||||
| "screenshots": "node scripts/screenshot-all-templates.mjs" | ||||||
| "screenshots": "node scripts/screenshot-all-templates.mjs", | ||||||
| "locale:extract": "pnpm --filter @emdash-cms/admin locale:extract", | ||||||
| "locale:compile": "pnpm --filter @emdash-cms/admin locale:compile" | ||||||
| }, | ||||||
| "keywords": [], | ||||||
| "author": "Matt Kane", | ||||||
|
|
@@ -34,6 +36,7 @@ | |||||
| "@changesets/changelog-github": "^0.5.2", | ||||||
| "@changesets/cli": "^2.29.8", | ||||||
| "@e18e/eslint-plugin": "^0.2.0", | ||||||
| "@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc", | ||||||
|
||||||
| "@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc", | |
| "@lunariajs/core": "REPLACE_WITH_EXACT_PUBLISHED_NPM_VERSION", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generated HTML interpolates dynamic values into markup/attributes without escaping (e.g. locale label/lang in text and editUrl/historyUrl inside href). If any of these contain characters like <, &, or ", the dashboard HTML can break and could become an injection vector. Use HTML escaping for all inserted text, and an attribute-safe escape for href values (or build DOM with URL objects + encodeURIComponent for paths) before writing index.html.