uix(palabras): F-A chunk 1 · contrato soma multilenguaje

Soporte multilenguaje del CONTENIDO a la field-langs, todo detras de la prop
`languages` (retrocompatible: sin ella, `value` sigue siendo PalabrasDocument).

Contrato:
- value: PalabrasDocument | Record<LangCode, PalabrasDocument>
- languages: LangSpec[]  (presencia = modo multi; 1o = activo por defecto)
- requiredLangs: LangCode[]  (validez; renombrado por colision con `required`)
- activeLang bindable + onLangChange; onValueChange sobre la union.

Provider:
- historia POR IDIOMA (langHistories): cambiar de idioma nunca cruza pilas de
  undo; el activo es this.history, los inactivos en el mapa; seed en construccion
  desde un snapshot del value map.
- writeExternalValue escribe el mapa ENTERO (raw) en multi; proxy-safe.
- syncExternalState resuelve el doc activo del value (doc|map).
- api: multilingual, languages, activeLang, setActiveLang, missingRequiredLangs,
  langFilled; requiredLangs alimenta isInvalid.

Tests: 5 nuevos (modo+default · switch+writeback · historia por idioma ·
requiredLangs→validez · mono retrocompatible). 58/58 provider. check 0 en palabras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 73411f1440
commit 090b273911

@ -28,6 +28,10 @@
onValueCommit,
onUploadImage,
insertableBlocks,
languages,
requiredLangs,
activeLang = $bindable(''),
onLangChange,
children,
child,
...restProps
@ -60,7 +64,14 @@
onSelectionChange: readableActive(() => onSelectionChange),
onValueCommit: readableActive(() => onValueCommit),
onUploadImage: readableActive(() => onUploadImage),
insertableBlocks: readableActive(() => insertableBlocks)
insertableBlocks: readableActive(() => insertableBlocks),
languages: readableActive(() => languages),
requiredLangs: readableActive(() => requiredLangs),
activeLang: writableActive(
() => activeLang,
(v) => (activeLang = v)
),
onLangChange: readableActive(() => onLangChange)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -102,7 +102,9 @@ function palabrasOpts(root = document.createElement('div'), value = createEmptyD
invalid: state(false),
commitOnBlur: state(true),
ariaLabel: state<string | undefined>(undefined),
onValueChange: state<((value: PalabrasDocument) => void) | undefined>(undefined),
onValueChange: state<
((value: PalabrasDocument | Record<string, PalabrasDocument>) => void) | undefined
>(undefined),
onSelectionChange: state<((value: PalabrasSelection | null) => void) | undefined>(undefined),
onValueCommit: state<
((value: PalabrasDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined
@ -110,7 +112,13 @@ function palabrasOpts(root = document.createElement('div'), value = createEmptyD
onUploadImage: state<((file: File) => Promise<{ url: string; alt?: string }>) | undefined>(
undefined
),
insertableBlocks: state<readonly string[] | undefined>(undefined)
insertableBlocks: state<readonly string[] | undefined>(undefined),
activeLang: state<string>(''),
languages: state<
readonly (string | { code: string; label?: string; dir?: 'ltr' | 'rtl' })[] | undefined
>(undefined),
requiredLangs: state<readonly string[] | undefined>(undefined),
onLangChange: state<((code: string) => void) | undefined>(undefined)
};
}
@ -2650,3 +2658,114 @@ describe('canonical block-type parity — soma slash vs eidos blocks', () => {
}
});
});
describe('PalabrasProvider · multilingual (F-A)', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
const docWith = (text: string) => ({
version: PALABRAS_VERSION,
children: [createParagraph([createText(text)])]
});
function multiProvider(
values: Record<string, ReturnType<typeof docWith>>,
languages: string[],
requiredLangs?: string[]
) {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = palabrasOpts(root, createEmptyDocument());
opts.value.current = values as never;
opts.languages.current = languages;
if (requiredLangs) opts.requiredLangs.current = requiredLangs;
const { result, cleanup } = withEffectRoot(() => {
const provider = PalabrasProvider.create(opts);
vi.spyOn(PalabrasProvider, 'require').mockReturnValue(provider);
return { provider };
});
return { provider: result.provider, opts, cleanup, dom };
}
it('languages presence enables multilingual mode; first language is default active', () => {
const { provider, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: docWith('Hello') },
['es', 'en']
);
expect(provider.multilingual).toBe(true);
expect(provider.resolvedLanguages).toEqual(['es', 'en']);
expect(provider.activeLang).toBe('es');
expect(provider.plainText).toBe('Hola');
cleanup();
dom.dispose();
});
it('setActiveLang swaps the active document; the value map keeps every language', () => {
const { provider, opts, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: docWith('Hello') },
['es', 'en']
);
provider.setActiveLang('en');
expect(provider.activeLang).toBe('en');
expect(provider.plainText).toBe('Hello');
const map = opts.value.current as unknown as Record<string, unknown>;
expect(Object.keys(map).sort()).toEqual(['en', 'es']);
cleanup();
dom.dispose();
});
it('each language keeps its own document and undo history across switches', () => {
const { provider, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: docWith('Hello') },
['es', 'en']
);
provider.applyCommand({ type: 'replaceDocument', document: docWith('Hola editado') });
expect(provider.plainText).toBe('Hola editado');
provider.setActiveLang('en');
expect(provider.plainText).toBe('Hello'); // en untouched by the es edit
provider.applyCommand({ type: 'replaceDocument', document: docWith('Hello edited') });
expect(provider.plainText).toBe('Hello edited');
provider.setActiveLang('es');
expect(provider.plainText).toBe('Hola editado'); // es edit preserved
expect(provider.canUndo).toBe(true); // es carries its OWN history stack
cleanup();
dom.dispose();
});
it('requiredLangs feed missingRequiredLangs, langFilled and isInvalid', () => {
const { provider, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: createEmptyDocument() as never },
['es', 'en'],
['es', 'en']
);
expect(provider.langFilled('es')).toBe(true);
expect(provider.langFilled('en')).toBe(false);
expect(provider.missingRequiredLangs).toEqual(['en']);
expect(provider.isInvalid).toBe(true);
cleanup();
dom.dispose();
});
it('mono mode (no languages) stays single-document and backward-compatible', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = palabrasOpts(root, docWith('Solo'));
const { result, cleanup } = withEffectRoot(() => {
const provider = PalabrasProvider.create(opts);
vi.spyOn(PalabrasProvider, 'require').mockReturnValue(provider);
return { provider };
});
expect(result.provider.multilingual).toBe(false);
expect(result.provider.resolvedLanguages).toEqual([]);
expect(result.provider.plainText).toBe('Solo');
cleanup();
dom.dispose();
});
});

@ -93,8 +93,11 @@ import type {
} from './engine/types';
import type { PalabrasRenderDecorations } from './engine/render';
import type {
LangCode,
LangSpec,
PalabrasExportFormat,
PalabrasImportFormat,
PalabrasLangValues,
PalabrasRenderedBlock,
PalabrasTocEntry
} from './types';
@ -103,8 +106,9 @@ interface PalabrasOpts
extends
WithRefOpts,
StateProps<{
value: PalabrasDocument;
value: PalabrasDocument | PalabrasLangValues;
selection: PalabrasSelection | null;
activeLang: LangCode;
}>,
ActiveProps<{
contentId: string;
@ -116,11 +120,14 @@ interface PalabrasOpts
invalid: boolean;
commitOnBlur: boolean;
ariaLabel: string | undefined;
onValueChange: OnChangeFn<PalabrasDocument> | undefined;
onValueChange: OnChangeFn<PalabrasDocument | PalabrasLangValues> | undefined;
onSelectionChange: OnChangeFn<PalabrasSelection | null> | undefined;
onValueCommit: ((document: PalabrasDocument, reason: PalabrasCommitReason) => void) | undefined;
onUploadImage: PalabrasOnUploadImage | undefined;
insertableBlocks: readonly string[] | undefined;
languages: readonly LangSpec[] | undefined;
requiredLangs: readonly LangCode[] | undefined;
onLangChange: OnChangeFn<LangCode> | undefined;
}> {}
interface PalabrasContentOpts extends WithRefOpts {}
@ -192,6 +199,93 @@ export class PalabrasProvider {
* recognise our own round-trip in O(1) WITHOUT a proxy↔raw compare (which
* trips Svelte's `state_proxy_equality_mismatch`). Not `$state`: a cache. */
publishedExternal: PalabrasDocument | null = null;
// ── Multilingual (F-A) ───────────────────────────────────────────────
// `languages` present → the editor holds ONE document PER language, edits the
// ACTIVE language's doc and writes the whole map back through `value`. Each
// language keeps its OWN undo history (switching never crosses stacks):
// `langHistories` caches the INACTIVE languages' histories; the active one is
// `this.history`. Seeded once at construction from the initial value map.
private langHistories = new Map<LangCode, PalabrasHistory>();
private activeLangState = $state<LangCode>('');
readonly resolvedLanguages = $derived.by<readonly LangCode[]>(() =>
(this.opts.languages.current ?? []).map(langCodeOf)
);
get multilingual(): boolean {
return this.resolvedLanguages.length > 0;
}
get activeLang(): LangCode {
const langs = this.resolvedLanguages;
if (!langs.length) return '';
return langs.includes(this.activeLangState) ? this.activeLangState : langs[0];
}
readonly missingRequiredLangs = $derived.by<readonly LangCode[]>(() => {
if (!this.multilingual) return [];
return (this.opts.requiredLangs.current ?? []).filter((code) => !this.langFilled(code));
});
private langDocFor(code: LangCode): PalabrasDocument {
if (code === this.activeLang) return this.document;
return this.langHistories.get(code)?.present.document ?? createEmptyDocument();
}
langFilled(code: LangCode): boolean {
return documentHasContent(this.langDocFor(code));
}
/** The active language's document AS THE STORE HOLDS IT (proxied under a
* `$state` binding) — the reference `syncExternalState` compares against. */
private activeExternalDocument(): PalabrasDocument {
const v = this.opts.value.current;
return this.multilingual
? ((v as PalabrasLangValues)[this.activeLang] ?? createEmptyDocument())
: (v as PalabrasDocument);
}
/** Seed a history per language from the initial value map (multilingual init). */
private seedLanguages(opts: PalabrasOpts) {
const langs = this.resolvedLanguages;
const rawMap = ($state.snapshot(opts.value.current) ?? {}) as PalabrasLangValues;
const requested = opts.activeLang.current;
const active = requested && langs.includes(requested) ? requested : langs[0];
this.activeLangState = active;
for (const code of langs) {
const doc = normalizeDocument(rawMap[code] ?? createEmptyDocument()).document;
const hist = createPalabrasHistory(
createState(doc, code === active ? opts.selection.current : null)
);
if (code === active) this.history = hist;
else this.langHistories.set(code, hist);
}
this.opts.activeLang.current = active;
this.writeExternalValue(this.document);
this.opts.selection.current = this.history.present.selection;
}
/** Switch the active editing language, persisting the current one's history. */
setActiveLang(code: LangCode) {
if (!this.multilingual) return;
const langs = this.resolvedLanguages;
if (!langs.includes(code) || code === this.activeLang) return;
const from = this.activeLang;
untrack(() => {
this.closeTypingBatch();
this.langHistories.set(from, this.history);
const target =
this.langHistories.get(code) ?? createPalabrasHistory(createState(createEmptyDocument()));
this.langHistories.delete(code);
this.activeLangState = code;
this.history = target;
this.dirty = false;
this.activeBlockId = null;
this.writeExternalSelection(target.present.selection);
this.opts.activeLang.current = code;
// A pure switch leaves the value MAP unchanged; refresh publishedExternal
// so a later EXTERNAL edit to the now-active language is still detected.
this.publishedExternal = this.activeExternalDocument();
});
this.opts.onLangChange.current?.(code);
}
isSelecting = $state(false);
findQuery = $state('');
findCaseSensitive = $state(false);
@ -289,11 +383,15 @@ export class PalabrasProvider {
// their materialized-path id, has nothing to resolve. Normalizing
// here (and writing the id-stamped doc back to `value`) is the single
// point that guarantees ids exist before the first paint.
const normalizedInitial = normalizeDocument(opts.value.current).document;
const initialState = createState(normalizedInitial, opts.selection.current);
this.history = createPalabrasHistory(initialState);
this.writeExternalValue(initialState.document);
this.opts.selection.current = initialState.selection;
if (this.multilingual) {
this.seedLanguages(opts);
} else {
const normalizedInitial = normalizeDocument(opts.value.current as PalabrasDocument).document;
const initialState = createState(normalizedInitial, opts.selection.current);
this.history = createPalabrasHistory(initialState);
this.writeExternalValue(initialState.document);
this.opts.selection.current = initialState.selection;
}
this.runtime = this.soma.runtime(palabrasMorfo, {
props: {
@ -334,6 +432,18 @@ export class PalabrasProvider {
this.syncExternalState(this.opts.value.current, this.opts.selection.current);
});
$effect(() => {
const ext = this.opts.activeLang.current;
if (
this.multilingual &&
ext &&
ext !== this.activeLang &&
this.resolvedLanguages.includes(ext)
) {
this.setActiveLang(ext);
}
});
$effect(() => {
const root = this.contentRef.current;
if (!root) return;
@ -461,7 +571,10 @@ export class PalabrasProvider {
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
() =>
this.opts.invalid.current ||
(this.field?.isInvalid ?? false) ||
this.missingRequiredLangs.length > 0
);
readonly isEditable = $derived.by(() => !this.isDisabled && !this.isReadonly);
readonly isEmpty = $derived.by(() => this.plainText.trim().length === 0);
@ -1607,6 +1720,12 @@ export class PalabrasProvider {
};
readonly snippetProps = $derived.by(() => ({
multilingual: this.multilingual,
languages: this.resolvedLanguages,
activeLang: this.activeLang,
setActiveLang: (code: LangCode) => this.setActiveLang(code),
missingRequiredLangs: this.missingRequiredLangs,
langFilled: (code: LangCode) => this.langFilled(code),
document: this.document,
html: this.html,
blocks: this.blocks,
@ -2194,9 +2313,12 @@ export class PalabrasProvider {
}
private syncExternalState(
externalDocument: PalabrasDocument,
externalValue: PalabrasDocument | PalabrasLangValues,
externalSelection: PalabrasSelection | null
) {
const externalDocument = this.multilingual
? ((externalValue as PalabrasLangValues)[this.activeLang] ?? createEmptyDocument())
: (externalValue as PalabrasDocument);
const current = this.history.present;
// Fast path: our OWN edits round-trip back through the bound `value`, which
@ -2246,13 +2368,28 @@ export class PalabrasProvider {
}
private writeExternalValue(document: PalabrasDocument) {
if (this.multilingual) {
// Write the WHOLE per-language map (active edit + the other languages'
// current docs, all raw) so a bound `$state` re-proxies it once, cleanly.
const active = this.activeLang;
const map: PalabrasLangValues = {};
for (const code of this.resolvedLanguages) {
map[code] =
code === active
? document
: (this.langHistories.get(code)?.present.document ?? createEmptyDocument());
}
this.opts.value.current = map;
this.publishedExternal = (this.opts.value.current as PalabrasLangValues)[active] ?? null;
return;
}
// Assigning the same object is a Svelte no-op, so the old `!==` guard — which
// compared the $state PROXY against our raw doc and tripped
// `state_proxy_equality_mismatch` — is unnecessary. Cache the store's view of
// the doc (proxied under a `$state` binding) so `syncExternalState` recognises
// our own round-trip with a proxy↔proxy check, never proxy↔raw.
this.opts.value.current = document;
this.publishedExternal = this.opts.value.current;
this.publishedExternal = this.opts.value.current as PalabrasDocument;
}
private writeExternalSelection(selection: PalabrasSelection | null) {
@ -3361,6 +3498,14 @@ function samePalabrasSelection(a: PalabrasSelection | null, b: PalabrasSelection
);
}
function langCodeOf(spec: LangSpec): LangCode {
return typeof spec === 'string' ? spec : spec.code;
}
function documentHasContent(doc: PalabrasDocument): boolean {
return renderPalabrasPlainText(doc).trim().length > 0;
}
function samePalabrasDocument(a: PalabrasDocument, b: PalabrasDocument): boolean {
if (a.version !== b.version) return false;
if (a.children.length !== b.children.length) return false;

@ -121,7 +121,30 @@ export type PalabrasTocEntry = {
readonly path: readonly number[];
};
/** A content-language code, e.g. `'en'`, `'es'`, `'pt-BR'`. Distinct from the
* chrome UI locale (the `langs` system) — this is the language of the DOCUMENT. */
export type LangCode = string;
/** A supported content language: a bare code (its display name resolves via
* `Intl.DisplayNames`) or an explicit descriptor. Mirrors field-langs. */
export type LangSpec = LangCode | { code: LangCode; label?: string; dir?: 'ltr' | 'rtl' };
/** The multilingual `value` shape — one `PalabrasDocument` per language. */
export type PalabrasLangValues = Record<LangCode, PalabrasDocument>;
export type PalabrasProviderSnippetProps = {
/** True when the editor runs in multilingual mode (`languages` provided). */
readonly multilingual: boolean;
/** Resolved supported language codes (first = default active). */
readonly languages: readonly LangCode[];
/** The language whose document is currently being edited. */
readonly activeLang: LangCode;
/** Switch the active editing language (persists the current one). */
readonly setActiveLang: (code: LangCode) => void;
/** Required languages left with an empty document (field-langs `required`). */
readonly missingRequiredLangs: readonly LangCode[];
/** Whether a language's document carries any content. */
readonly langFilled: (code: LangCode) => boolean;
readonly document: PalabrasDocument;
readonly html: string;
/** Per-block projection of `html` (same content, memoized per block). */
@ -375,9 +398,21 @@ export type PalabrasProps = WithChild<
{
id?: string;
contentId?: string;
value?: PalabrasDocument;
defaultValue?: PalabrasDocument;
value?: PalabrasDocument | PalabrasLangValues;
defaultValue?: PalabrasDocument | PalabrasLangValues;
selection?: PalabrasSelection | null;
/** Content languages. PRESENCE switches the editor to multilingual mode:
* `value` becomes a `Record<LangCode, PalabrasDocument>` and a language
* switcher appears in the chrome. First entry = default active. */
languages?: LangSpec[];
/** Languages that must carry a non-empty document for validity
* (field-langs' `required`, renamed to avoid the field-level `required`). */
requiredLangs?: readonly LangCode[];
/** The language currently being edited. Bindable. Defaults to the first
* of `languages`; an out-of-range value falls back to it. */
activeLang?: LangCode;
/** Called when the active editing language changes. */
onLangChange?: OnChangeFn<LangCode>;
placeholder?: string;
name?: string;
disabled?: boolean;
@ -392,7 +427,7 @@ export type PalabrasProps = WithChild<
* every registered type. */
insertableBlocks?: readonly string[];
'aria-label'?: string;
onValueChange?: OnChangeFn<PalabrasDocument>;
onValueChange?: OnChangeFn<PalabrasDocument | PalabrasLangValues>;
onSelectionChange?: OnChangeFn<PalabrasSelection | null>;
onValueCommit?: (document: PalabrasDocument, reason: PalabrasCommitReason) => void;
children?: Snippet<[PalabrasProviderSnippetProps]>;

Loading…
Cancel
Save

Powered by TurnKey Linux.