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
29 changes: 29 additions & 0 deletions .github/workflows/lunaria.yml
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
1 change: 1 addition & 0 deletions i18n/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
164 changes: 164 additions & 0 deletions i18n/build.ts
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, "&amp;").replace(LT, "&lt;").replace(GT, "&gt;").replace(QUOT, "&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>` : ""}
Comment on lines +84 to +94

Copilot AI Apr 11, 2026

Copy link

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.

Suggested change
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>` : ""}
function escapeHtmlAttribute(s: string): string {
return escapeHtml(s);
}
function localeCard(s: LocaleStatus): string {
return `
<details class="locale">
<summary>
<strong>${escapeHtml(s.label)} <span class="lang">${escapeHtml(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="${escapeHtmlAttribute(s.editUrl)}">Edit translation</a>
${s.historyUrl ? `· <a href="${escapeHtmlAttribute(s.historyUrl)}">History</a>` : ""}

Copilot uses AI. Check for mistakes.
</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`);
}
13 changes: 13 additions & 0 deletions i18n/package.json
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:"
}
}
10 changes: 10 additions & 0 deletions i18n/tsconfig.json
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"]
}
14 changes: 14 additions & 0 deletions i18n/wrangler.jsonc
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,
},
],
}
2 changes: 1 addition & 1 deletion lingui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf";

const config: LinguiConfig = {
sourceLocale: "en",
locales: ["en"],
locales: ["en", "de"],
catalogs: [
{
path: "<rootDir>/packages/admin/src/locales/{locale}/messages",
Expand Down
25 changes: 25 additions & 0 deletions lunaria.config.ts
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",
},
],
});
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lunariajs/core is being pulled from pkg.pr.new (a PR tarball URL). This can reduce reproducibility and can break installs if the tarball is GC’d or the service is unavailable. Prefer a published npm version (or a pinned git URL/commit in a registry you control) once the upstream change is released, and document the rationale if this must stay temporary.

Suggested change
"@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc",
"@lunariajs/core": "REPLACE_WITH_EXACT_PUBLISHED_NPM_VERSION",

Copilot uses AI. Check for mistakes.
"@playwright/test": "^1.58.0",
"@types/node": "catalog:",
"@typescript/native-preview": "^7.0.0-dev",
Expand Down
Loading
Loading