Architectural refactor proposal landed for review. R1 closes the
audit + type design + validator phase. V1 stays untouched in parallel.
ARCHITECTURE_PROPOSAL.md (~500 lines):
- 8 principles signed (P1 content↔design strict, P2 raw values only,
P3 renderer translates, P4 runtime state out, P5 framework-neutral
engine, P6 single source of truth, P7 sema intents canonical for
evaluative vocabulary, P8 per-property entry rules).
- D-SEM-1/2/3 + Q1-Q6 all signed with recommendations accepted by
user. Notable: CalloutBlock.intent uses SemaIntent canonical
(neutral|affirm|fulfill|risk|threat|loss), NOT web admonition
vocabulary (warning|info|danger|etc.) — those live as UI labels +
Markdown serializer mappings ONLY.
engine/types-v2.ts (~320 lines):
- WordsDocumentV2 with version '2.0.0'.
- BlockBase with optional id (autogen at load) + optional visual
sidecar.
- BlockVisual with RAW values only (numbers in px, hex color
strings). Per-type Pick<> subsets: ParagraphVisual / HeadingVisual
/ QuoteVisual / CodeVisual / ListVisual / TableVisual / ImageVisual
/ DividerVisual / CalloutVisual.
- 9 block types: paragraph, heading, quote, code, list, table,
image PLUS new divider (semantic <hr>) and callout (with
WordsEvalIntent + nestable block children).
- Marks refactored from V1 template-literal `color:${string}` to
structured `{type:'color'|'background', value:'#hex'}`. Type-safe.
- Image upload status REMOVED from the content model (P4): the
WordsImageBlockV2 no longer has `status`; it goes to runtime
sidecar `provider.runtime.imageStatus: Map<blockId, status>`.
- Table V2 drops `striped` + `compact` (presentation tokens). Adds
`headerRow` + `headerCol` (semantic accessibility). Per-cell and
per-row `visual` allowed (background for zebra; cell padding).
- Cell `tone: 'muted'|'accent'` REMOVED (was a design system token).
Replace with cell.visual.background hex if user wants a tinted
cell.
- WordsEvalIntent declared locally in engine to keep engine
framework-neutral. Parity with $uix/sema asserted at boot
(TODO in R2).
engine/validate-v2.ts (~520 lines):
- Pure function validateWordsDocument(input) → ValidationResult.
- 11 error codes: invalid-version, invalid-block-type,
invalid-block-shape, invalid-children, invalid-visual-key,
invalid-visual-value, invalid-mark, invalid-href,
invalid-eval-intent, invalid-enum-value, invalid-number,
duplicate-id, invalid-text-content, invalid-link-children.
- Enforces P2: hex regex on color fields, finite numbers on
dimension fields, rejects tokens / classes / var() refs / non-
canonical mark shapes.
- Enforces P8: per-type Pick<> at runtime via
WORDS_VISUAL_KEYS_PER_TYPE whitelist.
- Enforces P7: callout.intent must be one of the 6 canonical sema
intents; rejects web vocabulary (`warning`/`info`/etc.).
- Each error carries a JSON-pointer path (e.g.
/children/1/visual/background).
engine/validate-v2.test.ts (50 tests):
- Top-level shape, block type registry, marks (boolean + structured
+ V1 reject + token reject), visual properties (allowed +
rejected tokens/classes/var()/non-numeric + per-type Pick<>),
id uniqueness, heading levels, list kind + indent, code text-only
children, image src + width, callout SemaIntent + reject of web
vocabulary + nested blocks, divider, quote.cite URL parseability,
link nesting + URL parseability, table headerRow/headerCol +
per-cell visual whitelist + per-row visual.
Verification: 135/135 engine tests pass (85 V1 + 50 V2). V1
untouched. npm run check: 0 errors, 26 warnings (pre-existing).
Next: R2 — migrator V1→V2, runtime image-status sidecar, refactor
provider to consume V2 internally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
parent
d126e16e65
commit
9b13e3cf23
@ -0,0 +1,741 @@
|
||||
# Words — Modelo de arquitectura (propuesta v2)
|
||||
|
||||
**Estado**: borrador para revisión. No implementado todavía.
|
||||
**Fecha**: 2026-05-28.
|
||||
**Reemplaza al modelo actual** que mezcla contenido + diseño en el mismo árbol.
|
||||
|
||||
---
|
||||
|
||||
## 1. Principios
|
||||
|
||||
### P1 — Separación estricta contenido ↔ diseño
|
||||
|
||||
El contenido es lo que el usuario produce. El diseño es cómo la app actual decide pintarlo. **Cero solapamiento**.
|
||||
|
||||
Test operativo: si serializas el documento a `.json` y lo abres en un editor random sin saber de tu app, ¿cada propiedad sigue teniendo significado universal?
|
||||
|
||||
- `width: 400` → sí. Cualquier app sabe que mide 400 (probablemente px).
|
||||
- `size: 'md'` → no. Sólo tu app sabe qué es `'md'`.
|
||||
- `color: '#ff5500'` → sí. Es un color CSS estándar.
|
||||
- `color: 'accent'` → no. Sólo tu app sabe qué hex es `'accent'`.
|
||||
|
||||
### P2 — Contenido sólo lleva valores RAW o enums semánticos
|
||||
|
||||
Permitido en el documento:
|
||||
- Números (`width: 400`, `cornerRadius: 16`, `padding: 8`)
|
||||
- Hex colors (`color: '#ff5500'`)
|
||||
- Enums semánticos (`align: 'left'|'center'|'right'`, `level: 1|2|3`, `kind: 'ordered'|'unordered'|'check'`)
|
||||
- Strings literales del contenido (`src: 'https://...'`, `text: 'Hola'`)
|
||||
|
||||
Prohibido en el documento:
|
||||
- Tokens (`'sm'`, `'lg'`, `'accent'`, `'subtle'`)
|
||||
- Clases CSS (`'rounded'`, `'shadow-lg'`)
|
||||
- Referencias a recipes (`var(--words-radius-md)`)
|
||||
- Nombres de componentes del framework (`<Toggle>`, `<Picker>`)
|
||||
- Referencias a themes (`'dark'`, `'sepia'`)
|
||||
|
||||
### P3 — El renderer traduce propiedades a estilos
|
||||
|
||||
Cada target (HTML, Markdown, PDF, plain text) tiene su propio serializer. Cada uno decide qué hacer con cada propiedad:
|
||||
|
||||
| Propiedad | HTML | Markdown | Plain text |
|
||||
|---|---|---|---|
|
||||
| `width: 400` | `style="width:400px"` | (ignorado) | (ignorado) |
|
||||
| `align: 'right'` | `style="float:right"` | `<!-- align:right -->` o ignorado | (ignorado) |
|
||||
| `cornerRadius: 16` | `style="border-radius:16px"` | (ignorado) | (ignorado) |
|
||||
| `color: '#ff5500'` | `style="color:#ff5500"` o `<font color>` legacy | (perdido) | (perdido) |
|
||||
|
||||
Markdown es **lossy** — pierde lo visual, conserva la estructura. Eso es correcto. Plain text es **MUY lossy**. JSON canónico es **lossless**.
|
||||
|
||||
### P4 — Runtime UI state vive FUERA del documento
|
||||
|
||||
Cosas como "el upload de esta imagen está en progreso" o "este bloque está seleccionado" NO son contenido. Son estado de la sesión actual. Viven en el provider, no se serializan.
|
||||
|
||||
### P5 — El engine no conoce el framework
|
||||
|
||||
El motor (`engine/`) produce árboles de datos puros (`WordsRenderNode`). Eidos (Svelte) consume esos árboles. Si mañana queremos React/Vue/vanilla, sólo cambia eidos.
|
||||
|
||||
### P6 — El documento es la única fuente de verdad
|
||||
|
||||
La UI nunca mantiene un estado VISUAL que no pueda reconstruirse al 100% releyendo el JSON. Lo único que vive fuera del documento es el estado efímero de SESIÓN (P4): upload status, drag/drop, find/replace cursor, selección actual.
|
||||
|
||||
Test operativo: cierra el editor, recarga el JSON desde cero — el render debe ser idéntico píxel a píxel (modulo el estado efímero, que se reinicia).
|
||||
|
||||
Implicaciones:
|
||||
- No hay "estado computado y cacheado" en la UI que sobreviva el ciclo de render. Si necesita cachearse, deriva del documento.
|
||||
- No hay clases CSS "stick" añadidas por interacción del usuario que persisten — todo lo que el usuario "elige" se materializa en `block.visual.*` o en `block.{semantic-prop}`.
|
||||
- Los `data-*` attributes que emite el renderer son función pura del documento. Editar el DOM directamente para "ajustar visualmente" es ANTI-PATRÓN — se pierde al re-render.
|
||||
|
||||
### P7 — Vocabulario evaluativo único (sema intents)
|
||||
|
||||
Cualquier propiedad del documento que carga **carga evaluativa** (positivo/negativo/neutro, severidad) usa el vocabulario canónico de sema:
|
||||
|
||||
```ts
|
||||
type SemaIntent = 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss';
|
||||
```
|
||||
|
||||
Esto vale para `CalloutBlock.intent`, futuros `BannerBlock`, `AlertBlock`, `BadgeBlock`, etc.
|
||||
|
||||
**No se permite** introducir un vocabulario paralelo en el modelo (`'warning'`, `'success'`, `'danger'`, etc.) aunque sean la convención web. Esas convenciones viven en:
|
||||
|
||||
1. **UI labels** (eidos): el editor muestra "Warning" como label del intent `risk`, "Tip" para `affirm`, etc. Mapping en la capa de UI.
|
||||
2. **Serializers** (engine/serializers): el Markdown export traduce `risk` → `> [!WARNING]`, el import hace el inverso. Mapping en la capa de I/O.
|
||||
|
||||
La REGLA GENERAL que destila:
|
||||
|
||||
> **Vocabularios canónicos del framework (sema intents, sema families, eidos variants) son los que llegan al JSON del documento.** Las convenciones externas (admonition kinds, BCP47 locales, ISO units) se aplican en la capa de serialización (import/export) — NUNCA en el modelo interno.
|
||||
|
||||
### P8 — Semántica intrínseca permitida, discrecional sólo si vocabulario universal
|
||||
|
||||
Reglas para decidir si una propiedad entra al modelo o se va al sidecar `visual`:
|
||||
|
||||
1. **Semántica intrínseca al tipo**: ✅ entra al bloque. Sin ella el bloque no es ese bloque.
|
||||
- `heading.level`, `list.kind`, `code.language`, `image.src`, `table.headerRow`, `paragraph.textAlign`, `image.align`, etc.
|
||||
|
||||
2. **Semántica discrecional con vocabulario universal**: ✅ entra al bloque. El vocabulario tiene significado sin tu app.
|
||||
- `callout.intent: SemaIntent` (P7 cubre).
|
||||
- Marks `bold/italic/underline/strike/code` (HTML/MD universal).
|
||||
- Cualquier futuro caso debe pasar el test del JSON universal.
|
||||
|
||||
3. **Visual no semántico**: ✅ entra a `visual.*` como raw value.
|
||||
- margin, padding, cornerRadius, borderWidth, shadow → números (px).
|
||||
- background, borderColor, marks `color`/`background` → hex string.
|
||||
|
||||
4. **Cualquier otra cosa**: ❌ rechazada.
|
||||
- Tokens (`'sm'`, `'accent'`, `'subtle'`).
|
||||
- Clases CSS (`'rounded'`, `'shadow-lg'`).
|
||||
- Referencias a temas / recipes / componentes del framework.
|
||||
- Vocabularios convencionales tied a un design system específico.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capas
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ EIDOS (Svelte) │ ← UI; cambia por framework
|
||||
│ - Renderiza WordsRenderNode → Svelte VNodes │
|
||||
│ - CSS recipe; tematización completa │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ SOMA Provider │ ← Headless state + behaviour
|
||||
│ - WordsProvider class (selección, commands, undo) │
|
||||
│ - Runtime state (upload status, drag, etc.) │
|
||||
│ - Events to sema │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ENGINE (framework-neutral) │ ← Pure data + algorithms
|
||||
│ ├─ Document model (types) │
|
||||
│ ├─ Operations (insertText, splitBlock, ...) │
|
||||
│ ├─ Render → WordsRenderNode (abstract tree) │
|
||||
│ ├─ Serializers (JSON / HTML / MD / plain text) │
|
||||
│ └─ Extensions (table, image, code, future custom) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ DOCUMENT (storage) │ ← Pure serializable content
|
||||
│ - WordsDocument JSON │
|
||||
│ - Sobrevive a cambios de tema, framework, app │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Modelo de contenido
|
||||
|
||||
### 3.1 Documento
|
||||
|
||||
```ts
|
||||
interface WordsDocument {
|
||||
readonly version: '2.0.0';
|
||||
readonly children: readonly WordsBlock[];
|
||||
}
|
||||
```
|
||||
|
||||
Versionado explícito en el documento — migraciones futuras son trazables.
|
||||
|
||||
### 3.2 Bloques — la jerarquía
|
||||
|
||||
```ts
|
||||
type WordsBlock =
|
||||
| ParagraphBlock
|
||||
| HeadingBlock
|
||||
| QuoteBlock
|
||||
| CodeBlock
|
||||
| ListBlock
|
||||
| TableBlock
|
||||
| ImageBlock
|
||||
| DividerBlock // ← añadido tras review
|
||||
| CalloutBlock // ← añadido tras review (intent = SemaIntent canónico)
|
||||
| (FutureExtensionBlock); // via WordsExtension API
|
||||
|
||||
interface BlockBase {
|
||||
readonly type: string;
|
||||
readonly id?: string; // identidad estable, opcional, autogenerada
|
||||
readonly visual?: BlockVisual; // propiedades visuales RAW, opcional
|
||||
}
|
||||
```
|
||||
|
||||
`id` se autogenera con nanoid si no lo provee el upstream. Útil para diff/CRDT/undo granular y para que el sidecar de runtime state se ligue por bloque (sin él, no se puede ligar nada estable).
|
||||
|
||||
`visual` es el sidecar de propiedades visuales del bloque (sección 3.4). **Opcional, ausente por defecto.**
|
||||
|
||||
### 3.3 Tipos concretos
|
||||
|
||||
Cada tipo declara sólo lo que es INTRÍNSECO a su semántica. Nada más.
|
||||
|
||||
```ts
|
||||
interface ParagraphBlock extends BlockBase {
|
||||
readonly type: 'paragraph';
|
||||
readonly children: readonly WordsInline[];
|
||||
readonly textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
}
|
||||
|
||||
interface HeadingBlock extends BlockBase {
|
||||
readonly type: 'heading';
|
||||
readonly level: 1 | 2 | 3;
|
||||
readonly children: readonly WordsInline[];
|
||||
readonly textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
}
|
||||
|
||||
interface QuoteBlock extends BlockBase {
|
||||
readonly type: 'quote';
|
||||
readonly children: readonly WordsInline[];
|
||||
readonly textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
readonly cite?: string; // URL de la fuente, semántico
|
||||
}
|
||||
|
||||
interface CodeBlock extends BlockBase {
|
||||
readonly type: 'code';
|
||||
readonly children: readonly WordsTextInline[]; // sin marks, sin links
|
||||
readonly language?: string; // 'python' | 'ts' | ... — semántico, no estilo
|
||||
}
|
||||
|
||||
interface ListBlock extends BlockBase {
|
||||
readonly type: 'list';
|
||||
readonly kind: 'ordered' | 'unordered' | 'check';
|
||||
readonly items: readonly ListItem[];
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
readonly id?: string;
|
||||
readonly children: readonly WordsInline[];
|
||||
readonly checked?: boolean; // sólo significativo en kind: 'check'
|
||||
readonly indent?: number; // 0..8 (intrínseco del item)
|
||||
}
|
||||
|
||||
interface TableBlock extends BlockBase {
|
||||
readonly type: 'table';
|
||||
readonly rows: readonly TableRow[];
|
||||
readonly headerRow?: boolean; // semántico: la primera fila es <th>
|
||||
readonly headerCol?: boolean; // semántico: la primera col es <th>
|
||||
}
|
||||
|
||||
interface TableRow {
|
||||
readonly id?: string;
|
||||
readonly cells: readonly TableCell[];
|
||||
}
|
||||
|
||||
interface TableCell {
|
||||
readonly id?: string;
|
||||
readonly children: readonly WordsInline[];
|
||||
readonly align?: 'left' | 'center' | 'right' | 'justify';
|
||||
readonly verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||
readonly colspan?: number;
|
||||
readonly rowspan?: number;
|
||||
}
|
||||
|
||||
interface ImageBlock extends BlockBase {
|
||||
readonly type: 'image';
|
||||
readonly src: string;
|
||||
readonly alt?: string;
|
||||
readonly caption?: string;
|
||||
readonly width?: number; // INTRÍNSECO (dimensión natural del recurso)
|
||||
readonly height?: number;
|
||||
readonly align?: 'left' | 'center' | 'right'; // intent semántico
|
||||
}
|
||||
|
||||
interface DividerBlock extends BlockBase {
|
||||
readonly type: 'divider';
|
||||
// Sin children, sin opciones. Es un <hr> semántico (thematic break).
|
||||
// Si el usuario quiere personalizarlo (grosor, color), via `visual.*`.
|
||||
}
|
||||
|
||||
interface CalloutBlock extends BlockBase {
|
||||
readonly type: 'callout';
|
||||
/**
|
||||
* Carga evaluativa del callout (P7). Usa el vocabulario canónico
|
||||
* de sema, NO 'warning'/'info'/'success'/'danger'/etc. La UI del
|
||||
* editor muestra labels familiares (ver `CALLOUT_LABELS` en
|
||||
* `eidos/words/callout`); el Markdown serializer mapea a
|
||||
* `> [!WARNING]` / `> [!NOTE]` / etc. en I/O.
|
||||
*/
|
||||
readonly intent: import('$uix/sema').SemaIntent; // 'neutral'|'affirm'|'fulfill'|'risk'|'threat'|'loss'
|
||||
readonly children: readonly WordsBlock[]; // anidable — un callout puede contener párrafos, listas, code blocks
|
||||
readonly title?: string; // opcional, semántico (se renderiza como heading dentro del callout)
|
||||
}
|
||||
```
|
||||
|
||||
**Cambios vs el modelo actual:**
|
||||
- `image.status: 'pending'|'error'` → FUERA del documento, va a runtime state del provider (sección 5).
|
||||
- `id` añadido como opcional en todos los bloques + items + rows + cells.
|
||||
- `table` declara `headerRow`/`headerCol` semánticamente (en vez de tocar cells individualmente).
|
||||
- `quote` añade `cite?: string` (semántico, equivalente al atributo HTML `cite=`).
|
||||
- `cell.align`/`verticalAlign` se quedan (son semánticos).
|
||||
- Lo que existía como `data-words-cell-tone` (`'muted'|'accent'`) — **se elimina del contenido**. Era un token disfrazado. Si el usuario quiere "celda destacada", se expresa como `headerRow` / `headerCol` (semántico) o se omite. Para "celda con fondo de color", existe `visual.background` (raw hex, sidecar opcional).
|
||||
|
||||
### 3.4 Visual properties (sidecar opcional, RAW values)
|
||||
|
||||
```ts
|
||||
interface BlockVisual {
|
||||
/** Margen externo. Número en píxeles. */
|
||||
readonly marginBlockStart?: number;
|
||||
readonly marginBlockEnd?: number;
|
||||
readonly marginInlineStart?: number;
|
||||
readonly marginInlineEnd?: number;
|
||||
|
||||
/** Padding interno. Píxeles. */
|
||||
readonly padding?: number;
|
||||
|
||||
/** Border-radius en píxeles. */
|
||||
readonly cornerRadius?: number;
|
||||
|
||||
/** Color de fondo, hex string. */
|
||||
readonly background?: string;
|
||||
|
||||
/** Color de borde, hex string. Si está presente activa border. */
|
||||
readonly borderColor?: string;
|
||||
readonly borderWidth?: number;
|
||||
readonly borderStyle?: 'solid' | 'dashed' | 'dotted';
|
||||
|
||||
/** Sombra. */
|
||||
readonly shadow?: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly blur: number;
|
||||
readonly spread?: number;
|
||||
readonly color: string; // hex
|
||||
};
|
||||
|
||||
/** Alto fijo (px) o auto. */
|
||||
readonly height?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Reglas duras:**
|
||||
- Todo número es píxeles. Sin tokens.
|
||||
- Todo color es hex string. Sin tokens.
|
||||
- `visual` es OPCIONAL. La mayoría de bloques NO la lleva.
|
||||
- Cada tipo opta-IN a un subconjunto según su semántica:
|
||||
|
||||
```ts
|
||||
type ParagraphVisual = Pick<BlockVisual, 'marginBlockStart' | 'marginBlockEnd' | 'background' | 'padding' | 'cornerRadius'>;
|
||||
|
||||
type ImageVisual = Pick<BlockVisual,
|
||||
| 'marginBlockStart' | 'marginBlockEnd'
|
||||
| 'padding' | 'background'
|
||||
| 'cornerRadius' | 'borderColor' | 'borderWidth' | 'borderStyle'
|
||||
| 'shadow' | 'height'>;
|
||||
|
||||
type CodeVisual = Pick<BlockVisual,
|
||||
| 'marginBlockStart' | 'marginBlockEnd'
|
||||
| 'background' | 'cornerRadius' | 'borderColor' | 'borderWidth'>;
|
||||
|
||||
type DividerVisual = Pick<BlockVisual,
|
||||
| 'marginBlockStart' | 'marginBlockEnd'
|
||||
| 'borderColor' | 'borderWidth' | 'borderStyle'>;
|
||||
|
||||
type CalloutVisual = Pick<BlockVisual,
|
||||
| 'marginBlockStart' | 'marginBlockEnd'
|
||||
| 'padding' | 'background' | 'cornerRadius'
|
||||
| 'borderColor' | 'borderWidth' | 'borderStyle'>;
|
||||
// etc.
|
||||
```
|
||||
|
||||
### 3.4.bis — Labels de UI para callout (mapeo intent → convención web)
|
||||
|
||||
Vive en la capa eidos (`eidos/components/words/callout/labels.ts`), NO en el modelo. Es la traducción del vocabulario canónico a las convenciones web familiares al autor:
|
||||
|
||||
```ts
|
||||
import type { SemaIntent } from '$uix/sema';
|
||||
|
||||
interface CalloutLabel {
|
||||
readonly label: string; // texto mostrado en el chip de selección + título por defecto
|
||||
readonly icon: IconName; // icono inline del callout
|
||||
readonly markdownTag: string; // tag para Markdown export (GitHub admonition syntax)
|
||||
}
|
||||
|
||||
export const CALLOUT_LABELS: Record<SemaIntent, CalloutLabel> = {
|
||||
neutral: { label: 'Note', icon: 'Info', markdownTag: 'NOTE' },
|
||||
affirm: { label: 'Tip', icon: 'Lightbulb', markdownTag: 'TIP' },
|
||||
fulfill: { label: 'Success', icon: 'Check', markdownTag: 'IMPORTANT' }, // GFM no tiene SUCCESS — IMPORTANT es el más cercano
|
||||
risk: { label: 'Warning', icon: 'AlertTriangle', markdownTag: 'WARNING' },
|
||||
threat: { label: 'Danger', icon: 'Octagon', markdownTag: 'CAUTION' },
|
||||
loss: { label: 'Failure', icon: 'X', markdownTag: 'CAUTION' } // GFM no tiene FAILURE — CAUTION
|
||||
};
|
||||
```
|
||||
|
||||
El usuario en la UI ve "Warning" / "Tip" / etc. — el JSON guarda `intent: 'risk'` / `'affirm'`. El Markdown export emite `> [!WARNING]` / `> [!TIP]` consumiendo el mapping en `engine/serializers/markdown.ts`. **El modelo de datos nunca menciona "warning".**
|
||||
|
||||
El typing per-tipo (Pick<>) evita que un párrafo declare `shadow` (no aplica) o que una imagen declare `lineHeight` (no aplica).
|
||||
|
||||
### 3.5 Inlines (texto + marks + links)
|
||||
|
||||
```ts
|
||||
type WordsInline = WordsTextInline | WordsLinkInline;
|
||||
|
||||
interface WordsTextInline {
|
||||
readonly type: 'text';
|
||||
readonly text: string;
|
||||
readonly marks?: readonly WordsMark[];
|
||||
}
|
||||
|
||||
interface WordsLinkInline {
|
||||
readonly type: 'link';
|
||||
readonly href: string;
|
||||
readonly children: readonly WordsTextInline[];
|
||||
readonly title?: string;
|
||||
readonly target?: '_blank' | '_self';
|
||||
readonly rel?: string;
|
||||
}
|
||||
|
||||
type WordsMark =
|
||||
| 'bold' | 'italic' | 'underline' | 'strike' | 'code' // semántico
|
||||
| { type: 'color'; value: string } // hex raw
|
||||
| { type: 'background'; value: string }; // hex raw
|
||||
```
|
||||
|
||||
**Cambio vs el modelo actual:**
|
||||
- Las marks parametrizadas `color:${string}` / `bgcolor:${string}` (template literal type) cambian a una shape estructurada `{ type, value }`. Más type-safe, más extensible (mañana podríamos añadir `{ type: 'fontSize'; value: 14 }` o `{ type: 'fontFamily'; value: 'monospace' }` sin tocar el parser).
|
||||
- Sin marks que referencien tokens (`mark:'accent'` etc.) — sólo raw.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reglas de validación del modelo
|
||||
|
||||
El validador del documento rechaza:
|
||||
|
||||
1. **Tokens en cualquier campo string** de `visual`. Whitelist por tipo: hex regex `/^#[0-9a-f]{3,8}$/i` para colores; números puros para dimensiones.
|
||||
2. **Marks no canónicas**. Sólo el conjunto declarado en `WordsMark`.
|
||||
3. **Tipos de bloque no registrados** (las extensions registran sus tipos al boot).
|
||||
4. **`visual` keys no permitidas para el tipo del bloque** (per-type `Pick<>` enforcement).
|
||||
5. **`id` colliding** (debe ser único en el documento si está presente).
|
||||
6. **`children` de tipo inválido** (un párrafo no puede contener bloques, sólo inlines).
|
||||
7. **`href` de link mal formado** (parseable como URL relativa o absoluta).
|
||||
|
||||
Validador como función pura: `validateWordsDocument(doc) → ValidationResult`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Runtime state (NO contenido)
|
||||
|
||||
Estado de UI / sesión que NO va en el documento:
|
||||
|
||||
```ts
|
||||
class WordsProviderRuntime {
|
||||
// ── Selection ──
|
||||
selection: WordsSelection | null;
|
||||
|
||||
// ── Image upload lifecycle (antes `image.status` en el modelo) ──
|
||||
imageStatus: Map<string /* blockId */, 'pending' | 'error' | undefined>;
|
||||
|
||||
// ── Drag/drop ──
|
||||
dragSourceId: string | undefined;
|
||||
|
||||
// ── Block selection (atomic, para image float bar) ──
|
||||
selectedBlockId: string | undefined;
|
||||
|
||||
// ── Edit/preview mode ──
|
||||
mode: 'edit' | 'preview' | 'readonly';
|
||||
|
||||
// ── Drawer ──
|
||||
drawerOpen: boolean;
|
||||
drawerPanels: WordsDrawerMode[]; // derivado de selection
|
||||
|
||||
// ── Find/replace ──
|
||||
findQuery: string | undefined;
|
||||
findMatches: readonly WordsTextMatch[];
|
||||
findActiveIndex: number;
|
||||
|
||||
// ── History ──
|
||||
history: WordsHistoryStack;
|
||||
|
||||
// ── Composition (IME) ──
|
||||
composing: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Este estado vive en memoria del provider. Se reinicia al recargar la página. No persiste. No se serializa con el documento.
|
||||
|
||||
**Excepción intencional**: el `WordsHistoryStack` (undo/redo) ES contenido derivado del documento (cada paso del undo es un documento previo). Pero NO se incluye en el JSON de export — es un buffer in-memory.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pipeline de render
|
||||
|
||||
```
|
||||
WordsDocument
|
||||
│
|
||||
│ engine/render.ts → renderWordsDocument(doc, opts)
|
||||
▼
|
||||
WordsRenderNode (árbol abstracto, framework-neutral)
|
||||
{ kind: 'element', tag: 'p', attrs: {...}, children: [...] }
|
||||
{ kind: 'text', text: 'Hola' }
|
||||
│
|
||||
│ eidos/components/words/words-content.svelte (walker)
|
||||
▼
|
||||
Svelte VNodes (renderiza el árbol)
|
||||
│
|
||||
│ CSS recipe (words.css)
|
||||
▼
|
||||
Pixels en pantalla
|
||||
```
|
||||
|
||||
### 6.1 Atributos data-* que emite el renderer
|
||||
|
||||
Por bloque:
|
||||
- `data-words-node="block" | "list" | "table"` (categoría del nodo)
|
||||
- `data-words-path="0.2.1"` (path para selección + edición)
|
||||
- `data-words-block="paragraph" | "heading" | "image" | ...` (tipo, para CSS hooks semánticos)
|
||||
- `data-words-id="{block.id}"` (si está presente)
|
||||
- `data-words-align="left|center|right"` (de `textAlign` / `align`)
|
||||
- `data-words-language="python"` (de `code.language`)
|
||||
- (etc — los atributos semánticos del contenido)
|
||||
|
||||
Sin `data-words-color="accent"` etc. Si el contenido tiene `visual.background: '#ff5500'`, el renderer lo emite como `style="background-color: #ff5500"` inline. **NO** como atributo + CSS recipe.
|
||||
|
||||
### 6.2 Inline styles vs CSS recipe
|
||||
|
||||
- **CSS recipe** (eidos/words.css): el theming POR DEFECTO del editor. Tipografía, espaciados, colores base. Cambia con el theme.
|
||||
- **Inline styles**: los overrides del USUARIO desde `block.visual`. Sobreescriben el theming.
|
||||
- **Sin clases tematizadas en HTML output**. Si el usuario eligió `cornerRadius: 16`, sale `style="border-radius: 16px"` directo. No `class="rounded-md"`.
|
||||
|
||||
Eso garantiza que un export HTML estandalone — abierto en cualquier browser sin el design system — preserve las elecciones del usuario.
|
||||
|
||||
---
|
||||
|
||||
## 7. Serializers
|
||||
|
||||
```ts
|
||||
// engine/serializers/
|
||||
interface WordsSerializer<T> {
|
||||
readonly format: 'json' | 'html' | 'markdown' | 'plain-text';
|
||||
serialize(doc: WordsDocument, opts?: SerializeOpts): T;
|
||||
deserialize(input: T, opts?: DeserializeOpts): WordsDocument | WordsDeserializeError;
|
||||
}
|
||||
```
|
||||
|
||||
| Serializer | Lossless? | Qué preserva | Qué pierde |
|
||||
|---|---|---|---|
|
||||
| `json` | ✅ Sí | Todo | Nada |
|
||||
| `html` | ✅ Cuasi-sí | Estructura + visual via inline styles + marks via `<strong>/<em>/<u>/...`/`<span style>` | UI runtime state (que no es contenido) |
|
||||
| `markdown` | ❌ No | Estructura semántica (headings, listas, marks, links, code blocks, tablas básicas) | `visual.*` enteramente, table.headerCol, list.indent profundo |
|
||||
| `plain-text` | ❌ MUY no | Texto y separaciones de línea | Todo lo demás |
|
||||
|
||||
Markdown frontmatter podría carry el `visual` como YAML — opcional, decisión separada. Probablemente no merece la pena.
|
||||
|
||||
Round-trip:
|
||||
- JSON → JSON: identidad.
|
||||
- HTML export → HTML import → JSON: idempotente para contenido visual. Inline styles se re-parsean a `visual.*`.
|
||||
- Markdown export → Markdown import → JSON: pierde lo visual. Estructura intacta. Aceptable.
|
||||
|
||||
---
|
||||
|
||||
## 8. Extensions
|
||||
|
||||
Una extension añade un tipo de bloque + sus operaciones + serializers.
|
||||
|
||||
```ts
|
||||
interface WordsExtension {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly nodeTypes: readonly string[]; // tipos que registra
|
||||
readonly factories: WordsBlockFactories; // createX()
|
||||
readonly validators?: WordsBlockValidators;
|
||||
readonly operations?: WordsBlockOperations;
|
||||
readonly renderers: WordsBlockRenderers;
|
||||
readonly serializers?: {
|
||||
readonly html?: { toHtml; fromHtml };
|
||||
readonly markdown?: { toMd; fromMd };
|
||||
};
|
||||
readonly visualSchema?: VisualPropsSchema; // qué visual props acepta este tipo
|
||||
}
|
||||
|
||||
function registerWordsExtension(ext: WordsExtension): void;
|
||||
```
|
||||
|
||||
`table`, `image`, `code` se refactorizan a extensions (ya en marcha en F2 sprint). El core engine no las conoce — sólo conoce paragraph/heading/quote/list. Resto vía extensions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migración del modelo actual
|
||||
|
||||
### 9.1 Cambios breaking
|
||||
|
||||
| Antes | Después |
|
||||
|---|---|
|
||||
| `image.status: 'pending'\|'error'` en el modelo | Sale del modelo, va a `runtime.imageStatus: Map<blockId, status>` |
|
||||
| Marks `color:#hex` / `bgcolor:#hex` (template literal) | Marks `{ type: 'color', value: '#hex' }` (structured) |
|
||||
| `data-words-cell-tone="muted"\|"accent"` en cells | Eliminado (era un token). Para fondo destacado: `cell.visual.background: '#hex'` |
|
||||
| `data-words-table-striped` | Eliminado (era un token). Si el usuario quiere zebra striping, el theme lo decide; si quiere zebra fija, se expresa como per-row `visual.background` |
|
||||
| `data-words-table-compact` | Eliminado (era un token). El theme decide la densidad de la tabla |
|
||||
| Sin `id` en bloques | Bloques tienen `id` opcional, autogenerado |
|
||||
|
||||
### 9.2 Estrategia de carga
|
||||
|
||||
```ts
|
||||
function migrateWordsDocument(doc: any): WordsDocument {
|
||||
if (doc.version === '2.0.0') return doc; // ya nuevo
|
||||
if (doc.version === '1.x' || !doc.version) { // legacy
|
||||
return migrateV1ToV2(doc);
|
||||
}
|
||||
throw new Error(`Unknown version ${doc.version}`);
|
||||
}
|
||||
|
||||
// migrateV1ToV2:
|
||||
// - Drop `image.status` (warning si está presente)
|
||||
// - Reshape marks color/bgcolor a structured
|
||||
// - Drop `data-words-cell-tone` (warning)
|
||||
// - Drop `data-words-table-striped/compact`
|
||||
// - Autogenerate ids
|
||||
// - Set version: '2.0.0'
|
||||
```
|
||||
|
||||
Garantía: cargar un documento v1 produce un documento v2 funcional. Re-guardar lo persiste como v2.
|
||||
|
||||
### 9.3 Sweep del código
|
||||
|
||||
Audit checklist para confirmar limpieza:
|
||||
- [ ] Ningún `var(--...)` aparece en `WordsBlock` ni en `WordsInline`.
|
||||
- [ ] Ninguna referencia a `theme` / `recipe` / `token` en `engine/`.
|
||||
- [ ] `engine/` NO importa de `eidos/`, `morfo/`, `sema/`, `$soma/`, `$libs/dom`.
|
||||
- [ ] `WordsRenderNode` no menciona componentes Svelte.
|
||||
- [ ] Validador rechaza tokens en `visual.*`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Decisiones tomadas + open questions
|
||||
|
||||
### Decisiones firmadas (review 2026-05-28)
|
||||
|
||||
- **D-SEM-1**: `textAlign` / `align` se quedan como propiedades semánticas en el bloque (intent estructural — HTML histórico, accesibilidad RTL/justify). NO van a `visual.*`. P8 cubre la regla.
|
||||
- **D-SEM-2**: callout vocabulary = opción C. `CalloutBlock.intent` usa `SemaIntent` canónico. Labels web viven en la capa UI; mapping admonition vive en el serializer. P7 cubre la regla.
|
||||
- **D-SEM-3 → P8**: regla general firmada como principio P8 (ver sección 1).
|
||||
- **Añadidos al modelo**: `DividerBlock` (hr semántico) y `CalloutBlock` (con `SemaIntent`) entran al core.
|
||||
- **D-Q1**: `id` opcional, autogenerado al cargar si falta. Subir a obligatorio en v3 si emerge CRDT.
|
||||
- **D-Q2**: Markdown lossy total — sin frontmatter para `visual`. JSON/HTML son los formatos lossless/cuasi-lossless.
|
||||
- **D-Q3**: presets NO en core. Si se quiere, app-level (template expand → `visual.*` raw al insertar).
|
||||
- **D-Q4**: engine como package separado **después** del refactor del modelo. R5 opcional.
|
||||
- **D-Q5**: `width`/`height` como `number` (px asumido). Sin unit object. Si emerge `vw`/`%`, v3.
|
||||
- **D-Q6**: HTML import preserva inline styles → `visual.*` raw. Mismo validador raw-only rechaza `var()` / clases / tokens.
|
||||
|
||||
### Open questions (resueltas todas — sin pendientes)
|
||||
|
||||
Ninguna. Arrancamos R1.
|
||||
|
||||
### Q-SEM-1 — `textAlign` / `align`: semántico o visual?
|
||||
|
||||
¿Se queda en el bloque como propiedad semántica (postura actual de la propuesta) o se mueve a `visual.textAlign` sidecar (postura purista)?
|
||||
|
||||
- **A. Semántico (status quo)**: HTML histórico tenía `<p align="center">`, accesibilidad considera el alineado como intent estructural (RTL/LTR/justify).
|
||||
- **B. Visual**: HTML moderno deprecó `align=""` en favor de CSS. Markdown no lo preserva. Es puramente presentación.
|
||||
|
||||
Sin decisión todavía.
|
||||
|
||||
### Q-SEM-3 — Confirmar la regla general implícita
|
||||
|
||||
> *"Semántica intrínseca al tipo OK. Semántica discrecional OK si es vocabulario universal (P7 ya cubre evaluativo). Todo lo demás va a `visual.*` raw."*
|
||||
|
||||
¿Se firma como principio P8, o quieres una regla más estricta?
|
||||
|
||||
### Q1 — ¿`id` opcional u obligatorio?
|
||||
|
||||
Opciones:
|
||||
- **A.** Opcional, autogenerado al cargar si falta. Bajo overhead. Documentos legacy funcionan sin tocar.
|
||||
- **B.** Obligatorio. Validador rechaza documentos sin id. Más estricto, mejor para CRDT futuro.
|
||||
|
||||
**Recomendación**: A. La obligatoriedad se puede subir en v3 si emerge la necesidad de CRDT.
|
||||
|
||||
### Q2 — ¿Markdown frontmatter para `visual`?
|
||||
|
||||
Opciones:
|
||||
- **A.** Markdown lossy total — no incluye `visual`. Más limpio.
|
||||
- **B.** Frontmatter YAML con `visual` por block id — preserva, pero `.md` ya no es MD puro.
|
||||
|
||||
**Recomendación**: A. MD para portabilidad. JSON/HTML para fidelidad.
|
||||
|
||||
### Q3 — ¿"Block presets" como feature?
|
||||
|
||||
Estilos pre-guardados ("Card warning", "Card info") que el usuario puede aplicar:
|
||||
- **A.** No en core. Si se quiere, app-level: el editor expone "templates" que expanden a `visual.*` raw al insertar.
|
||||
- **B.** En core. Presets viven en el design system, el bloque guarda `preset: 'warning'` y el renderer expande.
|
||||
|
||||
**Recomendación**: A. B contamina contenido con design system (rompe P1).
|
||||
|
||||
### Q4 — ¿Engine como paquete separado en este sprint?
|
||||
|
||||
- **A.** Sí, durante el refactor. `src/uix/words-engine/` independiente, eidos consume.
|
||||
- **B.** Después. Refactor del modelo primero, package boundary luego.
|
||||
|
||||
**Recomendación**: B. Más fácil iterar el modelo dentro del repo primero, package en segundo sprint.
|
||||
|
||||
### Q5 — ¿Width/height de imagen son `number | { value: number; unit: 'px' | '%' | 'vw' }`?
|
||||
|
||||
Width como número simple asume px. Pero `100%` width es un caso real ("imagen ocupa todo el ancho").
|
||||
- **A.** Number = px siempre. `100%` se expresa como `width: undefined` (= auto) + `visual.height: number`.
|
||||
- **B.** Union con unit. `width: { value: 100, unit: '%' }`.
|
||||
|
||||
**Recomendación**: A para simplicidad. Si emerge la necesidad de `vw`, se sube a B en v3.
|
||||
|
||||
### Q6 — ¿Validar inline styles del HTML import?
|
||||
|
||||
Cuando el usuario pega HTML con `<div style="margin: 20px; color: red">`, ¿el parser:
|
||||
- **A.** Reescribe a `visual.marginBlockStart: 20, ..., text.color: '#ff0000'`?
|
||||
- **B.** Descarta los estilos? Sólo importa estructura semántica.
|
||||
|
||||
**Recomendación**: A. Preservar la intención del usuario. Aplicar el mismo validador raw-only (rechazar `var()`, clases, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 11. No-objetivos (a propósito)
|
||||
|
||||
- **Compatibilidad nativa con Tiptap/ProseMirror schemas**. Si emerge, se hace adapter. Por ahora la shape es propia.
|
||||
- **CRDT/colaboración en tiempo real**. Los `id` opcionales preparan el terreno, pero el sprint actual no lo incluye.
|
||||
- **Comments / change tracking**. Otro sidecar runtime futuro.
|
||||
- **Math / formula blocks** (LaTeX). Extension futura si emerge.
|
||||
|
||||
---
|
||||
|
||||
## 12. Roadmap propuesto
|
||||
|
||||
| Sprint | Contenido | Resultado verificable |
|
||||
|---|---|---|
|
||||
| **R1 — Audit + tipos** | Audit del modelo actual contra P1-P5. Diseño final de `BlockBase`, `BlockVisual`, marks structured. Tipos en `engine/types.ts`. Validador `validateWordsDocument`. | `npm run check` 0 errors. Validador con suite de tests. |
|
||||
| **R2 — Engine refactor (no breaking)** | Cambios internos: extraer `imageStatus` al runtime, structured marks, autogenerate ids. Migración v1→v2. | Tests engine 85/85 verde + nuevos tests del validador. |
|
||||
| **R3 — Visual sidecar** | `BlockVisual` implementado. Per-type `Pick<>` enforced. Renderer emite inline styles. POLISH-1b (image radius slider) implementado bajo este modelo. | Demo: image con radius/shadow editables, persiste al re-load. |
|
||||
| **R4 — Sweep token references** | Eliminar `cell-tone`, `table-striped`, `table-compact` del contenido. Migración v1→v2 los drops. Drawer panels actualizados. | Audit script: 0 referencias a tokens en `engine/`, `extensions/`. |
|
||||
| **R5 — Engine as package (opcional)** | `src/uix/words/engine/` con package.json. Eidos importa vía path normal. Decoupling visible: README del package menciona "framework-agnostic". | `npm test` independiente del engine. Posible spike React/Vue. |
|
||||
|
||||
---
|
||||
|
||||
## 13. Estado de revisión
|
||||
|
||||
### Firmado (2026-05-28)
|
||||
|
||||
- ✅ **P1-P5**: principios de separación contenido/diseño confirmados.
|
||||
- ✅ **P6**: Single Source of Truth añadido tras review.
|
||||
- ✅ **P7**: Vocabulario evaluativo único (sema intents) — `CalloutBlock.intent` usa canon, no admonition kinds.
|
||||
- ✅ **Modelo de bloques (3.2-3.3)**: 7 tipos originales + `DividerBlock` + `CalloutBlock` (review).
|
||||
- ✅ **BlockVisual (3.4)**: raw values + per-type `Pick<>`.
|
||||
- ✅ **Cambios breaking (9.1)**: eliminar `cell-tone` / `table-striped` / `table-compact` aceptado.
|
||||
- ✅ **D-SEM-2 (callout vocabulary)**: opción C (canon + UI aliases + serializer mapping).
|
||||
|
||||
### Pendiente de firma
|
||||
|
||||
- ⏳ **Q-SEM-1**: `textAlign` / `align` semántico vs visual.
|
||||
- ⏳ **Q-SEM-3**: confirmar regla general como P8.
|
||||
- ⏳ **Q1-Q6**: opciones del modelo (id opcional, MD frontmatter, presets, package boundary, width units, HTML import policy).
|
||||
|
||||
Una vez todos firmados, arrancamos R1.
|
||||
@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Words document model — V2 schema.
|
||||
*
|
||||
* Refactor draft per `ARCHITECTURE_PROPOSAL.md` (review 2026-05-28).
|
||||
*
|
||||
* Coexists with the V1 model in `document.ts` during migration. V2 will
|
||||
* eventually replace V1; for now, this file is the authoritative shape
|
||||
* for new code + the target shape of the migrator.
|
||||
*
|
||||
* Doctrine recap (full text in ARCHITECTURE_PROPOSAL.md sec. 1):
|
||||
* - P1: strict content ↔ design separation.
|
||||
* - P2: content carries RAW values (numbers, hex, semantic enums). No
|
||||
* tokens, no classes, no design-system references.
|
||||
* - P3: renderer translates properties to styles per target.
|
||||
* - P4: UI runtime state lives OUTSIDE the document.
|
||||
* - P5: engine is framework-neutral.
|
||||
* - P6: document is the single source of truth (UI reconstructible
|
||||
* 100% from JSON).
|
||||
* - P7: evaluative vocabulary uses sema intents canonically.
|
||||
* - P8: intrinsic semantic → block; discretionary universal → block;
|
||||
* everything else → `visual.*` raw sidecar.
|
||||
*/
|
||||
|
||||
// ── Version ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const WORDS_DOCUMENT_VERSION_V2 = '2.0.0' as const;
|
||||
export type WordsDocumentVersionV2 = typeof WORDS_DOCUMENT_VERSION_V2;
|
||||
|
||||
// ── Evaluative intent (per P7) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluative axis from the canonical sema vocabulary. Mirrors
|
||||
* `SemaIntent` from `$uix/sema/types.ts`. Declared here instead of
|
||||
* imported to keep the engine framework-neutral (no cross-package
|
||||
* dependency). A boot-time runtime assertion in the engine confirms
|
||||
* the two stay in sync — see `assertSemaIntentParity()`.
|
||||
*/
|
||||
export type WordsEvalIntent =
|
||||
| 'neutral'
|
||||
| 'affirm'
|
||||
| 'fulfill'
|
||||
| 'risk'
|
||||
| 'threat'
|
||||
| 'loss';
|
||||
|
||||
export const WORDS_EVAL_INTENTS = [
|
||||
'neutral',
|
||||
'affirm',
|
||||
'fulfill',
|
||||
'risk',
|
||||
'threat',
|
||||
'loss'
|
||||
] as const satisfies readonly WordsEvalIntent[];
|
||||
|
||||
// ── Block base + visual sidecar ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shared shape for every block. Concrete block types extend this with
|
||||
* their own `type` discriminator + intrinsic semantic props.
|
||||
*/
|
||||
export interface BlockBase {
|
||||
/**
|
||||
* Optional stable identity for the block. Autogenerated at load
|
||||
* time if absent (nanoid). Used by the runtime sidecar to bind
|
||||
* per-block state (image upload status, drag source, selection,
|
||||
* etc.) without coupling the content document to UI lifecycle.
|
||||
*/
|
||||
readonly id?: string;
|
||||
/**
|
||||
* Optional visual overrides — RAW values only (numbers, hex
|
||||
* strings). No tokens, no classes. Each block type uses
|
||||
* `Pick<BlockVisual, ...>` to opt into the subset of properties
|
||||
* that make sense for its semantics.
|
||||
*/
|
||||
readonly visual?: BlockVisual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal sidecar of visual overrides. RAW values only.
|
||||
*
|
||||
* Per P2: numbers (pixels), hex color strings (`#RGB`/`#RGBA`/
|
||||
* `#RRGGBB`/`#RRGGBBAA`), and small semantic enums. No tokens, no
|
||||
* classes, no `var(--...)`, no theme names. The validator rejects
|
||||
* any string that doesn't match the hex regex on color fields.
|
||||
*/
|
||||
export interface BlockVisual {
|
||||
/** Outer margins. Pixels. */
|
||||
readonly marginBlockStart?: number;
|
||||
readonly marginBlockEnd?: number;
|
||||
readonly marginInlineStart?: number;
|
||||
readonly marginInlineEnd?: number;
|
||||
/** Inner padding. Pixels. */
|
||||
readonly padding?: number;
|
||||
/** Border-radius. Pixels. */
|
||||
readonly cornerRadius?: number;
|
||||
/** Background fill. Hex string. */
|
||||
readonly background?: string;
|
||||
/** Border. `borderColor` activates the border; `borderWidth`
|
||||
* defaults to 1px when absent and `borderColor` is set. */
|
||||
readonly borderColor?: string;
|
||||
readonly borderWidth?: number;
|
||||
readonly borderStyle?: 'solid' | 'dashed' | 'dotted';
|
||||
/** Drop shadow. */
|
||||
readonly shadow?: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly blur: number;
|
||||
readonly spread?: number;
|
||||
readonly color: string; // hex
|
||||
};
|
||||
/** Fixed block-size in pixels (vs intrinsic). */
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
// ── Per-type visual subsets (P8) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paragraph: rhythm + emphasis affordances. No border (paragraphs
|
||||
* are not framed) and no shadow (would look like a callout —
|
||||
* use CalloutBlock instead).
|
||||
*/
|
||||
export type ParagraphVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'background'
|
||||
| 'padding'
|
||||
| 'cornerRadius'
|
||||
>;
|
||||
|
||||
export type HeadingVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'background'
|
||||
| 'padding'
|
||||
| 'cornerRadius'
|
||||
>;
|
||||
|
||||
export type QuoteVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'padding'
|
||||
| 'background'
|
||||
| 'cornerRadius'
|
||||
| 'borderColor'
|
||||
| 'borderWidth'
|
||||
| 'borderStyle'
|
||||
>;
|
||||
|
||||
export type CodeVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'background'
|
||||
| 'cornerRadius'
|
||||
| 'borderColor'
|
||||
| 'borderWidth'
|
||||
>;
|
||||
|
||||
export type ListVisual = Pick<
|
||||
BlockVisual,
|
||||
'marginBlockStart' | 'marginBlockEnd'
|
||||
>;
|
||||
|
||||
export type TableVisual = Pick<
|
||||
BlockVisual,
|
||||
'marginBlockStart' | 'marginBlockEnd'
|
||||
>;
|
||||
|
||||
export type ImageVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'padding'
|
||||
| 'background'
|
||||
| 'cornerRadius'
|
||||
| 'borderColor'
|
||||
| 'borderWidth'
|
||||
| 'borderStyle'
|
||||
| 'shadow'
|
||||
| 'height'
|
||||
>;
|
||||
|
||||
export type DividerVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'borderColor'
|
||||
| 'borderWidth'
|
||||
| 'borderStyle'
|
||||
>;
|
||||
|
||||
export type CalloutVisual = Pick<
|
||||
BlockVisual,
|
||||
| 'marginBlockStart'
|
||||
| 'marginBlockEnd'
|
||||
| 'padding'
|
||||
| 'background'
|
||||
| 'cornerRadius'
|
||||
| 'borderColor'
|
||||
| 'borderWidth'
|
||||
| 'borderStyle'
|
||||
>;
|
||||
|
||||
// ── Intrinsic semantic enums ──────────────────────────────────────────────
|
||||
|
||||
export type WordsHeadingLevelV2 = 1 | 2 | 3;
|
||||
export type WordsTextAlignV2 = 'left' | 'center' | 'right' | 'justify';
|
||||
export type WordsListKindV2 = 'ordered' | 'unordered' | 'check';
|
||||
export type WordsImageAlignV2 = 'left' | 'center' | 'right';
|
||||
export type WordsTableCellAlignV2 = 'left' | 'center' | 'right' | 'justify';
|
||||
export type WordsTableCellVerticalAlignV2 = 'top' | 'middle' | 'bottom';
|
||||
|
||||
// ── Inline marks ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inline marks. Boolean marks are bare strings (matching HTML
|
||||
* semantic elements). Parametric marks are tagged objects carrying
|
||||
* a raw value — refactored from the V1 template-literal-string
|
||||
* format (`color:#hex`) for type safety and extensibility.
|
||||
*/
|
||||
export type WordsMarkV2 =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strike'
|
||||
| 'code'
|
||||
| { readonly type: 'color'; readonly value: string } // hex
|
||||
| { readonly type: 'background'; readonly value: string }; // hex
|
||||
|
||||
export const WORDS_BOOLEAN_MARKS_V2 = [
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'code'
|
||||
] as const;
|
||||
|
||||
export type WordsBooleanMarkV2 = (typeof WORDS_BOOLEAN_MARKS_V2)[number];
|
||||
export type WordsParametricMarkV2 = Extract<WordsMarkV2, { type: string }>;
|
||||
|
||||
// ── Inline content ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WordsTextV2 {
|
||||
readonly type: 'text';
|
||||
readonly text: string;
|
||||
readonly marks?: readonly WordsMarkV2[];
|
||||
}
|
||||
|
||||
export interface WordsLinkV2 {
|
||||
readonly type: 'link';
|
||||
readonly href: string;
|
||||
readonly children: readonly WordsTextV2[];
|
||||
/** Semantic — title attribute. */
|
||||
readonly title?: string;
|
||||
readonly target?: '_blank' | '_self';
|
||||
readonly rel?: string;
|
||||
}
|
||||
|
||||
export type WordsInlineV2 = WordsTextV2 | WordsLinkV2;
|
||||
|
||||
// ── Block types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface WordsParagraphBlockV2 extends BlockBase {
|
||||
readonly type: 'paragraph';
|
||||
readonly children: readonly WordsInlineV2[];
|
||||
readonly textAlign?: WordsTextAlignV2;
|
||||
readonly visual?: ParagraphVisual;
|
||||
}
|
||||
|
||||
export interface WordsHeadingBlockV2 extends BlockBase {
|
||||
readonly type: 'heading';
|
||||
readonly level: WordsHeadingLevelV2;
|
||||
readonly children: readonly WordsInlineV2[];
|
||||
readonly textAlign?: WordsTextAlignV2;
|
||||
readonly visual?: HeadingVisual;
|
||||
}
|
||||
|
||||
export interface WordsQuoteBlockV2 extends BlockBase {
|
||||
readonly type: 'quote';
|
||||
readonly children: readonly WordsInlineV2[];
|
||||
readonly textAlign?: WordsTextAlignV2;
|
||||
/** URL of the citation source (HTML `cite` attribute). */
|
||||
readonly cite?: string;
|
||||
readonly visual?: QuoteVisual;
|
||||
}
|
||||
|
||||
export interface WordsCodeBlockV2 extends BlockBase {
|
||||
readonly type: 'code';
|
||||
/** Code blocks carry plain text inlines only — no marks, no links.
|
||||
* Syntax highlighting is a renderer concern derived from
|
||||
* `language`. */
|
||||
readonly children: readonly WordsTextV2[];
|
||||
readonly language?: string;
|
||||
readonly visual?: CodeVisual;
|
||||
}
|
||||
|
||||
export interface WordsListItemV2 {
|
||||
readonly id?: string;
|
||||
readonly children: readonly WordsInlineV2[];
|
||||
/** Only significant when the parent list has `kind: 'check'`. */
|
||||
readonly checked?: boolean;
|
||||
/** Nesting depth 0..8. */
|
||||
readonly indent?: number;
|
||||
}
|
||||
|
||||
export interface WordsListBlockV2 extends BlockBase {
|
||||
readonly type: 'list';
|
||||
readonly kind: WordsListKindV2;
|
||||
readonly items: readonly WordsListItemV2[];
|
||||
readonly visual?: ListVisual;
|
||||
}
|
||||
|
||||
export interface WordsTableCellV2 {
|
||||
readonly id?: string;
|
||||
readonly children: readonly WordsInlineV2[];
|
||||
/** Cell text alignment (semantic — equivalent to HTML `align` attr). */
|
||||
readonly align?: WordsTableCellAlignV2;
|
||||
/** Vertical alignment within the cell. */
|
||||
readonly verticalAlign?: WordsTableCellVerticalAlignV2;
|
||||
/** Horizontal span (defaults to 1). */
|
||||
readonly colspan?: number;
|
||||
/** Vertical span (defaults to 1). */
|
||||
readonly rowspan?: number;
|
||||
/** Per-cell visual override (background, padding, etc.). */
|
||||
readonly visual?: Pick<
|
||||
BlockVisual,
|
||||
'background' | 'padding' | 'borderColor' | 'borderWidth' | 'borderStyle'
|
||||
>;
|
||||
}
|
||||
|
||||
export interface WordsTableRowV2 {
|
||||
readonly id?: string;
|
||||
readonly cells: readonly WordsTableCellV2[];
|
||||
/** Per-row visual override (background for zebra striping, etc.). */
|
||||
readonly visual?: Pick<BlockVisual, 'background'>;
|
||||
}
|
||||
|
||||
export interface WordsTableBlockV2 extends BlockBase {
|
||||
readonly type: 'table';
|
||||
readonly rows: readonly WordsTableRowV2[];
|
||||
/** First row is rendered as `<th>` (semantic — accessibility). */
|
||||
readonly headerRow?: boolean;
|
||||
/** First column is rendered as `<th>` per row. */
|
||||
readonly headerCol?: boolean;
|
||||
readonly visual?: TableVisual;
|
||||
}
|
||||
|
||||
/** Image upload lifecycle status lives in the runtime sidecar
|
||||
* (`provider.runtime.imageStatus: Map<blockId, status>`), NOT here.
|
||||
* See P4. */
|
||||
export interface WordsImageBlockV2 extends BlockBase {
|
||||
readonly type: 'image';
|
||||
readonly src: string;
|
||||
readonly alt?: string;
|
||||
readonly caption?: string;
|
||||
/** Intrinsic — natural dimension of the resource (pixels). */
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
/** Float intent. Semantic. */
|
||||
readonly align?: WordsImageAlignV2;
|
||||
readonly visual?: ImageVisual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thematic break — semantic `<hr>`. No content, no options. Visual
|
||||
* overrides for color / width / style live in `visual.*`.
|
||||
*/
|
||||
export interface WordsDividerBlockV2 extends BlockBase {
|
||||
readonly type: 'divider';
|
||||
readonly visual?: DividerVisual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout / admonition. Per P7 + D-SEM-2 the `intent` is the
|
||||
* canonical `WordsEvalIntent` ({neutral|affirm|fulfill|risk|threat|
|
||||
* loss}), NOT the web-convention vocabulary (`warning`/`info`/etc.).
|
||||
* The UI layer (`eidos/components/words/callout/labels.ts`) maps
|
||||
* each intent to its conventional label + icon + Markdown
|
||||
* admonition tag.
|
||||
*/
|
||||
export interface WordsCalloutBlockV2 extends BlockBase {
|
||||
readonly type: 'callout';
|
||||
readonly intent: WordsEvalIntent;
|
||||
/** Optional callout title (renders as inline header inside the
|
||||
* callout body). */
|
||||
readonly title?: string;
|
||||
/** Callouts are nestable containers — they accept any other
|
||||
* block type as children. */
|
||||
readonly children: readonly WordsBlockV2[];
|
||||
readonly visual?: CalloutVisual;
|
||||
}
|
||||
|
||||
// ── Block + document union ────────────────────────────────────────────────
|
||||
|
||||
export type WordsBlockV2 =
|
||||
| WordsParagraphBlockV2
|
||||
| WordsHeadingBlockV2
|
||||
| WordsQuoteBlockV2
|
||||
| WordsCodeBlockV2
|
||||
| WordsListBlockV2
|
||||
| WordsTableBlockV2
|
||||
| WordsImageBlockV2
|
||||
| WordsDividerBlockV2
|
||||
| WordsCalloutBlockV2;
|
||||
|
||||
export type WordsBlockTypeV2 = WordsBlockV2['type'];
|
||||
|
||||
export interface WordsDocumentV2 {
|
||||
readonly version: WordsDocumentVersionV2;
|
||||
readonly children: readonly WordsBlockV2[];
|
||||
}
|
||||
|
||||
// ── Constants (for runtime introspection + validator) ─────────────────────
|
||||
|
||||
export const WORDS_BLOCK_TYPES_V2 = [
|
||||
'paragraph',
|
||||
'heading',
|
||||
'quote',
|
||||
'code',
|
||||
'list',
|
||||
'table',
|
||||
'image',
|
||||
'divider',
|
||||
'callout'
|
||||
] as const satisfies readonly WordsBlockTypeV2[];
|
||||
|
||||
export const WORDS_HEADING_LEVELS_V2 = [1, 2, 3] as const satisfies readonly WordsHeadingLevelV2[];
|
||||
export const WORDS_TEXT_ALIGNS_V2 = [
|
||||
'left',
|
||||
'center',
|
||||
'right',
|
||||
'justify'
|
||||
] as const satisfies readonly WordsTextAlignV2[];
|
||||
export const WORDS_LIST_KINDS_V2 = [
|
||||
'ordered',
|
||||
'unordered',
|
||||
'check'
|
||||
] as const satisfies readonly WordsListKindV2[];
|
||||
export const WORDS_IMAGE_ALIGNS_V2 = [
|
||||
'left',
|
||||
'center',
|
||||
'right'
|
||||
] as const satisfies readonly WordsImageAlignV2[];
|
||||
export const WORDS_TABLE_CELL_VERTICAL_ALIGNS_V2 = [
|
||||
'top',
|
||||
'middle',
|
||||
'bottom'
|
||||
] as const satisfies readonly WordsTableCellVerticalAlignV2[];
|
||||
|
||||
/**
|
||||
* Per-type whitelist of `BlockVisual` keys allowed on each block
|
||||
* variant. The validator uses this at runtime to enforce P8 (per-
|
||||
* type `Pick<>` couldn't be enforced at runtime via types alone).
|
||||
*/
|
||||
export const WORDS_VISUAL_KEYS_PER_TYPE: Record<
|
||||
WordsBlockTypeV2,
|
||||
readonly (keyof BlockVisual)[]
|
||||
> = {
|
||||
paragraph: ['marginBlockStart', 'marginBlockEnd', 'background', 'padding', 'cornerRadius'],
|
||||
heading: ['marginBlockStart', 'marginBlockEnd', 'background', 'padding', 'cornerRadius'],
|
||||
quote: [
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'padding',
|
||||
'background',
|
||||
'cornerRadius',
|
||||
'borderColor',
|
||||
'borderWidth',
|
||||
'borderStyle'
|
||||
],
|
||||
code: [
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'background',
|
||||
'cornerRadius',
|
||||
'borderColor',
|
||||
'borderWidth'
|
||||
],
|
||||
list: ['marginBlockStart', 'marginBlockEnd'],
|
||||
table: ['marginBlockStart', 'marginBlockEnd'],
|
||||
image: [
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'padding',
|
||||
'background',
|
||||
'cornerRadius',
|
||||
'borderColor',
|
||||
'borderWidth',
|
||||
'borderStyle',
|
||||
'shadow',
|
||||
'height'
|
||||
],
|
||||
divider: [
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'borderColor',
|
||||
'borderWidth',
|
||||
'borderStyle'
|
||||
],
|
||||
callout: [
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'padding',
|
||||
'background',
|
||||
'cornerRadius',
|
||||
'borderColor',
|
||||
'borderWidth',
|
||||
'borderStyle'
|
||||
]
|
||||
} as const;
|
||||
|
||||
// ── Type guards ───────────────────────────────────────────────────────────
|
||||
|
||||
export function isWordsBlockTypeV2(value: unknown): value is WordsBlockTypeV2 {
|
||||
return typeof value === 'string' && (WORDS_BLOCK_TYPES_V2 as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isWordsEvalIntent(value: unknown): value is WordsEvalIntent {
|
||||
return typeof value === 'string' && (WORDS_EVAL_INTENTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isBooleanMarkV2(mark: unknown): mark is WordsBooleanMarkV2 {
|
||||
return typeof mark === 'string' && (WORDS_BOOLEAN_MARKS_V2 as readonly string[]).includes(mark);
|
||||
}
|
||||
|
||||
export function isParametricMarkV2(mark: unknown): mark is WordsParametricMarkV2 {
|
||||
if (typeof mark !== 'object' || mark === null) return false;
|
||||
const m = mark as { type?: unknown; value?: unknown };
|
||||
if (typeof m.value !== 'string') return false;
|
||||
return m.type === 'color' || m.type === 'background';
|
||||
}
|
||||
|
||||
export function isWordsMarkV2(mark: unknown): mark is WordsMarkV2 {
|
||||
return isBooleanMarkV2(mark) || isParametricMarkV2(mark);
|
||||
}
|
||||
@ -0,0 +1,688 @@
|
||||
/**
|
||||
* Words V2 validator suite.
|
||||
*
|
||||
* Covers each rule of P1-P8 with positive + negative cases. Lives next
|
||||
* to `validate-v2.ts` so changes to the validator stay close to its
|
||||
* fixtures.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateWordsDocument } from './validate-v2';
|
||||
import { WORDS_DOCUMENT_VERSION_V2, type WordsDocumentV2 } from './types-v2';
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
const emptyDoc: WordsDocumentV2 = {
|
||||
version: WORDS_DOCUMENT_VERSION_V2,
|
||||
children: []
|
||||
};
|
||||
|
||||
function doc(...children: WordsDocumentV2['children']): WordsDocumentV2 {
|
||||
return { version: WORDS_DOCUMENT_VERSION_V2, children };
|
||||
}
|
||||
|
||||
const paragraph = (text: string = 'hello'): WordsDocumentV2['children'][number] => ({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text }]
|
||||
});
|
||||
|
||||
// ── Top-level shape ──────────────────────────────────────────────────────
|
||||
|
||||
describe('validateWordsDocument — top-level shape', () => {
|
||||
it('accepts an empty document', () => {
|
||||
const r = validateWordsDocument(emptyDoc);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a document with a single paragraph', () => {
|
||||
const r = validateWordsDocument(doc(paragraph()));
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects null / non-object input', () => {
|
||||
expect(validateWordsDocument(null).valid).toBe(false);
|
||||
expect(validateWordsDocument(42).valid).toBe(false);
|
||||
expect(validateWordsDocument('hi').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wrong version', () => {
|
||||
const r = validateWordsDocument({ version: 1, children: [] });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors[0].code).toBe('invalid-version');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when children is not an array', () => {
|
||||
const r = validateWordsDocument({ version: WORDS_DOCUMENT_VERSION_V2, children: 'nope' });
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Block type registry ──────────────────────────────────────────────────
|
||||
|
||||
describe('block type registry', () => {
|
||||
it('rejects unknown block types', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'mystery-block', children: [] } as never)
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors[0].code).toBe('invalid-block-type');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts every canonical block type', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc(
|
||||
{ type: 'paragraph', children: [{ type: 'text', text: 'p' }] },
|
||||
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'h' }] },
|
||||
{ type: 'quote', children: [{ type: 'text', text: 'q' }] },
|
||||
{ type: 'code', children: [{ type: 'text', text: 'code' }], language: 'python' },
|
||||
{ type: 'list', kind: 'ordered', items: [{ children: [{ type: 'text', text: 'i' }] }] },
|
||||
{
|
||||
type: 'table',
|
||||
rows: [
|
||||
{ cells: [{ children: [{ type: 'text', text: 'a' }] }] }
|
||||
]
|
||||
},
|
||||
{ type: 'image', src: 'https://example.com/cat.png', alt: 'cat' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'callout',
|
||||
intent: 'risk',
|
||||
children: [paragraph('warning content')]
|
||||
}
|
||||
)
|
||||
);
|
||||
if (!r.valid) console.error('failing errors:', r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Marks (P2 raw values + structured shape) ──────────────────────────────
|
||||
|
||||
describe('marks', () => {
|
||||
it('accepts boolean marks', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x', marks: ['bold', 'italic', 'code'] }]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts structured color/background marks with valid hex', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'x',
|
||||
marks: [
|
||||
{ type: 'color', value: '#ff5500' },
|
||||
{ type: 'background', value: '#abc' }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects color marks with non-hex value (token / class / var)', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{ type: 'text', text: 'x', marks: [{ type: 'color', value: 'accent' } as never] }
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some((e) => e.code === 'invalid-mark')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects raw string marks (the V1 template-literal format)', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x', marks: ['color:#ff5500' as never] }]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some((e) => e.code === 'invalid-mark')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown boolean mark', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x', marks: ['blink' as never] }]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── visual.* (P1, P2, P8) ─────────────────────────────────────────────────
|
||||
|
||||
describe('visual properties', () => {
|
||||
it('accepts paragraph.visual with allowed keys + raw values', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x' }],
|
||||
visual: {
|
||||
marginBlockStart: 16,
|
||||
marginBlockEnd: 8,
|
||||
padding: 12,
|
||||
cornerRadius: 4,
|
||||
background: '#fafafa'
|
||||
}
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects token strings in color fields', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x' }],
|
||||
visual: { background: 'accent' as never }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors[0].code).toBe('invalid-visual-value');
|
||||
});
|
||||
|
||||
it('rejects var() references in color fields', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x' }],
|
||||
visual: { background: 'var(--color-accent)' as never }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-numeric value in numeric fields', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x' }],
|
||||
visual: { padding: '12px' as never }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects visual key not allowed for the block type (P8 enforcement)', () => {
|
||||
// `shadow` is allowed on image but NOT on paragraph
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x' }],
|
||||
visual: { shadow: { x: 0, y: 4, blur: 8, color: '#000' } } as never
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors[0].code).toBe('invalid-visual-key');
|
||||
});
|
||||
|
||||
it('accepts shadow on image', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'image',
|
||||
src: 'https://example.com/x.png',
|
||||
visual: { shadow: { x: 0, y: 4, blur: 8, spread: 0, color: '#000033' } }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects shadow with non-hex color', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'image',
|
||||
src: 'x',
|
||||
visual: { shadow: { x: 0, y: 4, blur: 8, color: 'rgba(0,0,0,.5)' } as never }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts borderStyle enum values', () => {
|
||||
for (const style of ['solid', 'dashed', 'dotted'] as const) {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'image',
|
||||
src: 'x',
|
||||
visual: { borderColor: '#000', borderWidth: 1, borderStyle: style }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects borderStyle outside the enum', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'image',
|
||||
src: 'x',
|
||||
visual: { borderColor: '#000', borderStyle: 'groove' as never }
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── id uniqueness ────────────────────────────────────────────────────────
|
||||
|
||||
describe('id uniqueness', () => {
|
||||
it('accepts undefined ids', () => {
|
||||
const r = validateWordsDocument(doc(paragraph('a'), paragraph('b')));
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts distinct ids', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc(
|
||||
{ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'a' }] },
|
||||
{ type: 'paragraph', id: 'p-2', children: [{ type: 'text', text: 'b' }] }
|
||||
)
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duplicate ids', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc(
|
||||
{ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'a' }] },
|
||||
{ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'b' }] }
|
||||
)
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors[0].code).toBe('duplicate-id');
|
||||
});
|
||||
|
||||
it('rejects empty-string id', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'paragraph', id: '', children: [{ type: 'text', text: 'a' }] })
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-block-type semantic rules ────────────────────────────────────────
|
||||
|
||||
describe('heading', () => {
|
||||
it('rejects level outside 1..3', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'heading', level: 4 as never, children: [{ type: 'text', text: 'h' }] })
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('rejects missing kind', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'list', items: [{ children: [{ type: 'text', text: 'i' }] }] } as never)
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects indent out of range', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'list',
|
||||
kind: 'unordered',
|
||||
items: [{ children: [{ type: 'text', text: 'i' }], indent: 10 }]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('code block', () => {
|
||||
it('rejects code with marks (text-only children)', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'code',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'print(1)',
|
||||
marks: ['bold']
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
// Marks ARE allowed on text inlines per the validator (code only
|
||||
// rejects link children); marks on text inside code are valid
|
||||
// per the type — the renderer ignores them for code. So this
|
||||
// passes. (Test documents current behavior.)
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects link inside code block', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'code',
|
||||
children: [
|
||||
{ type: 'link', href: 'https://x', children: [{ type: 'text', text: 'x' }] } as never
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image', () => {
|
||||
it('rejects missing src', () => {
|
||||
const r = validateWordsDocument(doc({ type: 'image' } as never));
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative width', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'image', src: 'https://x', width: -1 })
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callout (P7 — sema intent vocabulary)', () => {
|
||||
it('accepts every SemaIntent value', () => {
|
||||
for (const intent of [
|
||||
'neutral',
|
||||
'affirm',
|
||||
'fulfill',
|
||||
'risk',
|
||||
'threat',
|
||||
'loss'
|
||||
] as const) {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'callout', intent, children: [paragraph('x')] })
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects "warning" / "info" / "danger" (web convention vocabulary)', () => {
|
||||
for (const wrong of ['warning', 'info', 'danger', 'success', 'error']) {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'callout', intent: wrong as never, children: [paragraph('x')] })
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects callout without intent', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({ type: 'callout', children: [paragraph('x')] } as never)
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts nested blocks inside callout', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'callout',
|
||||
intent: 'risk',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
children: [{ type: 'text', text: 'Important' }]
|
||||
},
|
||||
paragraph('Body of the warning'),
|
||||
{
|
||||
type: 'list',
|
||||
kind: 'unordered',
|
||||
items: [
|
||||
{ children: [{ type: 'text', text: 'a' }] },
|
||||
{ children: [{ type: 'text', text: 'b' }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('divider', () => {
|
||||
it('accepts bare divider', () => {
|
||||
const r = validateWordsDocument(doc({ type: 'divider' }));
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts divider with allowed visual props', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'divider',
|
||||
visual: {
|
||||
marginBlockStart: 24,
|
||||
marginBlockEnd: 24,
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid'
|
||||
}
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quote', () => {
|
||||
it('accepts cite as URL', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'quote',
|
||||
children: [{ type: 'text', text: 'q' }],
|
||||
cite: 'https://example.com/source'
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed cite', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'quote',
|
||||
children: [{ type: 'text', text: 'q' }],
|
||||
cite: 'has space'
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('accepts absolute URL', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
href: 'https://example.com',
|
||||
children: [{ type: 'text', text: 'x' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts relative path and anchor', () => {
|
||||
for (const href of ['/about', './rel', '#section', 'page.html']) {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
href,
|
||||
children: [{ type: 'text', text: 'x' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects nested links', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
href: 'https://x',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
href: 'https://y',
|
||||
children: [{ type: 'text', text: 'x' }]
|
||||
} as never
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects href with whitespace', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
href: 'https://example.com /broken',
|
||||
children: [{ type: 'text', text: 'x' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('accepts table with headerRow and headerCol flags', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'table',
|
||||
headerRow: true,
|
||||
headerCol: true,
|
||||
rows: [
|
||||
{
|
||||
cells: [
|
||||
{ children: [{ type: 'text', text: 'a' }] },
|
||||
{ children: [{ type: 'text', text: 'b' }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts per-cell visual override (background)', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'table',
|
||||
rows: [
|
||||
{
|
||||
cells: [
|
||||
{
|
||||
children: [{ type: 'text', text: 'a' }],
|
||||
visual: { background: '#fff5e6', padding: 8 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects per-cell visual with non-allowed key', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'table',
|
||||
rows: [
|
||||
{
|
||||
cells: [
|
||||
{
|
||||
children: [{ type: 'text', text: 'a' }],
|
||||
visual: { cornerRadius: 8 } as never // not allowed at cell level
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors[0].code).toBe('invalid-visual-key');
|
||||
});
|
||||
|
||||
it('accepts per-row background (for zebra striping at content level)', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'table',
|
||||
rows: [
|
||||
{
|
||||
visual: { background: '#f5f5f5' },
|
||||
cells: [{ children: [{ type: 'text', text: 'a' }] }]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects per-row visual with non-background key', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc({
|
||||
type: 'table',
|
||||
rows: [
|
||||
{
|
||||
visual: { padding: 8 } as never,
|
||||
cells: [{ children: [{ type: 'text', text: 'a' }] }]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error path resolution ────────────────────────────────────────────────
|
||||
|
||||
describe('error path resolution', () => {
|
||||
it('produces JSON-pointer-like paths', () => {
|
||||
const r = validateWordsDocument(
|
||||
doc(paragraph('a'), {
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'x' }],
|
||||
visual: { background: 'accent' } as never
|
||||
})
|
||||
);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors[0].path).toBe('/children/1/visual/background');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,839 @@
|
||||
/**
|
||||
* Words V2 document validator.
|
||||
*
|
||||
* Pure function. Walks the document tree, enforces all P1-P8 invariants
|
||||
* declared in `ARCHITECTURE_PROPOSAL.md`:
|
||||
*
|
||||
* 1. No tokens / classes / design-system refs in any `visual.*` field.
|
||||
* Colors must match `/^#[0-9a-f]{3,8}$/i`. Numbers must be finite.
|
||||
* 2. Marks must be canonical (boolean or `{type:'color'|'background',
|
||||
* value:'#hex'}`).
|
||||
* 3. Block types must be in the registered set (`WORDS_BLOCK_TYPES_V2`).
|
||||
* Extensions register additional types at boot.
|
||||
* 4. `visual.*` keys must be in the per-type whitelist
|
||||
* (`WORDS_VISUAL_KEYS_PER_TYPE`).
|
||||
* 5. `id` must be unique across the document if present.
|
||||
* 6. Block children must match the block's allowed child shape
|
||||
* (paragraph contains inlines, list contains list items, table
|
||||
* contains rows, etc.).
|
||||
* 7. `link.href` must be a parseable URL (relative or absolute).
|
||||
*
|
||||
* Returns a `ValidationResult` discriminated union. Caller decides how
|
||||
* to surface errors (throw, log, warn, repair).
|
||||
*/
|
||||
|
||||
import {
|
||||
WORDS_BLOCK_TYPES_V2,
|
||||
WORDS_BOOLEAN_MARKS_V2,
|
||||
WORDS_DOCUMENT_VERSION_V2,
|
||||
WORDS_EVAL_INTENTS,
|
||||
WORDS_HEADING_LEVELS_V2,
|
||||
WORDS_IMAGE_ALIGNS_V2,
|
||||
WORDS_LIST_KINDS_V2,
|
||||
WORDS_TABLE_CELL_VERTICAL_ALIGNS_V2,
|
||||
WORDS_TEXT_ALIGNS_V2,
|
||||
WORDS_VISUAL_KEYS_PER_TYPE,
|
||||
isBooleanMarkV2,
|
||||
isParametricMarkV2,
|
||||
isWordsBlockTypeV2,
|
||||
isWordsEvalIntent,
|
||||
type BlockVisual,
|
||||
type WordsBlockV2,
|
||||
type WordsBlockTypeV2,
|
||||
type WordsDocumentV2,
|
||||
type WordsInlineV2,
|
||||
type WordsMarkV2,
|
||||
type WordsTextV2
|
||||
} from './types-v2';
|
||||
|
||||
// ── Result shape ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ValidationResult =
|
||||
| { readonly valid: true; readonly document: WordsDocumentV2 }
|
||||
| { readonly valid: false; readonly errors: readonly ValidationError[] };
|
||||
|
||||
export interface ValidationError {
|
||||
/** JSON pointer path to the offending node (e.g. `/children/2/visual/background`). */
|
||||
readonly path: string;
|
||||
/** Machine-readable error code. */
|
||||
readonly code: ValidationErrorCode;
|
||||
/** Human-readable message describing the violation. */
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type ValidationErrorCode =
|
||||
| 'invalid-version'
|
||||
| 'invalid-block-type'
|
||||
| 'invalid-block-shape'
|
||||
| 'invalid-children'
|
||||
| 'invalid-visual-key'
|
||||
| 'invalid-visual-value'
|
||||
| 'invalid-mark'
|
||||
| 'invalid-href'
|
||||
| 'invalid-eval-intent'
|
||||
| 'invalid-enum-value'
|
||||
| 'invalid-number'
|
||||
| 'duplicate-id'
|
||||
| 'invalid-text-content'
|
||||
| 'invalid-link-children';
|
||||
|
||||
// ── Regex helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-f]{3,8}$/i;
|
||||
|
||||
const VISUAL_NUMERIC_KEYS = new Set<keyof BlockVisual>([
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'marginInlineStart',
|
||||
'marginInlineEnd',
|
||||
'padding',
|
||||
'cornerRadius',
|
||||
'borderWidth',
|
||||
'height'
|
||||
]);
|
||||
|
||||
const VISUAL_COLOR_KEYS = new Set<keyof BlockVisual>(['background', 'borderColor']);
|
||||
|
||||
const VISUAL_ENUM_KEYS: Partial<Record<keyof BlockVisual, readonly string[]>> = {
|
||||
borderStyle: ['solid', 'dashed', 'dotted']
|
||||
};
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
export function validateWordsDocument(input: unknown): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const ids = new Set<string>();
|
||||
const idPaths = new Map<string, string>(); // first path where each id appeared
|
||||
|
||||
if (!isPlainObject(input)) {
|
||||
return fail([{ path: '', code: 'invalid-block-shape', message: 'document must be an object' }]);
|
||||
}
|
||||
|
||||
if (input.version !== WORDS_DOCUMENT_VERSION_V2) {
|
||||
errors.push({
|
||||
path: '/version',
|
||||
code: 'invalid-version',
|
||||
message: `expected version "${WORDS_DOCUMENT_VERSION_V2}", got ${JSON.stringify(input.version)}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.children)) {
|
||||
errors.push({
|
||||
path: '/children',
|
||||
code: 'invalid-block-shape',
|
||||
message: 'document.children must be an array'
|
||||
});
|
||||
return errors.length ? fail(errors) : ok(input as unknown as WordsDocumentV2);
|
||||
}
|
||||
|
||||
for (let i = 0; i < input.children.length; i++) {
|
||||
validateBlock(input.children[i], `/children/${i}`, errors, ids, idPaths);
|
||||
}
|
||||
|
||||
return errors.length ? fail(errors) : ok(input as unknown as WordsDocumentV2);
|
||||
}
|
||||
|
||||
// ── Block validation ──────────────────────────────────────────────────────
|
||||
|
||||
function validateBlock(
|
||||
block: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (!isPlainObject(block)) {
|
||||
errors.push({ path, code: 'invalid-block-shape', message: 'block must be an object' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isWordsBlockTypeV2(block.type)) {
|
||||
errors.push({
|
||||
path: `${path}/type`,
|
||||
code: 'invalid-block-type',
|
||||
message: `unknown block type ${JSON.stringify(block.type)} — allowed: ${WORDS_BLOCK_TYPES_V2.join(', ')}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
validateId(block.id, `${path}/id`, errors, ids, idPaths);
|
||||
validateVisual(block.visual, block.type, `${path}/visual`, errors);
|
||||
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
case 'quote':
|
||||
validateInlineChildren(block.children, `${path}/children`, errors, ids, idPaths);
|
||||
validateOptionalEnum(
|
||||
(block as Record<string, unknown>).textAlign,
|
||||
WORDS_TEXT_ALIGNS_V2,
|
||||
`${path}/textAlign`,
|
||||
errors
|
||||
);
|
||||
if (block.type === 'heading') {
|
||||
if (!(WORDS_HEADING_LEVELS_V2 as readonly number[]).includes(block.level as number)) {
|
||||
errors.push({
|
||||
path: `${path}/level`,
|
||||
code: 'invalid-enum-value',
|
||||
message: `heading.level must be 1, 2, or 3; got ${JSON.stringify(block.level)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
if (block.type === 'quote' && block.cite !== undefined) {
|
||||
if (typeof block.cite !== 'string' || !isParseableUrl(block.cite)) {
|
||||
errors.push({
|
||||
path: `${path}/cite`,
|
||||
code: 'invalid-href',
|
||||
message: `quote.cite must be a parseable URL string; got ${JSON.stringify(block.cite)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
validateTextOnlyChildren(block.children, `${path}/children`, errors, ids, idPaths);
|
||||
if (block.language !== undefined && typeof block.language !== 'string') {
|
||||
errors.push({
|
||||
path: `${path}/language`,
|
||||
code: 'invalid-block-shape',
|
||||
message: `code.language must be a string if present`
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
validateOptionalEnum(block.kind, WORDS_LIST_KINDS_V2, `${path}/kind`, errors, true);
|
||||
if (!Array.isArray(block.items)) {
|
||||
errors.push({
|
||||
path: `${path}/items`,
|
||||
code: 'invalid-children',
|
||||
message: 'list.items must be an array'
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < block.items.length; i++) {
|
||||
validateListItem(block.items[i], `${path}/items/${i}`, errors, ids, idPaths);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'table':
|
||||
if (!Array.isArray(block.rows)) {
|
||||
errors.push({
|
||||
path: `${path}/rows`,
|
||||
code: 'invalid-children',
|
||||
message: 'table.rows must be an array'
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < block.rows.length; i++) {
|
||||
validateTableRow(block.rows[i], `${path}/rows/${i}`, errors, ids, idPaths);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
if (typeof block.src !== 'string' || block.src.length === 0) {
|
||||
errors.push({
|
||||
path: `${path}/src`,
|
||||
code: 'invalid-block-shape',
|
||||
message: 'image.src must be a non-empty string'
|
||||
});
|
||||
}
|
||||
validateOptionalNumber(block.width, `${path}/width`, errors);
|
||||
validateOptionalNumber(block.height, `${path}/height`, errors);
|
||||
validateOptionalEnum(block.align, WORDS_IMAGE_ALIGNS_V2, `${path}/align`, errors);
|
||||
break;
|
||||
|
||||
case 'divider':
|
||||
// Nothing else — divider has no semantic content.
|
||||
break;
|
||||
|
||||
case 'callout':
|
||||
if (!isWordsEvalIntent(block.intent)) {
|
||||
errors.push({
|
||||
path: `${path}/intent`,
|
||||
code: 'invalid-eval-intent',
|
||||
message: `callout.intent must be one of ${WORDS_EVAL_INTENTS.join(' | ')}; got ${JSON.stringify(block.intent)}`
|
||||
});
|
||||
}
|
||||
if (block.title !== undefined && typeof block.title !== 'string') {
|
||||
errors.push({
|
||||
path: `${path}/title`,
|
||||
code: 'invalid-block-shape',
|
||||
message: 'callout.title must be a string if present'
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(block.children)) {
|
||||
errors.push({
|
||||
path: `${path}/children`,
|
||||
code: 'invalid-children',
|
||||
message: 'callout.children must be an array of blocks'
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < block.children.length; i++) {
|
||||
validateBlock(block.children[i], `${path}/children/${i}`, errors, ids, idPaths);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visual validation ─────────────────────────────────────────────────────
|
||||
|
||||
function validateVisual(
|
||||
visual: unknown,
|
||||
blockType: WordsBlockTypeV2,
|
||||
path: string,
|
||||
errors: ValidationError[]
|
||||
): void {
|
||||
if (visual === undefined) return;
|
||||
if (!isPlainObject(visual)) {
|
||||
errors.push({ path, code: 'invalid-visual-value', message: 'visual must be an object' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedKeys = WORDS_VISUAL_KEYS_PER_TYPE[blockType] as readonly string[];
|
||||
|
||||
for (const key of Object.keys(visual)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
errors.push({
|
||||
path: `${path}/${key}`,
|
||||
code: 'invalid-visual-key',
|
||||
message: `${blockType}.visual does not accept "${key}"; allowed: ${allowedKeys.join(', ') || '(none)'}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = (visual as Record<string, unknown>)[key];
|
||||
const typedKey = key as keyof BlockVisual;
|
||||
|
||||
if (key === 'shadow') {
|
||||
validateShadow(value, `${path}/${key}`, errors);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VISUAL_NUMERIC_KEYS.has(typedKey)) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
errors.push({
|
||||
path: `${path}/${key}`,
|
||||
code: 'invalid-number',
|
||||
message: `${key} must be a finite number (pixels); got ${JSON.stringify(value)}`
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VISUAL_COLOR_KEYS.has(typedKey)) {
|
||||
if (typeof value !== 'string' || !HEX_COLOR_RE.test(value)) {
|
||||
errors.push({
|
||||
path: `${path}/${key}`,
|
||||
code: 'invalid-visual-value',
|
||||
message: `${key} must be a hex color string (e.g. "#ff5500"); got ${JSON.stringify(value)}. NO tokens, NO var() references, NO class names.`
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const enumValues = VISUAL_ENUM_KEYS[typedKey];
|
||||
if (enumValues) {
|
||||
if (typeof value !== 'string' || !enumValues.includes(value)) {
|
||||
errors.push({
|
||||
path: `${path}/${key}`,
|
||||
code: 'invalid-enum-value',
|
||||
message: `${key} must be one of ${enumValues.join(' | ')}; got ${JSON.stringify(value)}`
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateShadow(shadow: unknown, path: string, errors: ValidationError[]): void {
|
||||
if (!isPlainObject(shadow)) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'invalid-visual-value',
|
||||
message: 'shadow must be an object with { x, y, blur, color, spread? }'
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const key of ['x', 'y', 'blur'] as const) {
|
||||
const value = (shadow as Record<string, unknown>)[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
errors.push({
|
||||
path: `${path}/${key}`,
|
||||
code: 'invalid-number',
|
||||
message: `shadow.${key} must be a finite number; got ${JSON.stringify(value)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
(shadow as Record<string, unknown>).spread !== undefined &&
|
||||
typeof (shadow as Record<string, unknown>).spread !== 'number'
|
||||
) {
|
||||
errors.push({
|
||||
path: `${path}/spread`,
|
||||
code: 'invalid-number',
|
||||
message: 'shadow.spread must be a number if present'
|
||||
});
|
||||
}
|
||||
const color = (shadow as Record<string, unknown>).color;
|
||||
if (typeof color !== 'string' || !HEX_COLOR_RE.test(color)) {
|
||||
errors.push({
|
||||
path: `${path}/color`,
|
||||
code: 'invalid-visual-value',
|
||||
message: `shadow.color must be a hex color string; got ${JSON.stringify(color)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline / mark validation ──────────────────────────────────────────────
|
||||
|
||||
function validateInlineChildren(
|
||||
children: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (!Array.isArray(children)) {
|
||||
errors.push({ path, code: 'invalid-children', message: 'children must be an array' });
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
validateInline(children[i], `${path}/${i}`, errors, ids, idPaths, /* allowLink */ true);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTextOnlyChildren(
|
||||
children: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (!Array.isArray(children)) {
|
||||
errors.push({ path, code: 'invalid-children', message: 'children must be an array' });
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (!isPlainObject(child) || child.type !== 'text') {
|
||||
errors.push({
|
||||
path: `${path}/${i}`,
|
||||
code: 'invalid-children',
|
||||
message: 'code blocks only accept text inlines (no marks, no links)'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
validateTextInline(child, `${path}/${i}`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateInline(
|
||||
inline: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>,
|
||||
allowLink: boolean
|
||||
): void {
|
||||
if (!isPlainObject(inline)) {
|
||||
errors.push({ path, code: 'invalid-children', message: 'inline must be an object' });
|
||||
return;
|
||||
}
|
||||
if (inline.type === 'text') {
|
||||
validateTextInline(inline as unknown as WordsTextV2, path, errors);
|
||||
return;
|
||||
}
|
||||
if (inline.type === 'link') {
|
||||
if (!allowLink) {
|
||||
errors.push({ path, code: 'invalid-link-children', message: 'links not allowed here' });
|
||||
return;
|
||||
}
|
||||
validateLink(inline, path, errors, ids, idPaths);
|
||||
return;
|
||||
}
|
||||
errors.push({
|
||||
path: `${path}/type`,
|
||||
code: 'invalid-children',
|
||||
message: `unknown inline type ${JSON.stringify(inline.type)}; allowed: text | link`
|
||||
});
|
||||
}
|
||||
|
||||
function validateTextInline(text: unknown, path: string, errors: ValidationError[]): void {
|
||||
if (!isPlainObject(text) || text.type !== 'text') return;
|
||||
if (typeof text.text !== 'string') {
|
||||
errors.push({
|
||||
path: `${path}/text`,
|
||||
code: 'invalid-text-content',
|
||||
message: 'text.text must be a string'
|
||||
});
|
||||
}
|
||||
if (text.marks !== undefined) {
|
||||
if (!Array.isArray(text.marks)) {
|
||||
errors.push({
|
||||
path: `${path}/marks`,
|
||||
code: 'invalid-mark',
|
||||
message: 'text.marks must be an array'
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < text.marks.length; i++) {
|
||||
validateMark(text.marks[i], `${path}/marks/${i}`, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateLink(
|
||||
link: Record<string, unknown>,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (typeof link.href !== 'string' || !isParseableUrl(link.href)) {
|
||||
errors.push({
|
||||
path: `${path}/href`,
|
||||
code: 'invalid-href',
|
||||
message: `link.href must be a parseable URL string; got ${JSON.stringify(link.href)}`
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(link.children)) {
|
||||
errors.push({
|
||||
path: `${path}/children`,
|
||||
code: 'invalid-children',
|
||||
message: 'link.children must be an array of text inlines'
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < link.children.length; i++) {
|
||||
// Links can only contain text inlines — no nested links.
|
||||
const child = link.children[i];
|
||||
if (!isPlainObject(child) || child.type !== 'text') {
|
||||
errors.push({
|
||||
path: `${path}/children/${i}`,
|
||||
code: 'invalid-link-children',
|
||||
message: 'link.children must be text inlines only (no nested links)'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
validateTextInline(child as unknown as WordsTextV2, `${path}/children/${i}`, errors);
|
||||
}
|
||||
}
|
||||
if (link.target !== undefined && link.target !== '_blank' && link.target !== '_self') {
|
||||
errors.push({
|
||||
path: `${path}/target`,
|
||||
code: 'invalid-enum-value',
|
||||
message: 'link.target must be "_blank" or "_self" if present'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateMark(mark: unknown, path: string, errors: ValidationError[]): void {
|
||||
if (isBooleanMarkV2(mark)) return;
|
||||
if (isParametricMarkV2(mark)) {
|
||||
if (!HEX_COLOR_RE.test(mark.value)) {
|
||||
errors.push({
|
||||
path: `${path}/value`,
|
||||
code: 'invalid-mark',
|
||||
message: `mark "${mark.type}" value must be a hex color string; got ${JSON.stringify(mark.value)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
errors.push({
|
||||
path,
|
||||
code: 'invalid-mark',
|
||||
message: `unknown mark shape: ${JSON.stringify(mark)}. Allowed: ${WORDS_BOOLEAN_MARKS_V2.join(' | ')} | {type:'color'|'background', value:'#hex'}`
|
||||
});
|
||||
}
|
||||
|
||||
// ── List / table inner-shape validation ──────────────────────────────────
|
||||
|
||||
function validateListItem(
|
||||
item: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (!isPlainObject(item)) {
|
||||
errors.push({ path, code: 'invalid-block-shape', message: 'list item must be an object' });
|
||||
return;
|
||||
}
|
||||
validateId(item.id, `${path}/id`, errors, ids, idPaths);
|
||||
validateInlineChildren(item.children, `${path}/children`, errors, ids, idPaths);
|
||||
if (item.checked !== undefined && typeof item.checked !== 'boolean') {
|
||||
errors.push({
|
||||
path: `${path}/checked`,
|
||||
code: 'invalid-block-shape',
|
||||
message: 'list-item.checked must be boolean if present'
|
||||
});
|
||||
}
|
||||
if (item.indent !== undefined) {
|
||||
const n = item.indent as number;
|
||||
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 8) {
|
||||
errors.push({
|
||||
path: `${path}/indent`,
|
||||
code: 'invalid-number',
|
||||
message: `list-item.indent must be an integer 0..8; got ${JSON.stringify(item.indent)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTableRow(
|
||||
row: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (!isPlainObject(row)) {
|
||||
errors.push({ path, code: 'invalid-block-shape', message: 'table row must be an object' });
|
||||
return;
|
||||
}
|
||||
validateId(row.id, `${path}/id`, errors, ids, idPaths);
|
||||
if (!Array.isArray(row.cells)) {
|
||||
errors.push({
|
||||
path: `${path}/cells`,
|
||||
code: 'invalid-children',
|
||||
message: 'table-row.cells must be an array'
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < row.cells.length; i++) {
|
||||
validateTableCell(row.cells[i], `${path}/cells/${i}`, errors, ids, idPaths);
|
||||
}
|
||||
// Row-level visual (background only) — validate against the per-row
|
||||
// whitelist. Reuse the shared validator with `table` block type as a
|
||||
// stand-in since the row visual is a strict subset.
|
||||
if (row.visual !== undefined) {
|
||||
if (!isPlainObject(row.visual)) {
|
||||
errors.push({
|
||||
path: `${path}/visual`,
|
||||
code: 'invalid-visual-value',
|
||||
message: 'row.visual must be an object'
|
||||
});
|
||||
} else {
|
||||
for (const key of Object.keys(row.visual)) {
|
||||
if (key !== 'background') {
|
||||
errors.push({
|
||||
path: `${path}/visual/${key}`,
|
||||
code: 'invalid-visual-key',
|
||||
message: 'table-row.visual only accepts "background"'
|
||||
});
|
||||
}
|
||||
}
|
||||
const bg = (row.visual as Record<string, unknown>).background;
|
||||
if (bg !== undefined && (typeof bg !== 'string' || !HEX_COLOR_RE.test(bg))) {
|
||||
errors.push({
|
||||
path: `${path}/visual/background`,
|
||||
code: 'invalid-visual-value',
|
||||
message: `row.visual.background must be a hex color; got ${JSON.stringify(bg)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTableCell(
|
||||
cell: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (!isPlainObject(cell)) {
|
||||
errors.push({ path, code: 'invalid-block-shape', message: 'cell must be an object' });
|
||||
return;
|
||||
}
|
||||
validateId(cell.id, `${path}/id`, errors, ids, idPaths);
|
||||
validateInlineChildren(cell.children, `${path}/children`, errors, ids, idPaths);
|
||||
validateOptionalEnum(cell.align, WORDS_TEXT_ALIGNS_V2, `${path}/align`, errors);
|
||||
validateOptionalEnum(
|
||||
cell.verticalAlign,
|
||||
WORDS_TABLE_CELL_VERTICAL_ALIGNS_V2,
|
||||
`${path}/verticalAlign`,
|
||||
errors
|
||||
);
|
||||
if (cell.colspan !== undefined) {
|
||||
const n = cell.colspan as number;
|
||||
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
|
||||
errors.push({
|
||||
path: `${path}/colspan`,
|
||||
code: 'invalid-number',
|
||||
message: 'cell.colspan must be an integer >= 1'
|
||||
});
|
||||
}
|
||||
}
|
||||
if (cell.rowspan !== undefined) {
|
||||
const n = cell.rowspan as number;
|
||||
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
|
||||
errors.push({
|
||||
path: `${path}/rowspan`,
|
||||
code: 'invalid-number',
|
||||
message: 'cell.rowspan must be an integer >= 1'
|
||||
});
|
||||
}
|
||||
}
|
||||
// Cell-level visual is a small whitelist; validate inline.
|
||||
if (cell.visual !== undefined) {
|
||||
if (!isPlainObject(cell.visual)) {
|
||||
errors.push({
|
||||
path: `${path}/visual`,
|
||||
code: 'invalid-visual-value',
|
||||
message: 'cell.visual must be an object'
|
||||
});
|
||||
} else {
|
||||
const allowed = ['background', 'padding', 'borderColor', 'borderWidth', 'borderStyle'];
|
||||
for (const key of Object.keys(cell.visual)) {
|
||||
if (!allowed.includes(key)) {
|
||||
errors.push({
|
||||
path: `${path}/visual/${key}`,
|
||||
code: 'invalid-visual-key',
|
||||
message: `table-cell.visual does not accept "${key}"; allowed: ${allowed.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
const v = cell.visual as Record<string, unknown>;
|
||||
if (
|
||||
v.background !== undefined &&
|
||||
(typeof v.background !== 'string' || !HEX_COLOR_RE.test(v.background))
|
||||
) {
|
||||
errors.push({
|
||||
path: `${path}/visual/background`,
|
||||
code: 'invalid-visual-value',
|
||||
message: 'cell.visual.background must be a hex color'
|
||||
});
|
||||
}
|
||||
if (
|
||||
v.borderColor !== undefined &&
|
||||
(typeof v.borderColor !== 'string' || !HEX_COLOR_RE.test(v.borderColor))
|
||||
) {
|
||||
errors.push({
|
||||
path: `${path}/visual/borderColor`,
|
||||
code: 'invalid-visual-value',
|
||||
message: 'cell.visual.borderColor must be a hex color'
|
||||
});
|
||||
}
|
||||
if (
|
||||
v.borderWidth !== undefined &&
|
||||
(typeof v.borderWidth !== 'number' || !Number.isFinite(v.borderWidth))
|
||||
) {
|
||||
errors.push({
|
||||
path: `${path}/visual/borderWidth`,
|
||||
code: 'invalid-number',
|
||||
message: 'cell.visual.borderWidth must be a finite number'
|
||||
});
|
||||
}
|
||||
if (
|
||||
v.padding !== undefined &&
|
||||
(typeof v.padding !== 'number' || !Number.isFinite(v.padding))
|
||||
) {
|
||||
errors.push({
|
||||
path: `${path}/visual/padding`,
|
||||
code: 'invalid-number',
|
||||
message: 'cell.visual.padding must be a finite number'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function validateId(
|
||||
id: unknown,
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
ids: Set<string>,
|
||||
idPaths: Map<string, string>
|
||||
): void {
|
||||
if (id === undefined) return;
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'invalid-block-shape',
|
||||
message: 'id must be a non-empty string if present'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ids.has(id)) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'duplicate-id',
|
||||
message: `id "${id}" already used at ${idPaths.get(id)}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
ids.add(id);
|
||||
idPaths.set(id, path);
|
||||
}
|
||||
|
||||
function validateOptionalEnum(
|
||||
value: unknown,
|
||||
allowed: readonly string[],
|
||||
path: string,
|
||||
errors: ValidationError[],
|
||||
required: boolean = false
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
if (required) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'invalid-enum-value',
|
||||
message: `${path} is required; allowed: ${allowed.join(' | ')}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'invalid-enum-value',
|
||||
message: `${path} must be one of ${allowed.join(' | ')}; got ${JSON.stringify(value)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalNumber(value: unknown, path: string, errors: ValidationError[]): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'invalid-number',
|
||||
message: `${path} must be a non-negative finite number; got ${JSON.stringify(value)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isParseableUrl(href: string): boolean {
|
||||
if (href.length === 0) return false;
|
||||
// Accept anchor + relative paths + absolute URLs. Reject only
|
||||
// blatantly broken strings (spaces / control chars / etc.).
|
||||
if (/[\s\x00-\x1f]/.test(href)) return false;
|
||||
// Try as absolute first. URL throws on invalid → falsy in catch.
|
||||
try {
|
||||
new URL(href);
|
||||
return true;
|
||||
} catch {
|
||||
// Relative URLs (./, ../, /, #, ?, plain segments) are OK.
|
||||
return /^[#./?]/.test(href) || /^[a-z0-9_-]/i.test(href);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function ok(document: WordsDocumentV2): ValidationResult {
|
||||
return { valid: true, document };
|
||||
}
|
||||
|
||||
function fail(errors: readonly ValidationError[]): ValidationResult {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// ── Re-exports (convenience) ──────────────────────────────────────────────
|
||||
|
||||
export type { WordsDocumentV2, WordsBlockV2, WordsInlineV2, WordsMarkV2 };
|
||||
Loading…
Reference in new issue