You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
579 lines
20 KiB
579 lines
20 KiB
/**
|
|
* translations-check — validate the texts/idlangref discipline across UIX.
|
|
*
|
|
* Runs five independent checks and exits non-zero if any of them fails:
|
|
*
|
|
* 1. **Morfo `texts:` shape**: every value is an absolute idlangref
|
|
* (`'#?…|fallback'`). Component-scoped refs (`#?components.{kebab}.…`) must
|
|
* match the morfo's own `kebab`.
|
|
*
|
|
* 2. **Compiled ref coverage**: every `v.translationRef` / `v.commonRef` /
|
|
* `v.langRef` in a morfo's aria/data contract — as NORMALIZED by the real
|
|
* compiler (`compileMorfo`, the same path the runtime resolves) — points to
|
|
* a catalog leaf. This is the check that catches the silent-fallback class:
|
|
* `v.translationRef('prevMonth')` with a catalog key `prev-month` compiles,
|
|
* runs, and ships the English fallback on every locale (measured 2026-08-10
|
|
* in chronos: five labels). `texts:` is a declaration, never a lookup table,
|
|
* so only the compiled key can be validated.
|
|
*
|
|
* 3. **Component catalog coverage**: every component-scoped idlangref in
|
|
* source points into `src/uix/langs/components/{kebab}.ts`. The dotted path
|
|
* must resolve to a leaf `LangRecord` (object whose values are strings).
|
|
*
|
|
* 4. **Common catalog coverage**: every `#?common.…` idlangref points into
|
|
* `src/uix/langs/common.ts`.
|
|
*
|
|
* 5. **Orphan detection**: every leaf in the component catalogs is referenced
|
|
* by a `morfo.texts` entry, a compiled morfo ref, or a source file. Orphan
|
|
* entries are surfaced as warnings (not errors) so consumers can remove
|
|
* them safely.
|
|
*
|
|
* Also enforces:
|
|
* - No raw `translations:` field survives on any morfo.
|
|
* - No source file (.ts / .svelte under src/uix) declares an idlangref to a
|
|
* nonexistent path.
|
|
*
|
|
* The catalogs and morfos are IMPORTED (they are pure data modules) and morfos
|
|
* are compiled with the real `compileMorfo` — never re-parsed with regex. The
|
|
* previous implementation regex-evaluated the catalog sources and its comment
|
|
* stripper corrupted any string containing `//` (a `https://` URL crashed the
|
|
* whole run), while the class it was credited with catching — translationRef
|
|
* call sites — was never scanned at all: a `v.translationRef('key')` contains
|
|
* no `#?` literal until the compiler normalizes it. Validating the compiled
|
|
* contract closes both holes at once.
|
|
*
|
|
* `.ts` sources are scanned via the TypeScript AST (string literals only, so a
|
|
* `//` inside a string can never eat the rest of the line and comments never
|
|
* produce false references). `.svelte` sources get their `<script>` blocks
|
|
* AST-scanned the same way; the remaining template only has HTML comments
|
|
* stripped — line comments are left alone there on purpose, because stripping
|
|
* them is exactly what turned URLs into silent false negatives.
|
|
*/
|
|
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import ts from 'typescript';
|
|
|
|
import { compileMorfo } from '../src/uix/morfo/compile';
|
|
import type { SourcePlan } from '../src/uix/morfo/compile';
|
|
import type { Morfo } from '../src/uix/morfo/types';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = join(HERE, '..');
|
|
const UIX = join(REPO, 'src', 'uix');
|
|
const MORFO_DIR = join(UIX, 'morfo', 'components');
|
|
const LANGS_COMMON = join(UIX, 'langs', 'common.ts');
|
|
const LANGS_COMPONENTS = join(UIX, 'langs', 'components');
|
|
|
|
type LangLeaf = Record<string, string>;
|
|
type LangBranch = { [key: string]: LangLeaf | LangBranch };
|
|
|
|
interface Finding {
|
|
severity: 'error' | 'warn';
|
|
source: string;
|
|
message: string;
|
|
}
|
|
|
|
const findings: Finding[] = [];
|
|
function err(source: string, message: string) {
|
|
findings.push({ severity: 'error', source, message });
|
|
}
|
|
function warn(source: string, message: string) {
|
|
findings.push({ severity: 'warn', source, message });
|
|
}
|
|
|
|
// ── Module loading ────────────────────────────────────────────────────────
|
|
|
|
async function importModule(file: string): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
return (await import(pathToFileURL(file).href)) as Record<string, unknown>;
|
|
} catch (e) {
|
|
err(file, `Failed to import: ${e instanceof Error ? e.message : String(e)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function kebabToCamel(kebab: string): string {
|
|
return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
}
|
|
|
|
async function loadCommonCatalog(): Promise<LangBranch> {
|
|
const mod = await importModule(LANGS_COMMON);
|
|
const branch = mod?.commonLangs as LangBranch | undefined;
|
|
if (!branch) {
|
|
err(LANGS_COMMON, 'No commonLangs export found.');
|
|
return {};
|
|
}
|
|
return branch;
|
|
}
|
|
|
|
async function loadComponentCatalogs(): Promise<Map<string, LangBranch>> {
|
|
const map = new Map<string, LangBranch>();
|
|
const files = readdirSync(LANGS_COMPONENTS).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
|
|
for (const file of files) {
|
|
const kebab = file.replace(/\.ts$/, '');
|
|
const exportName = `${kebabToCamel(kebab)}Langs`;
|
|
const mod = await importModule(join(LANGS_COMPONENTS, file));
|
|
if (!mod) continue;
|
|
const data = mod[exportName] as LangBranch | undefined;
|
|
if (!data) {
|
|
err(join(LANGS_COMPONENTS, file), `No export const ${exportName} found.`);
|
|
continue;
|
|
}
|
|
map.set(kebab, data);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
interface LoadedMorfo {
|
|
file: string;
|
|
morfo: Morfo;
|
|
}
|
|
|
|
function looksLikeMorfo(x: unknown): x is Morfo {
|
|
return (
|
|
!!x &&
|
|
typeof x === 'object' &&
|
|
typeof (x as Morfo).kebab === 'string' &&
|
|
Array.isArray((x as Morfo).parts)
|
|
);
|
|
}
|
|
|
|
async function loadMorfos(): Promise<LoadedMorfo[]> {
|
|
const out: LoadedMorfo[] = [];
|
|
// Dedupe by identity: a module that re-exports another component's morfo
|
|
// must not double-count it (compileMorfo caches by identity for the same
|
|
// reason).
|
|
const seen = new Set<unknown>();
|
|
const files = readdirSync(MORFO_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
|
for (const file of files) {
|
|
const path = join(MORFO_DIR, file);
|
|
const mod = await importModule(path);
|
|
if (!mod) continue;
|
|
for (const exported of Object.values(mod)) {
|
|
if (!looksLikeMorfo(exported) || seen.has(exported)) continue;
|
|
seen.add(exported);
|
|
out.push({ file: path, morfo: exported });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── Path resolution inside a catalog branch ───────────────────────────────
|
|
|
|
function resolvePath(node: LangBranch, path: string[]): unknown {
|
|
let cur: unknown = node;
|
|
for (const seg of path) {
|
|
if (cur == null || typeof cur !== 'object') return undefined;
|
|
cur = (cur as Record<string, unknown>)[seg];
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
function isLeafRecord(v: unknown): v is LangLeaf {
|
|
return (
|
|
typeof v === 'object' &&
|
|
v !== null &&
|
|
!Array.isArray(v) &&
|
|
Object.values(v as Record<string, unknown>).every((x) => typeof x === 'string')
|
|
);
|
|
}
|
|
|
|
function collectLeafPaths(node: unknown, prefix: string[] = []): string[][] {
|
|
if (isLeafRecord(node)) return [prefix];
|
|
if (typeof node !== 'object' || node === null) return [];
|
|
return Object.entries(node).flatMap(([k, v]) => collectLeafPaths(v, [...prefix, k]));
|
|
}
|
|
|
|
// ── Morfo texts + compiled refs ───────────────────────────────────────────
|
|
|
|
interface MorfoTextEntry {
|
|
file: string;
|
|
kebab: string;
|
|
textKey: string;
|
|
rawRef: string;
|
|
}
|
|
|
|
function morfoTextEntries(loaded: LoadedMorfo[]): MorfoTextEntry[] {
|
|
const out: MorfoTextEntry[] = [];
|
|
for (const { file, morfo } of loaded) {
|
|
// Hard ban: no legacy `translations:` field.
|
|
if ('translations' in (morfo as object)) {
|
|
err(file, 'Legacy `translations:` field present. Replace with `texts:` idlangrefs.');
|
|
}
|
|
const texts = (morfo as { texts?: Record<string, string> }).texts ?? {};
|
|
for (const [textKey, rawRef] of Object.entries(texts)) {
|
|
out.push({ file, kebab: morfo.kebab, textKey, rawRef });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface CompiledRefEntry {
|
|
file: string;
|
|
kebab: string;
|
|
part: string;
|
|
attr: string;
|
|
/** Normalized idlangref as the runtime will resolve it: `#?path|fallback`. */
|
|
ref: string;
|
|
}
|
|
|
|
function visitSource(source: SourcePlan, onRef: (ref: string) => void): void {
|
|
if (source.kind === 'mapRef') {
|
|
visitSource(source.source, onRef);
|
|
return;
|
|
}
|
|
if (source.kind === 'translationRef') onRef(source.key);
|
|
}
|
|
|
|
function collectCompiledRefs(loaded: LoadedMorfo[]): CompiledRefEntry[] {
|
|
const out: CompiledRefEntry[] = [];
|
|
for (const { file, morfo } of loaded) {
|
|
let compiled;
|
|
try {
|
|
compiled = compileMorfo(morfo);
|
|
} catch (e) {
|
|
err(file, `compileMorfo failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
continue;
|
|
}
|
|
for (const [partKebab, part] of compiled.parts.byKebab) {
|
|
for (const plan of part.dynamicAttrs) {
|
|
visitSource(plan.source, (ref) => {
|
|
out.push({ file, kebab: compiled.kebab, part: partKebab, attr: plan.attr, ref });
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── Source idlangref scanning ─────────────────────────────────────────────
|
|
|
|
function listSourceFiles(root: string): string[] {
|
|
if (!statSync(root).isDirectory()) return [];
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(root)) {
|
|
const path = join(root, entry);
|
|
const stat = statSync(path);
|
|
if (stat.isDirectory()) {
|
|
out.push(...listSourceFiles(path));
|
|
} else if (
|
|
(entry.endsWith('.ts') || entry.endsWith('.svelte')) &&
|
|
!entry.endsWith('.test.ts') &&
|
|
!entry.endsWith('.test.svelte')
|
|
) {
|
|
out.push(path);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Only match refs with no `{` `}` `:` whitespace — those are real idlangrefs,
|
|
// not doc placeholders like `#?components.{kebab}.{key}`.
|
|
const IDLANGREF_RE = /#\?([A-Za-z][\w.-]*)(?:\|([^'"`\n]*))?/g;
|
|
|
|
/**
|
|
* A text chunk extracted from a source file. `truncated` marks template-literal
|
|
* heads / middle spans: their text ends where a `${…}` substitution begins, so
|
|
* a ref match that reaches the chunk's end is INCOMPLETE — a dynamic ref like
|
|
* `` `#?components.x.${key}|…` `` must not be validated as the literal path
|
|
* `components.x.`; it contributes a dynamic PREFIX instead.
|
|
*/
|
|
interface SourceChunk {
|
|
text: string;
|
|
truncated: boolean;
|
|
}
|
|
|
|
/** Every string-literal text in a TS source, via the real parser. */
|
|
function stringChunksOf(fileName: string, source: string): SourceChunk[] {
|
|
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false);
|
|
const out: SourceChunk[] = [];
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isStringLiteralLike(node)) {
|
|
out.push({ text: node.text, truncated: false });
|
|
} else if (ts.isTemplateExpression(node)) {
|
|
out.push({ text: node.head.text, truncated: true });
|
|
for (let i = 0; i < node.templateSpans.length; i++) {
|
|
// Every span's literal but the last is followed by another `${…}`.
|
|
const last = i === node.templateSpans.length - 1;
|
|
out.push({ text: node.templateSpans[i].literal.text, truncated: !last });
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sf);
|
|
return out;
|
|
}
|
|
|
|
interface SourceScan {
|
|
/** ref path → files that reference it. */
|
|
occurrences: Map<string, Set<string>>;
|
|
/**
|
|
* Path prefixes of refs built dynamically (template substitution right
|
|
* after the prefix). A catalog leaf under one of these cannot be proven
|
|
* orphan — the concrete key only exists at runtime.
|
|
*/
|
|
dynamicPrefixes: Set<string>;
|
|
}
|
|
|
|
function scanChunk(chunk: SourceChunk, file: string, scan: SourceScan): void {
|
|
IDLANGREF_RE.lastIndex = 0;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = IDLANGREF_RE.exec(chunk.text))) {
|
|
const reachesEnd = m.index + m[0].length === chunk.text.length;
|
|
if (chunk.truncated && reachesEnd) {
|
|
scan.dynamicPrefixes.add(m[1].replace(/\.$/, ''));
|
|
continue;
|
|
}
|
|
if (!scan.occurrences.has(m[1])) scan.occurrences.set(m[1], new Set());
|
|
scan.occurrences.get(m[1])!.add(file);
|
|
}
|
|
}
|
|
|
|
function refChunksOfFile(path: string): SourceChunk[] {
|
|
const source = readFileSync(path, 'utf8');
|
|
if (!path.endsWith('.svelte')) return stringChunksOf(path, source);
|
|
const chunks: SourceChunk[] = [];
|
|
const scriptRe = /<script[^>]*>([\s\S]*?)<\/script>/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = scriptRe.exec(source))) {
|
|
chunks.push(...stringChunksOf(path, m[1]));
|
|
}
|
|
// Template remainder: strip HTML comments only. `//` stays — it lives
|
|
// inside URLs in quoted strings, and eating the rest of the line is how
|
|
// real refs went unscanned.
|
|
chunks.push({
|
|
text: source.replace(scriptRe, '').replace(/<!--[\s\S]*?-->/g, ''),
|
|
truncated: false
|
|
});
|
|
return chunks;
|
|
}
|
|
|
|
function scanIdlangrefsInSource(): SourceScan {
|
|
const scan: SourceScan = { occurrences: new Map(), dynamicPrefixes: new Set() };
|
|
for (const file of listSourceFiles(UIX)) {
|
|
for (const chunk of refChunksOfFile(file)) {
|
|
scanChunk(chunk, file, scan);
|
|
}
|
|
}
|
|
return scan;
|
|
}
|
|
|
|
// ── Checks ────────────────────────────────────────────────────────────────
|
|
|
|
function checkMorfoTextsShape(
|
|
entries: MorfoTextEntry[],
|
|
components: Map<string, LangBranch>,
|
|
common: LangBranch
|
|
): void {
|
|
for (const { file, kebab, textKey, rawRef } of entries) {
|
|
if (!rawRef.startsWith('#?')) {
|
|
err(
|
|
file,
|
|
`texts.${textKey}: must be an absolute idlangref starting with '#?'. Got ${JSON.stringify(rawRef)}.`
|
|
);
|
|
continue;
|
|
}
|
|
const body = rawRef.slice(2);
|
|
const pipe = body.indexOf('|');
|
|
const refPath = pipe === -1 ? body : body.slice(0, pipe);
|
|
const fallback = pipe === -1 ? undefined : body.slice(pipe + 1);
|
|
|
|
if (!fallback) {
|
|
warn(file, `texts.${textKey}: idlangref ${rawRef} has no fallback after '|'.`);
|
|
}
|
|
|
|
const segments = refPath.split('.');
|
|
if (segments[0] === 'components') {
|
|
if (segments[1] !== kebab) {
|
|
err(
|
|
file,
|
|
`texts.${textKey}: ref points to ${segments[1]} but the morfo kebab is ${kebab}.`
|
|
);
|
|
continue;
|
|
}
|
|
const branch = components.get(kebab);
|
|
if (!branch) {
|
|
err(file, `texts.${textKey}: no catalog found at src/uix/langs/components/${kebab}.ts.`);
|
|
continue;
|
|
}
|
|
const leaf = resolvePath(branch, segments.slice(2));
|
|
if (!isLeafRecord(leaf)) {
|
|
err(
|
|
file,
|
|
`texts.${textKey}: ref ${refPath} does not resolve to a LangRecord leaf in components/${kebab}.ts.`
|
|
);
|
|
}
|
|
} else if (segments[0] === 'common') {
|
|
const leaf = resolvePath(common, segments.slice(1));
|
|
if (!isLeafRecord(leaf)) {
|
|
err(
|
|
file,
|
|
`texts.${textKey}: ref ${refPath} does not resolve to a LangRecord leaf in langs/common.ts.`
|
|
);
|
|
}
|
|
} else {
|
|
// app-scoped refs like `#?app.foo.bar`: cannot validate inside UIX.
|
|
warn(
|
|
file,
|
|
`texts.${textKey}: ref ${refPath} is outside common/components; not validated by UIX.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkCompiledRefs(
|
|
entries: CompiledRefEntry[],
|
|
components: Map<string, LangBranch>,
|
|
common: LangBranch
|
|
): void {
|
|
for (const { file, kebab, part, attr, ref } of entries) {
|
|
const body = ref.startsWith('#?') ? ref.slice(2) : ref;
|
|
const pipe = body.indexOf('|');
|
|
const refPath = pipe === -1 ? body : body.slice(0, pipe);
|
|
const segments = refPath.split('.');
|
|
const where = `${kebab}.${part} ${attr}`;
|
|
if (segments[0] === 'components') {
|
|
if (segments[1] !== kebab) {
|
|
err(
|
|
file,
|
|
`${where}: compiled ref ${refPath} points into components.${segments[1]} but the morfo kebab is ${kebab}.`
|
|
);
|
|
continue;
|
|
}
|
|
const branch = components.get(kebab);
|
|
if (!branch) {
|
|
err(
|
|
file,
|
|
`${where}: no catalog at src/uix/langs/components/${kebab}.ts for compiled ref ${refPath}.`
|
|
);
|
|
continue;
|
|
}
|
|
const leaf = resolvePath(branch, segments.slice(2));
|
|
if (!isLeafRecord(leaf)) {
|
|
err(
|
|
file,
|
|
`${where}: compiled ref ${refPath} does not resolve to a leaf in components/${kebab}.ts — ` +
|
|
'translationRef takes the CATALOG key verbatim; an alias dies to the English fallback.'
|
|
);
|
|
}
|
|
} else if (segments[0] === 'common') {
|
|
const leaf = resolvePath(common, segments.slice(1));
|
|
if (!isLeafRecord(leaf)) {
|
|
err(
|
|
file,
|
|
`${where}: compiled ref ${refPath} does not resolve to a leaf in langs/common.ts — ` +
|
|
'commonRef takes the CATALOG group verbatim; an alias dies to the English fallback.'
|
|
);
|
|
}
|
|
} else {
|
|
warn(
|
|
file,
|
|
`${where}: compiled ref ${refPath} is outside common/components; not validated by UIX.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkComponentCatalogOrphans(
|
|
components: Map<string, LangBranch>,
|
|
morfoEntries: MorfoTextEntry[],
|
|
compiledRefs: CompiledRefEntry[],
|
|
scan: SourceScan
|
|
): void {
|
|
const referenced = new Set<string>();
|
|
for (const e of morfoEntries) {
|
|
const refPath = e.rawRef.slice(2).split('|')[0];
|
|
referenced.add(refPath);
|
|
}
|
|
for (const e of compiledRefs) {
|
|
const body = e.ref.startsWith('#?') ? e.ref.slice(2) : e.ref;
|
|
referenced.add(body.split('|')[0]);
|
|
}
|
|
for (const path of scan.occurrences.keys()) referenced.add(path);
|
|
const dynamicPrefixes = [...scan.dynamicPrefixes];
|
|
|
|
for (const [kebab, branch] of components) {
|
|
const leaves = collectLeafPaths(branch);
|
|
for (const leafPath of leaves) {
|
|
const full = `components.${kebab}.${leafPath.join('.')}`;
|
|
if (referenced.has(full)) continue;
|
|
// A ref built at runtime (`#?components.x.${key}`) can reach any leaf
|
|
// under its prefix — those cannot be proven orphan.
|
|
if (dynamicPrefixes.some((p) => full === p || full.startsWith(`${p}.`))) continue;
|
|
warn(
|
|
join(LANGS_COMPONENTS, `${kebab}.ts`),
|
|
`Orphan: ${full} is not referenced by any morfo.texts entry, compiled ref or source file.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkSourceIdlangrefs(
|
|
occurrences: Map<string, Set<string>>,
|
|
components: Map<string, LangBranch>,
|
|
common: LangBranch
|
|
): void {
|
|
for (const [path, files] of occurrences) {
|
|
const segments = path.split('.');
|
|
if (segments[0] === 'components') {
|
|
const kebab = segments[1];
|
|
const branch = components.get(kebab);
|
|
if (!branch) continue; // not a UIX-owned component
|
|
const leaf = resolvePath(branch, segments.slice(2));
|
|
if (!isLeafRecord(leaf)) {
|
|
for (const file of files) {
|
|
err(file, `idlangref ${path} does not resolve to a leaf in components/${kebab}.ts.`);
|
|
}
|
|
}
|
|
} else if (segments[0] === 'common') {
|
|
const leaf = resolvePath(common, segments.slice(1));
|
|
if (!isLeafRecord(leaf)) {
|
|
for (const file of files) {
|
|
err(file, `idlangref ${path} does not resolve to a leaf in langs/common.ts.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const common = await loadCommonCatalog();
|
|
const components = await loadComponentCatalogs();
|
|
const morfos = await loadMorfos();
|
|
const morfoEntries = morfoTextEntries(morfos);
|
|
const compiledRefs = collectCompiledRefs(morfos);
|
|
const scan = scanIdlangrefsInSource();
|
|
|
|
checkMorfoTextsShape(morfoEntries, components, common);
|
|
checkCompiledRefs(compiledRefs, components, common);
|
|
checkSourceIdlangrefs(scan.occurrences, components, common);
|
|
checkComponentCatalogOrphans(components, morfoEntries, compiledRefs, scan);
|
|
|
|
const errors = findings.filter((f) => f.severity === 'error');
|
|
const warnings = findings.filter((f) => f.severity === 'warn');
|
|
|
|
for (const f of findings) {
|
|
const rel = f.source.replace(REPO + '\\', '').replace(REPO + '/', '');
|
|
const tag = f.severity === 'error' ? 'ERROR' : 'WARN';
|
|
console.log(`${tag} ${rel} ${f.message}`);
|
|
}
|
|
|
|
console.log();
|
|
console.log(
|
|
`Summary: ${morfos.length} morfos · ${morfoEntries.length} texts refs · ` +
|
|
`${compiledRefs.length} compiled refs · ${components.size} component catalogs · ` +
|
|
`${errors.length} errors · ${warnings.length} warnings.`
|
|
);
|
|
|
|
if (errors.length > 0) process.exit(1);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(2);
|
|
});
|