diff --git a/_tools/bakei18n.ts b/_tools/bakei18n.ts index 9ded7145..b86cc390 100644 --- a/_tools/bakei18n.ts +++ b/_tools/bakei18n.ts @@ -1,11 +1,12 @@ -import { writeFileSync } from "fs"; import { allMessages } from "../src/common/messages/combinedMessages.dev.ts"; -import path from "path"; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; const currentPath = __dirname; const outDir = path.resolve(currentPath, "../src/common/messages/combinedMessages.prod.ts"); -console.log(`Writing to ${outDir}`); +process.stdout.write(`Writing to ${outDir}\n`); writeFileSync( outDir, `export const allMessages: Readonly>>> = ${JSON.stringify(allMessages, null, 4)};` diff --git a/_tools/checkI18nCoverage.ts b/_tools/checkI18nCoverage.ts index 6b51f33b..f8c6d744 100644 --- a/_tools/checkI18nCoverage.ts +++ b/_tools/checkI18nCoverage.ts @@ -1,14 +1,14 @@ -import { readFile } from "fs/promises"; -import { join, resolve } from "path"; import { glob } from "tinyglobby"; import { parse } from "yaml"; import { objectToDotted } from "./messagelib.ts"; +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; -const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/")); +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/")); const files = (await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir })).sort(); -function flattenMessages(src: Record) { +function flattenMessages(src: unknown) { return Object.fromEntries( Object.entries(objectToDotted(src)) .map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value] as const) @@ -20,9 +20,14 @@ function flattenMessages(src: Record) { const localeData = new Map>(); for (const file of files) { const segments = file.split(/[/\\]/); - const locale = segments[segments.length - 1]!.replace(/\.yaml$/, ""); - const content = await readFile(file, "utf-8"); - localeData.set(locale, flattenMessages(parse(content) ?? {})); + const localeFilename = segments[segments.length - 1]; + if (localeFilename === undefined) { + throw new Error(`Could not determine the locale name for ${file}`); + } + const locale = localeFilename.replace(/\.yaml$/, ""); + const content = await fsPromises.readFile(file, "utf-8"); + const parsed: unknown = parse(content); + localeData.set(locale, flattenMessages(parsed ?? {})); } const baseLocale = "en"; @@ -55,4 +60,4 @@ const report = Object.fromEntries( }) ); -console.log(JSON.stringify(report, null, 2)); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/_tools/decompileRosetta.ts b/_tools/decompileRosetta.ts index 9c8b2600..c2716382 100644 --- a/_tools/decompileRosetta.ts +++ b/_tools/decompileRosetta.ts @@ -1,9 +1,8 @@ -import { writeFileSync } from "fs"; - import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../src/common/rosetta"; import { allMessages } from "../src/common/messages/combinedMessages.dev.ts"; -import path from "path"; +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); const thisFileDir = __dirname; const outDir = path.join(thisFileDir, "i18n"); diff --git a/_tools/decompileRosettaToJson.ts b/_tools/decompileRosettaToJson.ts index 5eb8a65d..030c067e 100644 --- a/_tools/decompileRosettaToJson.ts +++ b/_tools/decompileRosettaToJson.ts @@ -1,26 +1,17 @@ -import { writeFileSync } from "fs"; - import { allMessages } from "../src/common/messages/combinedMessages.prod.ts"; const __dirname = import.meta.dirname; -import path from "path"; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); const thisFileDir = __dirname; const outDir = path.resolve(thisFileDir, "../src/common/messagesJson"); const out = {} as Record; for (const [key, value] of Object.entries(allMessages)) { - //@ts-ignore - for (const [lang, langValue] of Object.entries(allMessages[key])) { + for (const [lang, langValue] of Object.entries(value)) { if (!out[lang]) out[lang] = {}; - if (lang in value) { - out[lang][key] = langValue as string; - } else { - if (lang === "def") { - out[lang][key] = key; - } else { - out[lang][key] = undefined; - } - } + out[lang][key] = langValue; } } diff --git a/_tools/inspect-troubleshooting-docs.ts b/_tools/inspect-troubleshooting-docs.ts index e7de1545..245bf3da 100644 --- a/_tools/inspect-troubleshooting-docs.ts +++ b/_tools/inspect-troubleshooting-docs.ts @@ -1,6 +1,6 @@ -import { access, readFile } from "node:fs/promises"; -import { dirname, relative, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); +const url = process.getBuiltinModule("node:url"); type InspectionError = { check: "current-label" | "local-reference" | "retired-label"; @@ -20,7 +20,7 @@ const messageCataloguePath = "src/common/messagesJson/en.json"; const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu; function repositoryRootFromThisFile(): string { - return resolve(dirname(fileURLToPath(import.meta.url)), ".."); + return path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); } function normaliseReferenceTarget(rawTarget: string): string { @@ -48,14 +48,14 @@ async function inspectLocalReferences( const [pathPart] = target.split("#", 1); if (!pathPart) continue; checked++; - const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart); + const referencedPath = path.resolve(repositoryRoot, path.dirname(documentPath), pathPart); try { - await access(referencedPath); + await fsPromises.access(referencedPath); } catch { errors.push({ check: "local-reference", file: documentPath, - detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`, + detail: `Missing local reference: ${path.relative(repositoryRoot, referencedPath)}`, }); } } @@ -68,14 +68,13 @@ export async function inspectTroubleshootingDocs( const errors: InspectionError[] = []; const documents = new Map(); for (const guidePath of guidePaths) { - documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8")); + documents.set(guidePath, await fsPromises.readFile(path.resolve(repositoryRoot, guidePath), "utf8")); } const troubleshooting = documents.get("docs/troubleshooting.md")!; - const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record< - string, - string - >; + const catalogue = JSON.parse( + await fsPromises.readFile(path.resolve(repositoryRoot, messageCataloguePath), "utf8") + ) as Record; const requiredMessageKeys = [ "TweakMismatchResolve.Action.UseConfigured", "TweakMismatchResolve.Action.UseMine", @@ -132,7 +131,7 @@ async function runCli(): Promise { if (!result.ok) process.exitCode = 1; } -const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; +const invokedPath = process.argv[1] ? url.pathToFileURL(path.resolve(process.argv[1])).href : undefined; if (invokedPath === import.meta.url) { await runCli(); } diff --git a/_tools/json2yaml.ts b/_tools/json2yaml.ts index de8cae87..299b5d6b 100644 --- a/_tools/json2yaml.ts +++ b/_tools/json2yaml.ts @@ -1,27 +1,31 @@ // Convert Application convenient Message Resources (JSON) to Human-Editable format (YAML) -import { readFile, writeFile } from "fs/promises"; -import { join, resolve } from "path"; import { stringify } from "yaml"; import { glob } from "tinyglobby"; import { dottedToObject } from "./messagelib"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; -const targetDir = resolve(join(__dirname, "../src/common/messagesJson/")); -console.log(`Target directory: ${targetDir}`); +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesJson/")); +process.stdout.write(`Target directory: ${targetDir}\n`); const files = await glob(`*.json`, { expandDirectories: false, absolute: true, cwd: targetDir }); for (const file of files) { - const filePath = resolve(file); - console.log(`Processing file: ${filePath}`); - const content = await readFile(filePath, "utf-8"); - const jsonDataSrc = JSON.parse(content); + const filePath = path.resolve(file); + process.stdout.write(`Processing file: ${filePath}\n`); + const content = await fsPromises.readFile(filePath, "utf-8"); + const jsonDataSrc: unknown = JSON.parse(content); + if (typeof jsonDataSrc !== "object" || jsonDataSrc === null || Array.isArray(jsonDataSrc)) { + throw new TypeError(`Expected ${filePath} to contain a JSON object`); + } const jsonDataD2 = Object.fromEntries( Object.entries(jsonDataSrc).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) ); const jsonData = dottedToObject(jsonDataD2); const yamlData = stringify(jsonData, { indent: 2 }); const yamlFilePath = filePath.replace(/\.json$/, ".yaml").replace("Json", "YAML"); - await writeFile(yamlFilePath, yamlData, "utf-8"); - console.log(`Converted ${filePath} to ${yamlFilePath}`); + await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8"); + process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`); } // console.dir(files, { depth: 0 }); diff --git a/_tools/messagelib.ts b/_tools/messagelib.ts index 6243ffad..95a4c664 100644 --- a/_tools/messagelib.ts +++ b/_tools/messagelib.ts @@ -1,38 +1,49 @@ -export function objectToDotted(obj: any, prefix = ""): Record { - return Object.entries(obj).reduce( - (acc, [key, value]) => { - const newKey = prefix ? `${prefix}.${key}` : key; - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - Object.assign(acc, objectToDotted(value, newKey)); - } else { - acc[newKey] = value; - } - return acc; - }, - {} as Record - ); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -export function dottedToObject(obj: Record): Record { - return Object.entries(obj).reduce( - (acc, [key, value]) => { - if (key.includes(" ")) { - // Return as is. - return { ...acc, [key]: value }; // Skip keys with spaces - } - const keys = key.split("."); - keys.reduce((nestedAcc, currKey, index) => { - if (currKey in nestedAcc && typeof nestedAcc[currKey] !== "object") { - nestedAcc[currKey] = { _value: nestedAcc[currKey] }; // Convert to object if not already - } - if (index === keys.length - 1) { - nestedAcc[currKey] = value; - } else { - nestedAcc[currKey] = nestedAcc[currKey] || {}; - } - return nestedAcc[currKey]; - }, acc); - return acc; - }, - {} as Record - ); + +export function objectToDotted(obj: unknown, prefix = ""): Record { + if (!isRecord(obj)) { + throw new TypeError("Expected a message catalogue object"); + } + const flattened: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key; + if (isRecord(value)) { + Object.assign(flattened, objectToDotted(value, newKey)); + } else { + flattened[newKey] = value; + } + } + return flattened; +} + +export function dottedToObject(obj: unknown): Record { + if (!isRecord(obj)) { + throw new TypeError("Expected a dotted message catalogue object"); + } + const nestedResult: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key.includes(" ")) { + nestedResult[key] = value; + continue; + } + const keys = key.split("."); + let nested = nestedResult; + for (const [index, currentKey] of keys.entries()) { + if (index === keys.length - 1) { + nested[currentKey] = value; + continue; + } + const currentValue = nested[currentKey]; + if (isRecord(currentValue)) { + nested = currentValue; + continue; + } + const replacement = currentValue === undefined || currentValue === null ? {} : { _value: currentValue }; + nested[currentKey] = replacement; + nested = replacement; + } + } + return nestedResult; } diff --git a/_tools/messagelib.unit.spec.ts b/_tools/messagelib.unit.spec.ts new file mode 100644 index 00000000..ae8f612a --- /dev/null +++ b/_tools/messagelib.unit.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { dottedToObject, objectToDotted } from "./messagelib"; + +describe("message catalogue conversion", () => { + it("flattens nested objects while preserving leaf values", () => { + expect( + objectToDotted({ + dialogue: { + title: "Title", + options: ["first", "second"], + }, + "literal key": "Literal", + }) + ).toEqual({ + "dialogue.title": "Title", + "dialogue.options": ["first", "second"], + "literal key": "Literal", + }); + }); + + it("preserves an existing leaf under _value when a dotted child follows it", () => { + expect( + dottedToObject({ + section: "Base value", + "section.child": "Child value", + "literal key": "Literal", + }) + ).toEqual({ + section: { + _value: "Base value", + child: "Child value", + }, + "literal key": "Literal", + }); + }); + + it("rejects non-object catalogue roots", () => { + expect(() => objectToDotted("not an object")).toThrow("Expected a message catalogue object"); + expect(() => dottedToObject(["not", "an", "object"])).toThrow("Expected a dotted message catalogue object"); + }); +}); diff --git a/_tools/yaml2json.ts b/_tools/yaml2json.ts index 484ef2a5..d35a39d3 100644 --- a/_tools/yaml2json.ts +++ b/_tools/yaml2json.ts @@ -1,29 +1,30 @@ // Convert Human-Editable format (YAML) to Application convenient Message Resources (JSON) -import { readFile, writeFile } from "fs/promises"; -import { join, resolve } from "path"; import { parse } from "yaml"; import { glob } from "tinyglobby"; import { objectToDotted } from "./messagelib"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; -const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/")); -console.log(`Target directory: ${targetDir}`); +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/")); +process.stdout.write(`Target directory: ${targetDir}\n`); const files = await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir }); for (const file of files) { - const filePath = resolve(file); - const content = await readFile(filePath, "utf-8"); - const jsonDataSrc = parse(content); + const filePath = path.resolve(file); + const content = await fsPromises.readFile(filePath, "utf-8"); + const jsonDataSrc: unknown = parse(content); const jsonDataD2 = objectToDotted(jsonDataSrc); const jsonData = Object.fromEntries( Object.entries(jsonDataD2) - .map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value]) + .map(([key, value]): [string, unknown] => [key.endsWith("._value") ? key.slice(0, -7) : key, value]) .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) ); const yamlData = JSON.stringify(jsonData, null, 4) + "\n"; const yamlFilePath = filePath.replace(/\.yaml$/, ".json").replace("YAML", "Json"); - await writeFile(yamlFilePath, yamlData, "utf-8"); - console.log(`Converted ${filePath} to ${yamlFilePath}`); + await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8"); + process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`); } // console.dir(files, { depth: 0 }); diff --git a/package.json b/package.json index d2ab993c..3d66a4b5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "buildDev": "node esbuild.config.mjs dev", "lint": "eslint --cache --cache-strategy content --concurrency off src", "lint:community": "eslint --config eslint.community.config.mjs --concurrency off src", + "lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools", "svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "tsc-check": "tsc --noEmit", "tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json", @@ -22,7 +23,7 @@ "prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ", "precheck:compatibility": "npm run build", "check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15", - "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility", + "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility", "i18n:bake": "npm run i18n:yaml2json && npm run i18n:bakejson && npm run i18n:format", "i18n:bakejson": "tsx _tools/bakei18n.ts", "i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",