fix(scripts,langs): el guard de traducciones deja de cascar y de ser ciego a lo que decia cazar

El diagnostico heredado era falso por partida doble. No era una `url:`
multilinea de un catalogo: era el stripper de comentarios de
`evalCatalogFile` comiendose el `//` de un `https://` DENTRO de una cadena.
Y sobre todo, el guard NUNCA escaneo los call sites: un
`v.translationRef('key')` no contiene literal `#?` hasta que el compilador lo
normaliza, asi que los cinco defectos de chronos habrian pasado en verde
incluso con el script sano.

Reescrito sobre el contrato COMPILADO: importa los 166 morfos, los compila con
el `compileMorfo` real y valida cada ref ya normalizada contra los catalogos
(tambien importados, muerto el eval por regex). Las fuentes `.ts` se escanean
por AST de literales, asi que un `//` dentro de una cadena no puede volver a
romper nada; un ref construido con template aporta un prefijo dinamico en vez
de un falso huerfano.

Barrido de los 166: 172 errores a 0. Tres clases reales, ninguna cosmetica:
seis morfos declaraban `texts` SIN catalogo (card-group, css-field, picker,
qr-code, radio-cards, waveform: todo fallback ingles), media-player llevaba
seis claves menos que su morfo, y el chrome de palabras referenciaba ~130
claves inexistentes — el menu slash, la barra de burbuja, el menubar, find,
el panel de diseno y los anuncios servian ingles enteros.

Dentro de palabras, dos colisiones hoja<->rama: un nodo de catalogo no puede
ser etiqueta y grupo a la vez (`isLangRecord` rechaza nodos mixtos), asi que
la etiqueta baja a un hijo (`status.label`, `placeholder.default`) y las
descripciones del slash pasan a clave PLANA (`heading-1-d`), nunca `.d`
anidado, que exigiria lo imposible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent f44d1486d1
commit 37c732c3a1

@ -1,37 +1,63 @@
/**
* translations-check — validate the texts/idlangref discipline across UIX.
*
* Runs four independent checks and exits non-zero if any of them fails:
* 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. **Component catalog coverage**: every component-scoped idlangref points
* into `src/uix/langs/components/{kebab}.ts`. The dotted path must resolve
* to a leaf `LangRecord` (object whose values are strings).
* 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. **Common catalog coverage**: every `#?common.…` idlangref points into
* 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`.
*
* 4. **Orphan detection**: every leaf in the component catalogs is referenced
* by at least one `morfo.texts` entry OR a soma/eidos source file. Orphan
* 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 anywhere under `src/uix/morfo/`.
* - No raw `translations:` field survives on any morfo.
* - No source file (.ts / .svelte under src/uix) declares an idlangref to a
* nonexistent path.
*
* The implementation evaluates each `src/uix/langs/components/*.ts` and the
* common catalog via a controlled `Function(...)` call; those files are pure
* data so this is safe in CI.
* 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, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
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, '..');
@ -57,44 +83,83 @@ function warn(source: string, message: string) {
findings.push({ severity: 'warn', source, message });
}
// ── Catalog evaluation ────────────────────────────────────────────────────
function evalCatalogFile(file: string, exportName: string): LangBranch {
const source = readFileSync(file, 'utf8');
const stripped = source
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/,(\s*[}\]])/g, '$1');
const re = new RegExp(`export\\s+const\\s+${exportName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s+satisfies`);
const match = stripped.match(re);
if (!match) throw new Error(`No export const ${exportName} found in ${file}`);
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function(`return (${match[1]});`);
return fn() as LangBranch;
// ── 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());
}
function loadCommonCatalog(): LangBranch {
return evalCatalogFile(LANGS_COMMON, 'commonLangs');
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;
}
function loadComponentCatalogs(): Map<string, LangBranch> {
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'
);
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 data = evalCatalogFile(join(LANGS_COMPONENTS, file), exportName);
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 {
@ -121,7 +186,7 @@ function collectLeafPaths(node: unknown, prefix: string[] = []): string[][] {
return Object.entries(node).flatMap(([k, v]) => collectLeafPaths(v, [...prefix, k]));
}
// ── Morfo file scanning ───────────────────────────────────────────────────
// ── Morfo texts + compiled refs ───────────────────────────────────────────
interface MorfoTextEntry {
file: string;
@ -130,55 +195,60 @@ interface MorfoTextEntry {
rawRef: string;
}
function extractKebab(source: string): string | null {
const m = source.match(/kebab:\s*'([^']+)'/);
return m?.[1] ?? null;
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;
}
function extractTextsBlock(source: string): string | null {
const m = source.match(/^\ttexts:\s*\{/m);
if (!m) return null;
const open = m.index! + m[0].length - 1;
let depth = 1;
let i = open + 1;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
i++;
}
if (depth !== 0) return null;
return source.slice(open + 1, i - 1);
interface CompiledRefEntry {
file: string;
kebab: string;
part: string;
attr: string;
/** Normalized idlangref as the runtime will resolve it: `#?path|fallback`. */
ref: string;
}
function scanMorfoTexts(): MorfoTextEntry[] {
const out: MorfoTextEntry[] = [];
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 source = readFileSync(path, 'utf8');
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);
}
// Hard ban: no `translations:` field at root indent.
if (/^\ttranslations:\s*\{/m.test(source)) {
err(path, 'Legacy `translations:` field present. Replace with `texts:` idlangrefs.');
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;
}
const kebab = extractKebab(source);
if (!kebab) continue;
const block = extractTextsBlock(source);
if (!block) continue;
// Walk key: '#?…' or "key": '#?…' lines. The value is always a single
// quoted string per the migrated layout.
const lineRe = /['"]?([\w-]+(?:\.[\w-]+)*)['"]?\s*:\s*['"`](#\?[^'"`]+)['"`]/g;
let lm: RegExpExecArray | null;
while ((lm = lineRe.exec(block))) {
out.push({ file: path, kebab, textKey: lm[1], rawRef: lm[2] });
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;
}
// ── Cross-source idlangref scanning ───────────────────────────────────────
// ── Source idlangref scanning ─────────────────────────────────────────────
function listSourceFiles(root: string): string[] {
if (!statSync(root).isDirectory()) return [];
@ -199,32 +269,95 @@ function listSourceFiles(root: string): string[] {
return out;
}
function stripCommentsAndStrings(source: string): string {
// Drop // line comments, /* */ block comments, and `<!-- -->` HTML comments
// inside .svelte. Templates with `{var}` placeholders inside comments would
// otherwise produce false-positive idlangref scans.
return source
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/<!--[\s\S]*?-->/g, '');
// 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;
}
function scanIdlangrefsInSource(): Map<string, Set<string>> {
const occurrences = new Map<string, Set<string>>(); // ref → files
const files = listSourceFiles(UIX);
// Only match refs with no `{` `}` `:` whitespace — those are real idlangrefs,
// not doc placeholders like `#?components.{kebab}.{key}`.
const re = /#\?([A-Za-z][\w.-]*)(?:\|([^'"`\n]*))?/g;
for (const file of files) {
const source = stripCommentsAndStrings(readFileSync(file, 'utf8'));
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
const path = m[1];
if (!occurrences.has(path)) occurrences.set(path, new Set());
occurrences.get(path)!.add(file);
/** 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);
}
return occurrences;
}
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 ────────────────────────────────────────────────────────────────
@ -262,10 +395,7 @@ function checkMorfoTextsShape(
}
const branch = components.get(kebab);
if (!branch) {
err(
file,
`texts.${textKey}: no catalog found at src/uix/langs/components/${kebab}.ts.`
);
err(file, `texts.${textKey}: no catalog found at src/uix/langs/components/${kebab}.ts.`);
continue;
}
const leaf = resolvePath(branch, segments.slice(2));
@ -293,28 +423,89 @@ function checkMorfoTextsShape(
}
}
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[],
sourceOccurrences: Map<string, Set<string>>
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 path of sourceOccurrences.keys()) referenced.add(path);
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)) {
warn(
join(LANGS_COMPONENTS, `${kebab}.ts`),
`Orphan: ${full} is not referenced by any morfo.texts entry or source file.`
);
}
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.`
);
}
}
}
@ -349,15 +540,18 @@ function checkSourceIdlangrefs(
// ── Main ──────────────────────────────────────────────────────────────────
function main() {
const common = loadCommonCatalog();
const components = loadComponentCatalogs();
const morfoEntries = scanMorfoTexts();
const sourceOccurrences = scanIdlangrefsInSource();
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);
checkSourceIdlangrefs(sourceOccurrences, components, common);
checkComponentCatalogOrphans(components, morfoEntries, sourceOccurrences);
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');
@ -370,10 +564,15 @@ function main() {
console.log();
console.log(
`Summary: ${morfoEntries.length} morfo text refs · ${components.size} component catalogs · ${errors.length} errors · ${warnings.length} warnings.`
`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();
main().catch((e) => {
console.error(e);
process.exit(2);
});

@ -66,21 +66,24 @@
callout: '#?components.palabras.slash.callout|Aviso',
columns: '#?components.palabras.slash.columns|Columnas'
};
// Flat `{id}-d` keys: `{id}.d` would need `{id}` to be a catalog BRANCH,
// and a branch cannot also be the label leaf (isLangRecord rejects mixed
// nodes — the label would fall to its fallback in every language).
const DESCS: Record<string, string> = {
paragraph: '#?components.palabras.slash.paragraph.d|Párrafo de texto',
'heading-1': '#?components.palabras.slash.heading-1.d|Título de sección grande',
'heading-2': '#?components.palabras.slash.heading-2.d|Título de sección medio',
'heading-3': '#?components.palabras.slash.heading-3.d|Título de sección pequeño',
quote: '#?components.palabras.slash.quote.d|Bloque de cita',
'code-block': '#?components.palabras.slash.code.d|Código preformateado',
'unordered-list': '#?components.palabras.slash.ul.d|Lista con viñetas',
'ordered-list': '#?components.palabras.slash.ol.d|Lista numerada',
'check-list': '#?components.palabras.slash.checklist.d|Tareas con casillas',
table: '#?components.palabras.slash.table.d|Rejilla editable',
image: '#?components.palabras.slash.image.d|Insertar desde URL o subida',
divider: '#?components.palabras.slash.divider.d|Línea horizontal',
callout: '#?components.palabras.slash.callout.d|Nota destacada',
columns: '#?components.palabras.slash.columns.d|Disposición en columnas'
paragraph: '#?components.palabras.slash.paragraph-d|Párrafo de texto',
'heading-1': '#?components.palabras.slash.heading-1-d|Título de sección grande',
'heading-2': '#?components.palabras.slash.heading-2-d|Título de sección medio',
'heading-3': '#?components.palabras.slash.heading-3-d|Título de sección pequeño',
quote: '#?components.palabras.slash.quote-d|Bloque de cita',
'code-block': '#?components.palabras.slash.code-d|Código preformateado',
'unordered-list': '#?components.palabras.slash.ul-d|Lista con viñetas',
'ordered-list': '#?components.palabras.slash.ol-d|Lista numerada',
'check-list': '#?components.palabras.slash.checklist-d|Tareas con casillas',
table: '#?components.palabras.slash.table-d|Rejilla editable',
image: '#?components.palabras.slash.image-d|Insertar desde URL o subida',
divider: '#?components.palabras.slash.divider-d|Línea horizontal',
callout: '#?components.palabras.slash.callout-d|Nota destacada',
columns: '#?components.palabras.slash.columns-d|Disposición en columnas'
};
const labelOf = (item: PalabrasBlockMenuEntry) => (LABELS[item.id] ? ts(LABELS[item.id]) : item.label);
const descOf = (item: PalabrasBlockMenuEntry) =>

@ -0,0 +1,10 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the CardGroup component. Merged under
* `components.card-group.*` by `ActiveUix` (via the `componentLangs` barrel).
* Keys mirror `cardGroupMorfo.texts`.
*/
export const cardGroupLangs = {
label: { es: 'Grupo de tarjetas', en: 'Card group' }
} satisfies LangNode;

@ -0,0 +1,11 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the CssField component. Merged under
* `components.css-field.*` by `ActiveUix` (via the `componentLangs` barrel).
* Keys mirror `cssFieldMorfo.texts`.
*/
export const cssFieldLangs = {
label: { es: 'Valor CSS', en: 'CSS Value' },
invalid: { es: 'Valor CSS no válido', en: 'Invalid CSS value' }
} satisfies LangNode;

@ -13,6 +13,7 @@ import { buttonGroupLangs } from './button-group';
import { calendarLangs } from './calendar';
import { calloutLangs } from './callout';
import { cardLangs } from './card';
import { cardGroupLangs } from './card-group';
import { carouselLangs } from './carousel';
import { cascadeLangs } from './cascade';
import { chartLangs } from './chart';
@ -28,6 +29,7 @@ import { collapsibleLangs } from './collapsible';
import { colorFieldLangs } from './color-field';
import { colorPickerLangs } from './color-picker';
import { comboboxLangs } from './combobox';
import { cssFieldLangs } from './css-field';
import { commandLangs } from './command';
import { contextMenuLangs } from './context-menu';
import { cropperLangs } from './cropper';
@ -75,12 +77,15 @@ import { paginationLangs } from './pagination';
import { palabrasLangs } from './palabras';
import { passwordFieldLangs } from './password-field';
import { pathTraceLangs } from './path-trace';
import { pickerLangs } from './picker';
import { pickerShellLangs } from './picker-shell';
import { pinInputLangs } from './pin-input';
import { popoverLangs } from './popover';
import { progressLangs } from './progress';
import { proofOfHumanLangs } from './proof-of-human';
import { proseLangs } from './prose';
import { qrCodeLangs } from './qr-code';
import { radioCardsLangs } from './radio-cards';
import { radioGroupLangs } from './radio-group';
import { rangeCalendarLangs } from './range-calendar';
import { ratingGroupLangs } from './rating-group';
@ -118,6 +123,7 @@ import { treeGridLangs } from './tree-grid';
import { treeViewLangs } from './tree-view';
import { virtualGridLangs } from './virtual-grid';
import { virtualListLangs } from './virtual-list';
import { waveformLangs } from './waveform';
import { yearGridLangs } from './year-grid';
/**
@ -139,6 +145,7 @@ export const componentLangs = {
calendar: calendarLangs,
callout: calloutLangs,
card: cardLangs,
'card-group': cardGroupLangs,
carousel: carouselLangs,
cascade: cascadeLangs,
chart: chartLangs,
@ -154,6 +161,7 @@ export const componentLangs = {
'color-field': colorFieldLangs,
'color-picker': colorPickerLangs,
combobox: comboboxLangs,
'css-field': cssFieldLangs,
command: commandLangs,
'context-menu': contextMenuLangs,
cropper: cropperLangs,
@ -201,12 +209,15 @@ export const componentLangs = {
palabras: palabrasLangs,
'password-field': passwordFieldLangs,
'path-trace': pathTraceLangs,
picker: pickerLangs,
'picker-shell': pickerShellLangs,
'pin-input': pinInputLangs,
popover: popoverLangs,
progress: progressLangs,
'proof-of-human': proofOfHumanLangs,
prose: proseLangs,
'qr-code': qrCodeLangs,
'radio-cards': radioCardsLangs,
'radio-group': radioGroupLangs,
'range-calendar': rangeCalendarLangs,
'rating-group': ratingGroupLangs,
@ -244,5 +255,6 @@ export const componentLangs = {
'tree-view': treeViewLangs,
'virtual-grid': virtualGridLangs,
'virtual-list': virtualListLangs,
waveform: waveformLangs,
'year-grid': yearGridLangs
} satisfies LangNode;

@ -71,5 +71,31 @@ export const mediaPlayerLangs = {
settings: {
es: 'Ajustes',
en: 'Settings'
},
rate: {
es: 'Velocidad de reproducción',
en: 'Playback speed'
},
restart: {
es: 'Reiniciar',
en: 'Restart'
},
'skip-previous': {
es: 'Anterior',
en: 'Previous'
},
'skip-next': {
es: 'Siguiente',
en: 'Next'
},
// The «no subtitles» entry of a track LIST — distinct from `captions-off`,
// which names the ACTION of the toggle button.
'captions-none': {
es: 'Desactivados',
en: 'Off'
},
live: {
es: 'En directo',
en: 'Live'
}
} satisfies LangNode;

@ -11,6 +11,8 @@ import type { LangNode } from '$libs/langs';
* (dawn/morning/…). Only the component-specific labels live in this catalog.
*/
export const naturalTimePickerLangs = {
label: { es: 'Hora', en: 'Time' },
trigger: { es: 'Abrir selector de hora', en: 'Open time picker' },
band: { es: 'Hora del día', en: 'Time of day' },
placeholder: { es: 'Seleccionar hora', en: 'Select time' },
hourMinus: { es: 'Una hora menos', en: 'One hour earlier' },

@ -72,7 +72,39 @@ export const palabrasLangs = {
adjustments: { es: 'Ajustes', en: 'Adjustments' },
anchor: { es: 'Ancla', en: 'Anchor' },
code: { es: 'Código', en: 'Code' },
typography: { es: 'Tipografía', en: 'Typography' }
typography: { es: 'Tipografía', en: 'Typography' },
bar: { es: 'Barra', en: 'Bar' },
'bar-color': { es: 'Color de la barra', en: 'Bar color' },
bg: { es: 'Fondo', en: 'Background' },
border: { es: 'Borde', en: 'Border' },
'border-inner': { es: 'Borde interior', en: 'Inner border' },
box: { es: 'Casilla', en: 'Box' },
'box-color': { es: 'Color de casilla', en: 'Box color' },
'callout-icon': { es: 'Icono de intención', en: 'Intent icon' },
'col-pad': { es: 'Relleno de columna', en: 'Column padding' },
color: { es: 'Color', en: 'Color' },
'doc-margins': { es: 'Márgenes', en: 'Margins' },
'first-line': { es: 'Sangría 1ª línea', en: 'First-line indent' },
font: { es: 'Fuente', en: 'Font' },
indent: { es: 'Sangría', en: 'Indent' },
ink: { es: 'Tinta', en: 'Ink' },
'item-gap': { es: 'Interlínea', en: 'Item gap' },
leading: { es: 'Interlineado', en: 'Leading' },
'line-style': { es: 'Estilo', en: 'Style' },
marker: { es: 'Marcador', en: 'Marker' },
'marker-color': { es: 'Color del marcador', en: 'Marker color' },
measure: { es: 'Medida', en: 'Measure' },
opacity: { es: 'Opacidad', en: 'Opacity' },
'quote-marks': { es: 'Comillas decorativas', en: 'Decorative quote marks' },
rhythm: { es: 'Ritmo', en: 'Rhythm' },
rule: { es: 'Regla divisoria', en: 'Divider rule' },
'rule-color': { es: 'Color de la regla', en: 'Rule color' },
shadow: { es: 'Sombra', en: 'Shadow' },
size: { es: 'Tamaño', en: 'Size' },
'tab-size': { es: 'Tabulación', en: 'Tab size' },
thickness: { es: 'Grosor', en: 'Thickness' },
wrap: { es: 'Ajustar líneas', en: 'Wrap lines' },
zebra: { es: 'Filas alternas', en: 'Zebra stripes' }
},
placeholder: {
'heading-text': { es: 'Texto del encabezado', en: 'Heading text' },
@ -88,7 +120,10 @@ export const palabrasLangs = {
en: 'Describe the image (accessibility)'
},
anchor: { es: '#seccion', en: '#section' },
auto: { es: 'auto', en: 'auto' }
auto: { es: 'auto', en: 'auto' },
// The empty-document placeholder — `morfo.texts.placeholder` points here
// (the bare `placeholder` path is this GROUP, so the leaf needs a name).
default: { es: 'Empieza a escribir…', en: 'Start writing…' }
},
option: {
fill: { es: 'Llenar', en: 'Fill' },
@ -110,13 +145,33 @@ export const palabrasLangs = {
fulfill: { es: 'Logro', en: 'Fulfill' },
risk: { es: 'Riesgo', en: 'Risk' },
threat: { es: 'Amenaza', en: 'Threat' },
loss: { es: 'Pérdida', en: 'Loss' }
loss: { es: 'Pérdida', en: 'Loss' },
default: { es: 'Por defecto', en: 'Default' },
none: { es: 'Ninguno', en: 'None' },
// Feminine variant — Spanish agrees the pronoun with the noun it stands
// for ('Ninguna' for a feminine field); English collapses both to None.
'none-f': { es: 'Ninguna', en: 'None' },
solid: { es: 'Sólido', en: 'Solid' },
dashed: { es: 'Discontinuo', en: 'Dashed' },
dotted: { es: 'Punteado', en: 'Dotted' },
thin: { es: 'Fino', en: 'Thin' },
medium: { es: 'Medio', en: 'Medium' },
thick: { es: 'Grueso', en: 'Thick' },
'measure-narrow': { es: 'Estrecha', en: 'Narrow' },
'measure-medium': { es: 'Media', en: 'Medium' },
'measure-wide': { es: 'Ancha', en: 'Wide' },
'measure-full': { es: 'Completa', en: 'Full' }
},
section: {
typography: { es: 'Tipografía', en: 'Typography' },
layout: { es: 'Disposición', en: 'Layout' },
colors: { es: 'Colores', en: 'Colors' },
scheme: { es: 'Esquema', en: 'Scheme' }
scheme: { es: 'Esquema', en: 'Scheme' },
adjustments: { es: 'Ajustes', en: 'Adjustments' },
appearance: { es: 'Apariencia', en: 'Appearance' },
border: { es: 'Borde y efectos', en: 'Border & effects' },
'doc-text': { es: 'Texto', en: 'Text' },
document: { es: 'Documento', en: 'Document' }
},
action: {
properties: { es: 'Propiedades del bloque', en: 'Block properties' },
@ -136,7 +191,19 @@ export const palabrasLangs = {
es: 'Restablecer la sección a los valores por defecto',
en: 'Reset the section to defaults'
},
sections: { es: 'Secciones del bloque', en: 'Block sections' }
sections: { es: 'Secciones del bloque', en: 'Block sections' },
apply: { es: 'Aplicar', en: 'Apply' },
back: { es: 'Volver', en: 'Back' },
copy: { es: 'Copiar', en: 'Copy' },
link: { es: 'Enlace', en: 'Link' },
'merge-down': { es: 'Combinar con la de abajo', en: 'Merge with cell below' },
'merge-right': { es: 'Combinar con la derecha', en: 'Merge with cell to the right' },
paste: { es: 'Pegar', en: 'Paste' },
redo: { es: 'Rehacer', en: 'Redo' },
'resize-column': { es: 'Redimensionar columna', en: 'Resize column' },
'split-cell': { es: 'Dividir celda', en: 'Split cell' },
undo: { es: 'Deshacer', en: 'Undo' },
unlink: { es: 'Quitar enlace', en: 'Remove link' }
},
panel: {
empty: { es: 'Selecciona un bloque', en: 'Select a block' },
@ -154,6 +221,99 @@ export const palabrasLangs = {
heading: { es: 'Título', en: 'Title' },
'list-item': { es: 'Elemento', en: 'Item' }
},
// ── Live-region announcements (block operations) ──
announce: {
'cell-split': { es: 'Celda dividida', en: 'Cell split' },
'cells-merged': { es: 'Celdas combinadas', en: 'Cells merged' },
copied: { es: 'Bloque copiado', en: 'Block copied' },
deleted: { es: 'Bloque eliminado', en: 'Block deleted' },
inserted: { es: 'Bloque insertado', en: 'Block inserted' },
'moved-down': { es: 'Bloque movido abajo', en: 'Block moved down' },
'moved-up': { es: 'Bloque movido arriba', en: 'Block moved up' },
pasted: { es: 'Bloque pegado', en: 'Block pasted' }
},
export: {
label: { es: 'Exportar', en: 'Export' }
},
// ── Find & replace bar ──
find: {
case: { es: 'Distinguir mayúsculas', en: 'Match case' },
close: { es: 'Cerrar', en: 'Close' },
find: { es: 'Buscar', en: 'Find' },
// Invalid-regex badge — notation, identical in every language.
invalid: { es: '!', en: '!' },
next: { es: 'Siguiente', en: 'Next' },
prev: { es: 'Anterior', en: 'Previous' },
regex: { es: 'Expresión regular', en: 'Regular expression' },
replace: { es: 'Reemplazar', en: 'Replace' },
'replace-all': { es: 'Todo', en: 'All' },
'replace-one': { es: 'Reemplazar', en: 'Replace' },
word: { es: 'Palabra completa', en: 'Whole word' }
},
lang: {
switcher: { es: 'Idioma del contenido', en: 'Content language' }
},
// ── Link editor popover ──
link: {
placeholder: { es: 'Pega o escribe un enlace…', en: 'Paste or type a link…' },
url: { es: 'URL del enlace', en: 'Link URL' }
},
// ── Inline marks (bubble toolbar) ──
mark: {
bold: { es: 'Negrita', en: 'Bold' },
code: { es: 'Código', en: 'Code' },
family: { es: 'Fuente', en: 'Font' },
italic: { es: 'Cursiva', en: 'Italic' },
size: { es: 'Tamaño', en: 'Size' },
strike: { es: 'Tachado', en: 'Strikethrough' },
sub: { es: 'Subíndice', en: 'Subscript' },
sup: { es: 'Superíndice', en: 'Superscript' },
typography: { es: 'Tipografía', en: 'Typography' },
underline: { es: 'Subrayado', en: 'Underline' }
},
// ── Menubar ──
menu: {
block: { es: 'Bloque', en: 'Block' },
document: { es: 'Documento', en: 'Document' },
edit: { es: 'Editar', en: 'Edit' },
insert: { es: 'Insertar', en: 'Insert' },
'no-block': { es: 'Selecciona un bloque', en: 'Select a block' }
},
// ── Slash menu (insert-block palette). `{id}-d` is the id's description —
// flat keys on purpose: a `{id}.d` nesting would make `{id}` a branch, and
// a branch cannot also be the label leaf (`isLangRecord` rejects mixed
// nodes). ──
slash: {
aria: { es: 'Insertar bloque', en: 'Insert block' },
paragraph: { es: 'Texto', en: 'Text' },
'paragraph-d': { es: 'Párrafo de texto', en: 'Plain text paragraph' },
'heading-1': { es: 'Encabezado 1', en: 'Heading 1' },
'heading-1-d': { es: 'Título de sección grande', en: 'Large section title' },
'heading-2': { es: 'Encabezado 2', en: 'Heading 2' },
'heading-2-d': { es: 'Título de sección medio', en: 'Medium section title' },
'heading-3': { es: 'Encabezado 3', en: 'Heading 3' },
'heading-3-d': { es: 'Título de sección pequeño', en: 'Small section title' },
quote: { es: 'Cita', en: 'Quote' },
'quote-d': { es: 'Bloque de cita', en: 'Quote block' },
code: { es: 'Código', en: 'Code' },
'code-d': { es: 'Código preformateado', en: 'Preformatted code' },
ul: { es: 'Lista con viñetas', en: 'Bulleted list' },
'ul-d': { es: 'Lista con viñetas', en: 'Bulleted list' },
ol: { es: 'Lista numerada', en: 'Numbered list' },
'ol-d': { es: 'Lista numerada', en: 'Numbered list' },
checklist: { es: 'Lista de tareas', en: 'Task list' },
'checklist-d': { es: 'Tareas con casillas', en: 'Tasks with checkboxes' },
divider: { es: 'Separador', en: 'Divider' },
'divider-d': { es: 'Línea horizontal', en: 'Horizontal line' },
image: { es: 'Imagen', en: 'Image' },
'image-d': { es: 'Insertar desde URL o subida', en: 'Insert from URL or upload' },
callout: { es: 'Aviso', en: 'Callout' },
'callout-d': { es: 'Nota destacada', en: 'Highlighted note' },
columns: { es: 'Columnas', en: 'Columns' },
'columns-d': { es: 'Disposición en columnas', en: 'Column layout' },
table: { es: 'Tabla', en: 'Table' },
'table-d': { es: 'Rejilla editable', en: 'Editable grid' }
},
// ── Engine strings (folded from the former words langs) ──
content: { es: 'Contenido del editor', en: 'Editor content' },
toolbar: { es: 'Barra del editor', en: 'Editor toolbar' },
@ -161,6 +321,14 @@ export const palabrasLangs = {
'slash-menu': { es: 'Insertar bloque', en: 'Insert block' },
'link-editor': { es: 'Editar enlace', en: 'Edit link' },
'find-replace': { es: 'Buscar y reemplazar', en: 'Find and replace' },
status: { es: 'Estado del editor', en: 'Editor status' },
// Branch, not leaf: the region label lives at `status.label` (the morfo
// points there) so the bar's own strings can live beside it.
status: {
label: { es: 'Estado del editor', en: 'Editor status' },
chars: { es: 'caracteres', en: 'characters' },
outline: { es: 'Índice', en: 'Outline' },
untitled: { es: '(sin título)', en: '(untitled)' },
words: { es: 'palabras', en: 'words' }
},
drawer: { es: 'Cajón companion del editor', en: 'Editor companion drawer' }
} satisfies LangNode;

@ -0,0 +1,10 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the generic Picker component. Merged under
* `components.picker.*` by `ActiveUix` (via the `componentLangs` barrel).
* Keys mirror `pickerMorfo.texts`.
*/
export const pickerLangs = {
label: { es: 'Selector', en: 'Picker' }
} satisfies LangNode;

@ -0,0 +1,10 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the QrCode component. Merged under
* `components.qr-code.*` by `ActiveUix` (via the `componentLangs` barrel).
* Keys mirror `qrCodeMorfo.texts`.
*/
export const qrCodeLangs = {
download: { es: 'Descargar código QR', en: 'Download QR code' }
} satisfies LangNode;

@ -0,0 +1,10 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the RadioCards component. Merged under
* `components.radio-cards.*` by `ActiveUix` (via the `componentLangs` barrel).
* Keys mirror `radioCardsMorfo.texts`.
*/
export const radioCardsLangs = {
label: { es: 'Tarjetas de opción', en: 'Radio cards' }
} satisfies LangNode;

@ -0,0 +1,10 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the Waveform component. Merged under
* `components.waveform.*` by `ActiveUix` (via the `componentLangs` barrel).
* Keys mirror `waveformMorfo.texts`.
*/
export const waveformLangs = {
label: { es: 'Forma de onda', en: 'Audio waveform' }
} satisfies LangNode;

@ -87,13 +87,13 @@ export const palabrasMorfo = {
texts: {
label: '#?components.palabras.label|Rich text editor',
content: '#?components.palabras.content|Editor content',
placeholder: '#?components.palabras.placeholder|Start writing...',
placeholder: '#?components.palabras.placeholder.default|Start writing...',
toolbar: '#?components.palabras.toolbar|Editor toolbar',
bubbleMenu: '#?components.palabras.bubble-menu|Selection toolbar',
slashMenu: '#?components.palabras.slash-menu|Insert block',
linkEditor: '#?components.palabras.link-editor|Edit link',
findReplace: '#?components.palabras.find-replace|Find and replace',
status: '#?components.palabras.status|Editor status',
status: '#?components.palabras.status.label|Editor status',
drawer: '#?components.palabras.drawer|Editor companion drawer'
},
events: [
@ -778,7 +778,7 @@ export const palabrasMorfo = {
{ attr: 'aria-live', value: v.literal('polite') },
{
attr: 'aria-label',
value: v.translationRef('#?components.palabras.status|Editor status'),
value: v.translationRef('#?components.palabras.status.label|Editor status'),
severity: 'recommended'
}
]

Loading…
Cancel
Save

Powered by TurnKey Linux.