Skip to content
Open
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
141 changes: 141 additions & 0 deletions scripts/check-localization.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Review aid, not a substitute for comparing rules with the English source.
const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
const root = path.resolve(__dirname, "..");

function files(dir, suffix) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
return entry.isDirectory()
? files(full, suffix)
: suffix.test(full)
? [full]
: [];
});
}

function suspiciousText(value) {
// Multiword phrases avoid flagging names such as Hope & Fear or Storm.
const reasons = [];
if (
/\b(?:you (?:can|may|must|have)|the target|on a (?:success|failure)|until (?:your|their)|spend a hope|mark a stress|once per (?:scene|session|rest))\b/i.test(
value,
)
)
reasons.push("English rule phrase");
if (
/\b(?:[Vv]erwenden|[Mm]arkieren|[Gg]eben|[Mm]achen|[Bb]itten|[Hh]andeln) Sie\b/.test(
value,
)
)
reasons.push("inconsistent formal imperative");
if (/ZXQ|\uFFFD|\(Deutscher\)/.test(value))
reasons.push("damaged text or translation artifact");
return reasons;
}

function audit() {
const findings = [];
const add = (file, location, reason, text) =>
findings.push({
file: path.relative(root, file),
location,
reason,
text,
});
for (const dir of ["src/data/srd/locales/de", "src/data/locales/de"]) {
for (const file of files(path.join(root, dir), /\.json$/)) {
function visit(value, location) {
if (typeof value === "string") {
for (const reason of suspiciousText(value))
add(file, location, reason, value);
} else if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value))
visit(child, `${location}.${key}`);
}
}
visit(JSON.parse(fs.readFileSync(file, "utf8")), "$");
}
}
const names = new Set([
"DaggerForge",
"Daggerheart",
"Daggerheart © Darrington Press 2025",
]);
const uiSources = ["src/features", "src/utils"].flatMap((dir) =>
files(path.join(root, dir), /\.tsx?$/),
);
uiSources.push(path.join(root, "src/data/levelUpGuide.ts"));
for (const file of uiSources) {
const source = ts.createSourceFile(
file,
fs.readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
);
function visit(node) {
let value;
if (ts.isJsxText(node))
value = node.text.replace(/\s+/g, " ").trim();
if (ts.isStringLiteral(node)) {
const parent = node.parent;
if (
ts.isJsxAttribute(parent) &&
/^(title|placeholder|aria-label|label|alt|hint|actionLabel)$/.test(
parent.name.text,
)
)
value = node.text;
if (
ts.isPropertyAssignment(parent) &&
/^(text|label|title|placeholder|message|confirmLabel|cancelLabel)$/.test(
parent.name.getText(source),
)
)
value = node.text;
if (
ts.isCallExpression(parent) &&
/\.(setName|setDesc|setTitle|setTooltip|setButtonText|setPlaceholder|setText)$/.test(
parent.expression.getText(source),
)
)
value = node.text;
}
if (
value &&
/[a-zA-Z]/.test(value) &&
!names.has(value) &&
!/^wizard\.step\./.test(value)
) {
add(
file,
source.getLineAndCharacterOfPosition(node.getStart(source))
.line + 1,
"unlocalized static UI text",
value,
);
}
ts.forEachChild(node, visit);
}
visit(source);
}
return findings;
}

if (require.main === module) {
const findings = audit();
if (process.argv.includes("--json"))
console.log(JSON.stringify(findings, null, 2));
else {
for (const f of findings)
console.log(
`${f.file}:${f.location}: ${f.reason}: ${f.text.slice(0, 180)}`,
);
console.log(
`${findings.length} findings requiring review. Dynamic templates and semantic mistranslations require manual review.`,
);
}
process.exitCode = findings.length ? 1 : 0;
}
module.exports = { suspiciousText, audit };
107 changes: 107 additions & 0 deletions scripts/extract-core-domain-translations.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";

const [, , pdfArgument, outputArgument = "src/data/srd/locales/de/domains.json"] = process.argv;

if (!pdfArgument) {
throw new Error("Usage: node scripts/extract-core-domain-translations.mjs <cards.pdf> [output.json]");
}

const pdfPath = resolve(pdfArgument);
const outputPath = resolve(outputArgument);
const sourceCards = JSON.parse(readFileSync(new URL("../src/data/srd/domains.json", import.meta.url), "utf8"));
const columns = [28, 208, 388];
const rows = [42, 294, 547];
const translations = new Map();

function slug(value) {
return value
.normalize("NFKD")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}

function extractCard(page, x, y) {
return execFileSync(
"pdftotext",
["-f", String(page), "-l", String(page), "-x", String(x), "-y", String(y), "-W", "180", "-H", "253", "-layout", pdfPath, "-"],
{ encoding: "utf8" },
);
}

function joinParagraph(lines) {
return lines
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
// PDF line wrapping inserts hyphens into ordinary words. Preserve
// intentional compounds such as "Zauber-Attribut" (uppercase continuation),
// but rejoin lowercase continuations such as "durch-\nzuführen".
.replace(/(\p{Ll})-\n(\p{Ll})/gu, "$1$2")
.replace(/\n/g, " ")
.replace(/(\p{L})-\s+(\p{L})/gu, "$1-$2")
.replace(/\s+/g, " ")
.trim();
}

function parseCard(raw) {
const lines = raw.replace(/\f/g, "").split(/\r?\n/);
const footerIndex = lines.findIndex((line) => /DH Basis \d{3}\/270/.test(line));
if (footerIndex < 0) return null;

const number = Number(lines[footerIndex].match(/DH Basis (\d{3})\/270/)[1]);
if (number < 82) return { number };
const typeIndex = lines.findIndex((line) => /^(Fähigkeit|Zauber|Zauberbuch)$/.test(line.trim()));
if (typeIndex < 0) throw new Error(`Card ${number}: type label not found.`);

let bodyIndex = typeIndex + 1;
while (bodyIndex < footerIndex && !lines[bodyIndex].trim()) bodyIndex += 1;
const nameIndex = bodyIndex;
bodyIndex += 1;
while (bodyIndex < footerIndex && /^\s{4,}\S/.test(lines[bodyIndex])) bodyIndex += 1;
const name = joinParagraph(lines.slice(nameIndex, bodyIndex))
.replace(/grimoire/gi, "")
.replace(/\bZauberbuch\b/gi, "")
.replace(/\s+/g, " ")
.trim();

const paragraphs = [];
let paragraph = [];
for (const line of lines.slice(bodyIndex, footerIndex)) {
if (line.trim()) {
paragraph.push(line);
} else if (paragraph.length) {
paragraphs.push(joinParagraph(paragraph));
paragraph = [];
}
}
if (paragraph.length) paragraphs.push(joinParagraph(paragraph));

return { number, name, type: lines[typeIndex].trim(), text: paragraphs.join("\n\n") };
}

for (let page = 1; page <= 30; page += 1) {
for (const y of rows) {
for (const x of columns) {
const parsed = parseCard(extractCard(page, x, y));
if (parsed && parsed.number >= 82) translations.set(parsed.number, parsed);
}
}
}

const output = sourceCards.map((card, index) => {
const number = index + 82;
const translated = translations.get(number);
if (!translated) throw new Error(`Missing German translation for card ${number}.`);
return {
id: `domain-card-${slug(card.domain)}-${card.level}-${slug(card.name)}`,
name: translated.name,
type: translated.type,
text: translated.text,
};
});

writeFileSync(outputPath, `${JSON.stringify(output, null, "\t")}\n`);
console.log(`Wrote ${output.length} German domain-card translations to ${outputPath}.`);
Loading