palabras: fixes de auditoría — code/table anidado + notación marks/blocks + bubble

Auditoría multi-agente (hallazgos verificados adversarialmente). Arreglos:

- CRÍTICO: insertLineBreak detectaba code/table solo en el bloque TOP-LEVEL, así
  que un code block ANIDADO (dentro de una columna o callout) recibía un nodo
  break (ilegal en code text-only — validate lo rechaza) en vez de un \n literal.
  Ahora camina el container path: el code block es la hoja, la table un ancestro
  de la celda.
- El parser de NOTACIÓN (parsePalabrasScheme) no conocía marks/blocks, así que un
  esquema en forma de notación no podía gobernar (marks=/blocks= caían en el
  scale-shortcut como NaN y se descartaban). Añadido el parseo (coma-lista; vacío
  = ninguno), restaurando la paridad notación<->objeto (ESQUEMA §9).
- Bubble: el Select de familia se renderizaba VACÍO cuando el esquema declara
  fonts:[] (dropdown muerto). showFamily ahora exige allFonts.length > 0.
- Doc: marks NO gobierna color/background (el color va por editable + la paleta)
  — corregido en types.ts + ESQUEMA §6b, que lo documentaban por error.

Tests nuevos: parseo de notación marks/blocks + lock-down (marks:[]/blocks:[] =
nada permitido, que sobrevive al merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 67d7dc89cc
commit 7b2d1e3cd0

@ -132,9 +132,10 @@ blocks = paragraph, heading, list, quote; ← el slash / «+» solo ofrecen es
```
- **`marks`** — marcas inline que el usuario puede aplicar (bubble + atajos): booleanas
(`bold`/`italic`/`underline`/`strike`/`code`/`sub`/`sup`) y paramétricas (`fontSize`/
`fontFamily`/`color`/`background`). Sin declarar → todas; declarada VACÍA → ninguna. Los
VALORES de las paramétricas siguen saliendo de `fonts`/`sizes`/`palette`.
(`bold`/`italic`/`underline`/`strike`/`code`/`sub`/`sup`) y el tamaño/familia paramétricos
(`fontSize`/`fontFamily`). Sin declarar → todas; declarada VACÍA → ninguna. Los VALORES de
familia/tamaño salen de `fonts`/`sizes`. (El COLOR se gobierna aparte, vía `editable` + la
paleta — no por esta lista.)
- **`blocks`** — tipos de bloque insertables (slash + menús «+»/Insertar), en vocabulario
canónico (`heading` cubre h1–h6, `list` las tres listas). Sin declarar → todos los
registrados; declarada VACÍA → ninguno. El slash se filtra en el MOTOR (opt

@ -66,7 +66,7 @@
const showSub = $derived(schemeAllowsMark(scheme, 'sub'));
const showSup = $derived(schemeAllowsMark(scheme, 'sup'));
const showSize = $derived(schemeAllowsMark(scheme, 'fontSize'));
const showFamily = $derived(schemeAllowsMark(scheme, 'fontFamily'));
const showFamily = $derived(schemeAllowsMark(scheme, 'fontFamily') && allFonts.length > 0);
const showTypo = $derived(showSub || showSup || showSize || showFamily);
const marks = $derived(api.activeMarks);
const hasBool = (m: 'sub' | 'sup') => marks.includes(m);

@ -286,6 +286,8 @@ export function parsePalabrasScheme(src: string): PalabrasScheme {
const fields: Record<string, PalabrasSchemeField[]> = {};
let fonts: PalabrasFontGroup[] | undefined;
let palette: PalabrasPaletteEntry[] | undefined;
let marks: string[] | undefined;
let blocks: string[] | undefined;
const styleOf = (route: string): Record<string, unknown> => (styles[route] ??= {});
@ -328,6 +330,17 @@ export function parsePalabrasScheme(src: string): PalabrasScheme {
continue;
}
// ── governance allowlists: `marks=…` / `blocks=…` (comma lists; empty = none) ──
if ((head === 'marks' || head === 'blocks') && decl.value !== undefined && decl.path.length === 1) {
const list = decl.value
.split(',')
.map((k) => k.trim())
.filter(Boolean);
if (head === 'marks') marks = list;
else blocks = list;
continue;
}
// ── scale shortcut: `el=N` ──
if (decl.value !== undefined && decl.path.length === 1) {
const n = Number(decl.value);
@ -389,6 +402,8 @@ export function parsePalabrasScheme(src: string): PalabrasScheme {
? { sizes: sizes as Partial<Record<PalabrasSchemeElement, number>> }
: {}),
...(palette ? { palette } : {}),
...(marks ? { marks } : {}),
...(blocks ? { blocks } : {}),
...(Object.keys(editable).length ? { editable } : {}),
...(Object.keys(styles).length ? { styles: styles as PalabrasScheme['styles'] } : {}),
...(Object.keys(fields).length ? { fields } : {})

@ -34,6 +34,20 @@ describe('scheme governance — marks / blocks allowlists', () => {
expect(schemeAllowsBlock(s, 'heading')).toBe(true);
expect(schemeAllowsBlock(s, 'table')).toBe(false);
});
it('parses marks/blocks from compact notation (governs via notation form)', () => {
const s = resolvePalabrasScheme('marks=bold, italic; blocks=paragraph, heading');
expect(schemeAllowsMark(s, 'bold')).toBe(true);
expect(schemeAllowsMark(s, 'code')).toBe(false);
expect(schemeAllowsBlock(s, 'heading')).toBe(true);
expect(schemeAllowsBlock(s, 'table')).toBe(false);
});
it('empty marks/blocks → nothing allowed (lock-down survives the merge)', () => {
const s = resolvePalabrasScheme({ marks: [], blocks: [] });
expect(schemeAllowsMark(s, 'bold')).toBe(false);
expect(schemeAllowsMark(s, 'code')).toBe(false);
expect(schemeAllowsBlock(s, 'paragraph')).toBe(false);
expect(schemeAllowsBlock(s, 'heading')).toBe(false);
});
});
describe('parsePalabrasScheme (compact notation)', () => {

@ -226,9 +226,10 @@ export interface PalabrasScheme {
readonly editable?: Readonly<Record<string, readonly string[]>>;
/**
* WHITELIST of inline MARKS the user may apply (bubble / shortcuts):
* boolean `bold` `italic` `underline` `strike` `code` `sub` `sup` and
* parametric `fontSize` `fontFamily` `color` `background`. Undeclared →
* all marks allowed; declared EMPTY → none. Governs the bubble.
* boolean `bold` `italic` `underline` `strike` `code` `sub` `sup` and the
* parametric size/family (`fontSize` `fontFamily`). Undeclared → all allowed;
* declared EMPTY → none. Governs the bubble. (Colour is governed separately,
* via `editable` + the palette — not by this list.)
*/
readonly marks?: readonly string[];
/**

@ -31,7 +31,7 @@
import type { PalabrasPath } from '../path';
import { createCollapsedSelection, normalizeRange, type PalabrasSelection } from '../selection';
import { editingContainerPath, pointFromInlineTextOffset, replaceAt } from './helpers';
import { editingContainerPath, getNodeAtPath, pointFromInlineTextOffset, replaceAt } from './helpers';
import { ensureInlineChildren, splitContainerAtPoint } from './inline-split';
import { deleteRange } from './delete';
import { normalizeDocument } from './normalize';
@ -70,9 +70,15 @@ export function insertLineBreak(state: PalabrasEditorState): PalabrasOperationRe
if (!selection) return noOp(state);
const point = normalizeRange(selection).start;
const containerPath = editingContainerPath(state.document, point.path);
const block = state.document.children[containerPath?.[0] ?? -1];
if (block?.type === 'code' || block?.type === 'table') {
return insertNewlineChar(state);
// Code (text-only, pre) and table cells take a LITERAL newline, never a break
// node — at ANY nesting depth (code / table inside a column or callout). Walk the
// container path: a code block is the container leaf, a table an ancestor of the cell.
if (containerPath) {
for (let i = containerPath.length; i >= 1; i--) {
const node = getNodeAtPath(state.document, containerPath.slice(0, i));
const type = node && 'type' in node ? (node as { type?: string }).type : undefined;
if (type === 'code' || type === 'table') return insertNewlineChar(state);
}
}
return insertInlinesAtRange(state, [createBreak()]);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.