El modelo anterior (N documentos independientes por idioma) permitia que la ESTRUCTURA divergiera: borrar un bloque en un idioma no lo borraba en los demas. Requisito del usuario: la estructura es unica; solo el TEXTO es por idioma. Rehecho a UN solo documento con el contenido por-idioma en el bloque. Modelo (correcto por construccion): - `block.i18n?: Record<LangCode, Inline[]>` guarda el contenido de los idiomas INACTIVOS; `block.children` = el activo. Hay UN arbol → las ops estructurales (borrar/insertar/mover/tipo/tabla) aplican a TODOS los idiomas gratis, y el undo es automatico (el i18n vive en el doc). `doc.activeLang` registra cual esta en `children` (normalize lo preserva como `scheme`). El `i18n` sobrevive normalize + ops via spread (validate no rechaza props extra). - Cambiar de idioma = swap `children <-> i18n` en el arbol (switchDocumentLanguage). - Solo el texto INLINE es por idioma; alt/caption de imagen, code.language y callout.title quedan COMPARTIDOS en v1. Contrato: `value` vuelve a `PalabrasDocument` (uno solo, no un mapa). Helpers `foldPalabrasLanguages` (construye el doc desde docs-gemelos por idioma) y `projectPalabrasLanguage` (extrae un idioma para render/export). El switcher y la ubicacion (chunks 2-3) intactos. engine/i18n.ts NUEVO. Provider reworkeado (fuera langHistories/map). Tests reescritos: incluye el test CLAVE (borrar aplica a todos los idiomas) + insertar = vacio-a-traducir en los demas + project round-trip. 60/60 provider · 570/570 motor · 0 errores de tipo. Verificado Playwright: borrar 1 bloque en es (4->3) → en tambien 3, contenido es<->en distinto, 0 errores de consola. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
9a7a438a96
commit
461be12d91
@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Multilingual content model (F-A) — ONE document, per-language inline content.
|
||||
*
|
||||
* The block STRUCTURE is shared across languages by construction (there is a
|
||||
* single tree). Only the inline content of text-leaf blocks varies per language:
|
||||
* `block.children` holds the ACTIVE language, `block.i18n[lang]` holds the rest.
|
||||
* Structural edits (insert / delete / move / type / table) therefore apply to
|
||||
* ALL languages for free (the `i18n` bag rides with the block through the engine's
|
||||
* spread-based ops), and history is automatic (the `i18n` bag lives in the doc).
|
||||
*
|
||||
* v1 scope: only INLINE text is per-language. Block-level string props that are
|
||||
* also text (image `alt` / `caption`, `code.language`, `callout.title`) stay
|
||||
* SHARED across languages.
|
||||
*/
|
||||
|
||||
import type { PalabrasBlock, PalabrasDocument, PalabrasInline } from './types';
|
||||
|
||||
/** A block whose `children` is inline text — the unit that carries per-language content. */
|
||||
type TextLeaf = {
|
||||
readonly children: readonly PalabrasInline[];
|
||||
readonly i18n?: Record<string, readonly PalabrasInline[]>;
|
||||
};
|
||||
|
||||
const EMPTY_INLINE: readonly PalabrasInline[] = [{ type: 'text', text: '' }];
|
||||
|
||||
/** Whether inline content has any non-whitespace text. */
|
||||
function inlinesHaveText(inlines: readonly PalabrasInline[]): boolean {
|
||||
for (const node of inlines) {
|
||||
if (node.type === 'text' && node.text.trim().length > 0) return true;
|
||||
if (node.type === 'link' && inlinesHaveText(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Recurse the tree, applying `fn` to every text-leaf block; containers recurse. */
|
||||
function mapLeaves(block: PalabrasBlock, fn: (leaf: TextLeaf) => TextLeaf): PalabrasBlock {
|
||||
switch (block.type) {
|
||||
case 'list':
|
||||
return { ...block, items: block.items.map((it) => fn(it) as typeof it) };
|
||||
case 'table':
|
||||
return {
|
||||
...block,
|
||||
rows: block.rows.map((row) => ({
|
||||
...row,
|
||||
cells: row.cells.map((cell) => ({
|
||||
...cell,
|
||||
children: cell.children.map((b) => mapLeaves(b, fn))
|
||||
}))
|
||||
}))
|
||||
};
|
||||
case 'columns':
|
||||
return {
|
||||
...block,
|
||||
columns: block.columns.map((col) => ({
|
||||
...col,
|
||||
children: col.children.map((b) => mapLeaves(b, fn))
|
||||
}))
|
||||
};
|
||||
case 'callout':
|
||||
return { ...block, children: block.children.map((b) => mapLeaves(b, fn)) };
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
case 'quote':
|
||||
case 'code':
|
||||
return fn(block) as PalabrasBlock;
|
||||
default:
|
||||
return block; // image / divider / plugin atoms — no per-language inline content
|
||||
}
|
||||
}
|
||||
|
||||
/** Parallel variant of `mapLeaves`: recurses `block` alongside structural `twins`
|
||||
* (same tree shape), handing each leaf its twin leaves. For `foldPalabrasLanguages`. */
|
||||
function mapLeavesZip(
|
||||
block: PalabrasBlock,
|
||||
twins: readonly (PalabrasBlock | undefined)[],
|
||||
fn: (leaf: TextLeaf, twinLeaves: readonly (TextLeaf | undefined)[]) => TextLeaf
|
||||
): PalabrasBlock {
|
||||
switch (block.type) {
|
||||
case 'list':
|
||||
return {
|
||||
...block,
|
||||
items: block.items.map(
|
||||
(it, i) =>
|
||||
fn(
|
||||
it,
|
||||
twins.map((t) => (t as typeof block | undefined)?.items?.[i] as TextLeaf | undefined)
|
||||
) as typeof it
|
||||
)
|
||||
};
|
||||
case 'table':
|
||||
return {
|
||||
...block,
|
||||
rows: block.rows.map((row, ri) => ({
|
||||
...row,
|
||||
cells: row.cells.map((cell, ci) => ({
|
||||
...cell,
|
||||
children: cell.children.map((b, bi) =>
|
||||
mapLeavesZip(
|
||||
b,
|
||||
twins.map(
|
||||
(t) => (t as typeof block | undefined)?.rows?.[ri]?.cells?.[ci]?.children?.[bi]
|
||||
),
|
||||
fn
|
||||
)
|
||||
)
|
||||
}))
|
||||
}))
|
||||
};
|
||||
case 'columns':
|
||||
return {
|
||||
...block,
|
||||
columns: block.columns.map((col, ci) => ({
|
||||
...col,
|
||||
children: col.children.map((b, bi) =>
|
||||
mapLeavesZip(
|
||||
b,
|
||||
twins.map((t) => (t as typeof block | undefined)?.columns?.[ci]?.children?.[bi]),
|
||||
fn
|
||||
)
|
||||
)
|
||||
}))
|
||||
};
|
||||
case 'callout':
|
||||
return {
|
||||
...block,
|
||||
children: block.children.map((b, bi) =>
|
||||
mapLeavesZip(
|
||||
b,
|
||||
twins.map((t) => (t as typeof block | undefined)?.children?.[bi]),
|
||||
fn
|
||||
)
|
||||
)
|
||||
};
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
case 'quote':
|
||||
case 'code':
|
||||
return fn(block, twins as readonly (TextLeaf | undefined)[]) as PalabrasBlock;
|
||||
default:
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the active language of a document: stash the current active content
|
||||
* (`from`) into each leaf's `i18n`, load the `to` language's content into
|
||||
* `children` (empty when untranslated). Updates `doc.activeLang`.
|
||||
*/
|
||||
export function switchDocumentLanguage(
|
||||
doc: PalabrasDocument,
|
||||
from: string,
|
||||
to: string
|
||||
): PalabrasDocument {
|
||||
if (from === to) return doc.activeLang === to ? doc : { ...doc, activeLang: to };
|
||||
const swap = (leaf: TextLeaf): TextLeaf => {
|
||||
const i18n: Record<string, readonly PalabrasInline[]> = { ...(leaf.i18n ?? {}) };
|
||||
i18n[from] = leaf.children;
|
||||
const next = i18n[to] ?? EMPTY_INLINE;
|
||||
delete i18n[to];
|
||||
const out: Record<string, unknown> = { ...leaf, children: next };
|
||||
if (Object.keys(i18n).length) out.i18n = i18n;
|
||||
else delete out.i18n;
|
||||
return out as TextLeaf;
|
||||
};
|
||||
return {
|
||||
...doc,
|
||||
children: doc.children.map((b) => mapLeaves(b, swap)),
|
||||
activeLang: to
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single language as a clean monolingual `PalabrasDocument` (strips
|
||||
* `i18n` / `activeLang`). Use to render or export ONE language of a multilingual
|
||||
* document. Untranslated leaves project to an empty paragraph body.
|
||||
*/
|
||||
export function projectPalabrasLanguage(doc: PalabrasDocument, lang: string): PalabrasDocument {
|
||||
const active = doc.activeLang ?? lang;
|
||||
const project = (leaf: TextLeaf): TextLeaf => {
|
||||
const rest: Record<string, unknown> = { ...leaf };
|
||||
delete rest.i18n;
|
||||
rest.children = lang === active ? leaf.children : (leaf.i18n?.[lang] ?? EMPTY_INLINE);
|
||||
return rest as TextLeaf;
|
||||
};
|
||||
const doc2: Record<string, unknown> = { ...doc };
|
||||
delete doc2.activeLang;
|
||||
doc2.children = doc.children.map((b) => mapLeaves(b, project));
|
||||
return doc2 as unknown as PalabrasDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single multilingual document from per-language STRUCTURAL TWINS (docs
|
||||
* with identical block structure, differing only in inline text). `primary`
|
||||
* becomes the active language (its content lives in `children`); the others fold
|
||||
* into each leaf's `i18n`. The caller guarantees the twins share structure.
|
||||
*/
|
||||
export function foldPalabrasLanguages(
|
||||
values: Record<string, PalabrasDocument>,
|
||||
primary: string
|
||||
): PalabrasDocument {
|
||||
const base = values[primary];
|
||||
if (!base)
|
||||
throw new Error(`foldPalabrasLanguages: no document for primary language "${primary}"`);
|
||||
const others = Object.keys(values).filter((l) => l !== primary);
|
||||
const foldLeaf = (leaf: TextLeaf, twinLeaves: readonly (TextLeaf | undefined)[]): TextLeaf => {
|
||||
const i18n: Record<string, readonly PalabrasInline[]> = {};
|
||||
others.forEach((lang, i) => {
|
||||
const twin = twinLeaves[i];
|
||||
if (twin?.children) i18n[lang] = twin.children;
|
||||
});
|
||||
return Object.keys(i18n).length ? ({ ...leaf, i18n } as TextLeaf) : leaf;
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
activeLang: primary,
|
||||
children: base.children.map((block, i) =>
|
||||
mapLeavesZip(
|
||||
block,
|
||||
others.map((l) => values[l].children[i]),
|
||||
foldLeaf
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a language carries any content in the document (completeness dot). */
|
||||
export function documentLangHasContent(doc: PalabrasDocument, lang: string): boolean {
|
||||
const active = doc.activeLang ?? lang;
|
||||
let found = false;
|
||||
const check = (leaf: TextLeaf): TextLeaf => {
|
||||
if (!found) {
|
||||
const content = lang === active ? leaf.children : leaf.i18n?.[lang];
|
||||
if (content && inlinesHaveText(content)) found = true;
|
||||
}
|
||||
return leaf;
|
||||
};
|
||||
doc.children.forEach((b) => mapLeaves(b, check));
|
||||
return found;
|
||||
}
|
||||
Loading…
Reference in new issue