@elytracms/studio 0.0.83 → 0.0.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/components/builder/blocks-model.js +5 -2
  2. package/dist/components/builder/blocks-model.js.map +1 -1
  3. package/dist/components/builder/canvas-inline-editing.js +1 -1
  4. package/dist/components/builder/canvas-inline-editing.js.map +1 -1
  5. package/dist/components/builder/composition-field-editor.d.ts +48 -0
  6. package/dist/components/builder/composition-field-editor.js +94 -22
  7. package/dist/components/builder/composition-field-editor.js.map +1 -1
  8. package/dist/components/builder/inline-edit.js +3 -2
  9. package/dist/components/builder/inline-edit.js.map +1 -1
  10. package/dist/components/builder/node-factory.d.ts +4 -0
  11. package/dist/components/builder/node-factory.js +14 -9
  12. package/dist/components/builder/node-factory.js.map +1 -1
  13. package/dist/components/cms/cms-composition-validators.d.ts +7 -16
  14. package/dist/components/cms/cms-composition-validators.js +101 -52
  15. package/dist/components/cms/cms-composition-validators.js.map +1 -1
  16. package/dist/components/cms/gate-validation.js +3 -1
  17. package/dist/components/cms/gate-validation.js.map +1 -1
  18. package/dist/components/cms/rich-text/component-embed.d.ts +13 -7
  19. package/dist/components/cms/rich-text/component-embed.js +21 -13
  20. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  21. package/dist/components/cms/rich-text/editor.d.ts +1 -1
  22. package/dist/components/cms/rich-text/editor.js +2 -2
  23. package/dist/components/cms/rich-text/editor.js.map +1 -1
  24. package/dist/components/cms/rich-text/extensions.d.ts +4 -0
  25. package/dist/components/cms/rich-text/extensions.js +6 -0
  26. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  27. package/dist/components/cms/rich-text/insert.js +1 -1
  28. package/dist/components/cms/rich-text/insert.js.map +1 -1
  29. package/dist/components/cms/rich-text/unknown-node.d.ts +66 -0
  30. package/dist/components/cms/rich-text/unknown-node.js +101 -0
  31. package/dist/components/cms/rich-text/unknown-node.js.map +1 -0
  32. package/dist/components/cms/rich-text/value.d.ts +82 -13
  33. package/dist/components/cms/rich-text/value.js +169 -38
  34. package/dist/components/cms/rich-text/value.js.map +1 -1
  35. package/dist/components/cms/rich-text-preview.js +1 -1
  36. package/dist/components/cms/rich-text-preview.js.map +1 -1
  37. package/dist/lib/ai/rich-text-translate.js +3 -3
  38. package/dist/lib/ai/rich-text-translate.js.map +1 -1
  39. package/dist/lib/i18n/messages.de.js +7 -3
  40. package/dist/lib/i18n/messages.de.js.map +1 -1
  41. package/dist/lib/i18n/messages.en.d.ts +4 -0
  42. package/dist/lib/i18n/messages.en.js +7 -3
  43. package/dist/lib/i18n/messages.en.js.map +1 -1
  44. package/package.json +2 -2
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react';
3
+ import { FileQuestion } from 'lucide-react';
4
+ import { translate } from '@elytracms/studio/lib/i18n';
5
+ import { cn } from '@elytracms/studio/lib/utils';
6
+ /**
7
+ * The generic unknown-node carrier (EC-547, per EC-500 D3).
8
+ *
9
+ * A ProseMirror schema is CLOSED: any node type it does not declare is silently
10
+ * dropped on load, and the first keystroke persists the loss. That makes the
11
+ * flow editor a data shredder for any stored node it does not natively edit —
12
+ * exactly what the converged block sequence must survive (the anti-Gutenberg
13
+ * round-trip law: a projection may only rewrite nodes it natively edits; every
14
+ * other node survives byte-for-byte).
15
+ *
16
+ * So the storage⇄editor projection ({@link ../value}) wraps every node whose
17
+ * type is outside the editor's native vocabulary into this block atom on the
18
+ * way IN — the ORIGINAL node rides verbatim in the single JSON `node` attr
19
+ * (the `componentEmbed` pattern; in-repo precedent #2 is the dom-editor's
20
+ * `PRESERVE_NODE_ATTR` verbatim-JSON carry) — and unwraps it byte-for-byte on
21
+ * the way OUT. The NodeView renders a visibly-labelled read-only block naming
22
+ * the carried type, matching the server renderer's unknown fallback
23
+ * (`rich-text/render.tsx`: "Unknown content block: <type>") so both surfaces
24
+ * speak the same vocabulary. Never a silent drop, never a crash.
25
+ *
26
+ * The carrier exists ONLY in the editor document — it is never written to
27
+ * storage (the projection-out replaces it with its carried payload), so a
28
+ * stored node that happened to reuse this type name would itself be wrapped on
29
+ * the way in and restored untouched on the way out: misinterpretation is
30
+ * structurally impossible, not policed.
31
+ *
32
+ * **Two declared, deliberate limits (EC-550, measured and DECLINED for now):**
33
+ *
34
+ * 1. *Unknown MARKS still shred.* A foreign mark type on a text run inside a
35
+ * natively-edited node is dropped by ProseMirror's closed mark schema on
36
+ * load, and the first keystroke persists the loss (foreign marks inside a
37
+ * CARRIED node survive in its payload). Declined because the failure is
38
+ * bounded — the text itself survives, only the annotation drops — and the
39
+ * mark vocabulary is fixed and global today, so no writer can produce a
40
+ * foreign mark short of hand-written JSON. A mark-preserving projection
41
+ * (marks copied per-run to a carrier attr) is the design if a real producer
42
+ * ever lands (registry-extensible marks, external/AI writers).
43
+ * 2. *Inline-position unknowns relocate.* This carrier is a BLOCK atom; a
44
+ * foreign node inside a paragraph's inline content is wrapped as a block and
45
+ * ProseMirror's fitting moves it out of the inline position — it survives
46
+ * byte-for-byte but changes position on save. Declined for the same reason:
47
+ * there is no inline foreign-node producer today (the engine's own inline
48
+ * vocabulary is closed), and the fix needs a second inline carrier plus a
49
+ * position-aware projection. Revisit alongside (1).
50
+ */
51
+ /** The editor-only node type of the opaque carrier. Never a storage type. */
52
+ export const UNKNOWN_NODE_CARRIER_TYPE = 'unknownCarrier';
53
+ export const UnknownNodeCarrier = Node.create({
54
+ name: UNKNOWN_NODE_CARRIER_TYPE,
55
+ group: 'block',
56
+ atom: true,
57
+ selectable: true,
58
+ draggable: true,
59
+ addAttributes() {
60
+ // `node` holds the carried original node JSON verbatim; storage/JSON only,
61
+ // never rendered into markup (same convention as ComponentEmbed's attr).
62
+ return { node: { default: null, rendered: false } };
63
+ },
64
+ parseHTML() {
65
+ return [{ tag: 'div[data-ec-carrier]' }];
66
+ },
67
+ renderHTML({ HTMLAttributes }) {
68
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-ec-carrier': '' })];
69
+ },
70
+ addNodeView() {
71
+ return ReactNodeViewRenderer(UnknownNodeView);
72
+ },
73
+ });
74
+ /**
75
+ * The carried node's `type` for the visible label; `'unknown'` when the payload
76
+ * is not even node-shaped (the carrier is total — it can hold any JSON).
77
+ */
78
+ export function carriedNodeType(carried) {
79
+ if (typeof carried === 'object' && carried !== null && !Array.isArray(carried)) {
80
+ const type = carried.type;
81
+ if (typeof type === 'string' && type.length > 0)
82
+ return type;
83
+ }
84
+ return 'unknown';
85
+ }
86
+ /**
87
+ * The presentational carrier block — a labelled, read-only card naming the
88
+ * carried node type. Kept free of TipTap context so it renders + asserts
89
+ * standalone (the house `renderToStaticMarkup` smoke pattern). The label rides
90
+ * the studio message catalog (`richText.carrier.unknown`, EC-550) and matches
91
+ * the server renderer's unknown-node fallback vocabulary (`data-unknown-node` /
92
+ * "Unknown content block: <type>" — deliberately unlocalized there: delivery
93
+ * has no viewer-locale catalog).
94
+ */
95
+ export function UnknownNodeCard({ nodeType }) {
96
+ return (_jsxs("div", { "data-ec-carrier": nodeType, role: "note", contentEditable: false, className: "flex items-center gap-2.5 rounded-md border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground", children: [_jsx(FileQuestion, { className: "size-4 shrink-0" }), _jsxs("span", { className: "min-w-0 flex-1 truncate", children: [translate('richText.carrier.unknown'), " ", _jsx("span", { className: "font-mono", children: nodeType })] })] }));
97
+ }
98
+ function UnknownNodeView({ node, selected }) {
99
+ return (_jsx(NodeViewWrapper, { "data-drag-handle": true, className: cn('my-2', selected && 'rounded-md ring-2 ring-ring/50'), children: _jsx(UnknownNodeCard, { nodeType: carriedNodeType(node.attrs.node) }) }));
100
+ }
101
+ //# sourceMappingURL=unknown-node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unknown-node.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/unknown-node.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE7F,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,yBAAyB,GAAG,gBAAgB,CAAA;AAEzD,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC,MAAM,CAAC;IAC5C,IAAI,EAAE,yBAAyB;IAC/B,KAAK,EAAE,OAAO;IACd,IAAI,EAAE,IAAI;IACV,UAAU,EAAE,IAAI;IAChB,SAAS,EAAE,IAAI;IAEf,aAAa;QACX,2EAA2E;QAC3E,yEAAyE;QACzE,OAAO,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAA;IACrD,CAAC;IAED,SAAS;QACP,OAAO,CAAC,EAAE,GAAG,EAAE,sBAAsB,EAAE,CAAC,CAAA;IAC1C,CAAC;IAED,UAAU,CAAC,EAAE,cAAc,EAAE;QAC3B,OAAO,CAAC,KAAK,EAAE,eAAe,CAAC,cAAc,EAAE,EAAE,iBAAiB,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;IAC5E,CAAC;IAED,WAAW;QACT,OAAO,qBAAqB,CAAC,eAAe,CAAC,CAAA;IAC/C,CAAC;CACF,CAAC,CAAA;AAEF;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,OAAgB;IAC9C,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC/E,MAAM,IAAI,GAAI,OAA8B,CAAC,IAAI,CAAA;QACjD,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,IAAI,CAAA;IAC9D,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,EAAE,QAAQ,EAAwB;IAChE,OAAO,CACL,kCACmB,QAAQ,EACzB,IAAI,EAAC,MAAM,EACX,eAAe,EAAE,KAAK,EACtB,SAAS,EAAC,+HAA+H,aAEzI,KAAC,YAAY,IAAC,SAAS,EAAC,iBAAiB,GAAG,EAC5C,gBAAM,SAAS,EAAC,yBAAyB,aACtC,SAAS,CAAC,0BAA0B,CAAC,OAAE,eAAM,SAAS,EAAC,WAAW,YAAE,QAAQ,GAAQ,IAChF,IACH,CACP,CAAA;AACH,CAAC;AAED,SAAS,eAAe,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAsB;IAC7D,OAAO,CACL,KAAC,eAAe,8BAEd,SAAS,EAAE,EAAE,CAAC,MAAM,EAAE,QAAQ,IAAI,gCAAgC,CAAC,YAEnE,KAAC,eAAe,IAAC,QAAQ,EAAE,eAAe,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAI,GAC/C,CACnB,CAAA;AACH,CAAC","sourcesContent":["import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react'\nimport type { ReactNodeViewProps } from '@tiptap/react'\nimport { FileQuestion } from 'lucide-react'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { cn } from '@elytracms/studio/lib/utils'\n\n/**\n * The generic unknown-node carrier (EC-547, per EC-500 D3).\n *\n * A ProseMirror schema is CLOSED: any node type it does not declare is silently\n * dropped on load, and the first keystroke persists the loss. That makes the\n * flow editor a data shredder for any stored node it does not natively edit —\n * exactly what the converged block sequence must survive (the anti-Gutenberg\n * round-trip law: a projection may only rewrite nodes it natively edits; every\n * other node survives byte-for-byte).\n *\n * So the storage⇄editor projection ({@link ../value}) wraps every node whose\n * type is outside the editor's native vocabulary into this block atom on the\n * way IN — the ORIGINAL node rides verbatim in the single JSON `node` attr\n * (the `componentEmbed` pattern; in-repo precedent #2 is the dom-editor's\n * `PRESERVE_NODE_ATTR` verbatim-JSON carry) — and unwraps it byte-for-byte on\n * the way OUT. The NodeView renders a visibly-labelled read-only block naming\n * the carried type, matching the server renderer's unknown fallback\n * (`rich-text/render.tsx`: \"Unknown content block: <type>\") so both surfaces\n * speak the same vocabulary. Never a silent drop, never a crash.\n *\n * The carrier exists ONLY in the editor document — it is never written to\n * storage (the projection-out replaces it with its carried payload), so a\n * stored node that happened to reuse this type name would itself be wrapped on\n * the way in and restored untouched on the way out: misinterpretation is\n * structurally impossible, not policed.\n *\n * **Two declared, deliberate limits (EC-550, measured and DECLINED for now):**\n *\n * 1. *Unknown MARKS still shred.* A foreign mark type on a text run inside a\n * natively-edited node is dropped by ProseMirror's closed mark schema on\n * load, and the first keystroke persists the loss (foreign marks inside a\n * CARRIED node survive in its payload). Declined because the failure is\n * bounded — the text itself survives, only the annotation drops — and the\n * mark vocabulary is fixed and global today, so no writer can produce a\n * foreign mark short of hand-written JSON. A mark-preserving projection\n * (marks copied per-run to a carrier attr) is the design if a real producer\n * ever lands (registry-extensible marks, external/AI writers).\n * 2. *Inline-position unknowns relocate.* This carrier is a BLOCK atom; a\n * foreign node inside a paragraph's inline content is wrapped as a block and\n * ProseMirror's fitting moves it out of the inline position — it survives\n * byte-for-byte but changes position on save. Declined for the same reason:\n * there is no inline foreign-node producer today (the engine's own inline\n * vocabulary is closed), and the fix needs a second inline carrier plus a\n * position-aware projection. Revisit alongside (1).\n */\n\n/** The editor-only node type of the opaque carrier. Never a storage type. */\nexport const UNKNOWN_NODE_CARRIER_TYPE = 'unknownCarrier'\n\nexport const UnknownNodeCarrier = Node.create({\n name: UNKNOWN_NODE_CARRIER_TYPE,\n group: 'block',\n atom: true,\n selectable: true,\n draggable: true,\n\n addAttributes() {\n // `node` holds the carried original node JSON verbatim; storage/JSON only,\n // never rendered into markup (same convention as ComponentEmbed's attr).\n return { node: { default: null, rendered: false } }\n },\n\n parseHTML() {\n return [{ tag: 'div[data-ec-carrier]' }]\n },\n\n renderHTML({ HTMLAttributes }) {\n return ['div', mergeAttributes(HTMLAttributes, { 'data-ec-carrier': '' })]\n },\n\n addNodeView() {\n return ReactNodeViewRenderer(UnknownNodeView)\n },\n})\n\n/**\n * The carried node's `type` for the visible label; `'unknown'` when the payload\n * is not even node-shaped (the carrier is total — it can hold any JSON).\n */\nexport function carriedNodeType(carried: unknown): string {\n if (typeof carried === 'object' && carried !== null && !Array.isArray(carried)) {\n const type = (carried as { type?: unknown }).type\n if (typeof type === 'string' && type.length > 0) return type\n }\n return 'unknown'\n}\n\n/**\n * The presentational carrier block — a labelled, read-only card naming the\n * carried node type. Kept free of TipTap context so it renders + asserts\n * standalone (the house `renderToStaticMarkup` smoke pattern). The label rides\n * the studio message catalog (`richText.carrier.unknown`, EC-550) and matches\n * the server renderer's unknown-node fallback vocabulary (`data-unknown-node` /\n * \"Unknown content block: <type>\" — deliberately unlocalized there: delivery\n * has no viewer-locale catalog).\n */\nexport function UnknownNodeCard({ nodeType }: { nodeType: string }) {\n return (\n <div\n data-ec-carrier={nodeType}\n role=\"note\"\n contentEditable={false}\n className=\"flex items-center gap-2.5 rounded-md border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground\"\n >\n <FileQuestion className=\"size-4 shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate\">\n {translate('richText.carrier.unknown')} <span className=\"font-mono\">{nodeType}</span>\n </span>\n </div>\n )\n}\n\nfunction UnknownNodeView({ node, selected }: ReactNodeViewProps) {\n return (\n <NodeViewWrapper\n data-drag-handle\n className={cn('my-2', selected && 'rounded-md ring-2 ring-ring/50')}\n >\n <UnknownNodeCard nodeType={carriedNodeType(node.attrs.node)} />\n </NodeViewWrapper>\n )\n}\n"]}
@@ -1,33 +1,102 @@
1
- import type { RichTextDoc, RichTextValue } from '@elytracms/core/rich-text';
1
+ import type { BlockSequenceValue, RichTextDoc } from '@elytracms/core/rich-text';
2
2
  /**
3
- * Interpret whatever a `richText` field currently holds as an editor document.
3
+ * Storage ⟷ editor projection for the CMS TipTap flow editor (EC-314, converged
4
+ * by EC-547 per the EC-500 design).
4
5
  *
5
- * Accepts the canonical `{ version, doc }` object, its JSON-string form, a bare
6
- * `doc`, and — for graceful migration off the retired textarea stub — a plain
7
- * string, which becomes a single paragraph. Anything unparseable becomes an empty
8
- * document (never throws; validation is first-class elsewhere).
6
+ * Storage is the converged block sequence `{ version: 2, content: BlockNode[] }`
7
+ * — typography nodes byte-identical to the historical rich-text shape plus
8
+ * first-class `elytra/component` nodes. TipTap requires ITS grammar (a
9
+ * `{ type: 'doc', content }` document over a CLOSED schema), so this module is
10
+ * the just-in-time projection between the two:
11
+ *
12
+ * - **Way in** ({@link toEditorDoc}): typography passes near-identically; an
13
+ * `elytra/component` node becomes the existing ComponentEmbed atom (the
14
+ * original node riding verbatim in `attrs.node`); any node type the editor
15
+ * does not natively edit is wrapped into the opaque
16
+ * {@link UNKNOWN_NODE_CARRIER_TYPE} atom so the closed ProseMirror schema
17
+ * cannot drop it.
18
+ * - **Way out** ({@link blockSequenceValueFromDoc}): embed atoms convert back to
19
+ * tagged `elytra/component` nodes, carriers restore their carried JSON
20
+ * byte-for-byte, typography is re-emitted with {@link normalizeEditorDoc}'s
21
+ * lean-attrs canonicalisation applied.
22
+ *
23
+ * The round-trip law (EC-500 D3): the projection may only rewrite nodes the flow
24
+ * editor natively edits; every other node survives byte-for-byte.
25
+ *
26
+ * Two editor-ergonomics concerns carry over unchanged from EC-314:
27
+ *
28
+ * 1. **Lean canonicalisation.** ProseMirror stamps every declared attribute onto a
29
+ * node/mark, so TipTap emits `null` defaults the lean storage convention omits
30
+ * (`link` → `ref: null`, `codeBlock` → `language: null`, `image` → `title: null`).
31
+ * {@link normalizeEditorDoc} prunes those so an untouched document round-trips to
32
+ * the same shape the extractor/renderer already read.
33
+ *
34
+ * 2. **No false dirty.** Opening a field must not mark it changed, and typing then
35
+ * undoing back to the original must clear dirty. {@link isEquivalentToStored}
36
+ * compares semantically in editor-projection space so the editor can re-emit the
37
+ * *exact stored value* when nothing really changed.
38
+ */
39
+ /**
40
+ * The storage node types the flow editor natively edits — the projection's ONE
41
+ * wrap/pass decision: anything outside this set rides through the editor inside
42
+ * the opaque carrier atom. Everything listed here MUST be declared by
43
+ * `buildRichTextExtensions`' ProseMirror schema (an undeclared type would be
44
+ * silently dropped); `extensions.test.ts` pins the two sets equal, so drift
45
+ * between this declaration and the schema is loud.
46
+ *
47
+ * The ProseMirror schema declares TWO more types than this set: the
48
+ * ComponentEmbed atom and the carrier — both editor-only projection vehicles
49
+ * that are never valid STORAGE vocabulary (EC-548 retired `componentEmbed` at
50
+ * rest). Excluding them here is deliberate and load-bearing: a STORED node
51
+ * reusing either name is a foreigner and rides the carrier byte-for-byte,
52
+ * instead of being misread as an editor atom (which for a stored
53
+ * `componentEmbed` would silently rewrite it to `elytra/component` on save —
54
+ * a compat migration the owner ruled out).
55
+ */
56
+ export declare const EDITOR_NATIVE_NODE_TYPES: ReadonlySet<string>;
57
+ /**
58
+ * Project whatever a body-capable field currently holds into an editor document.
59
+ *
60
+ * Accepts the canonical v2 `{ version: 2, content }` sequence (object or JSON
61
+ * string) and projects it per the module contract. Anything unparseable becomes
62
+ * an empty document (never throws; validation is first-class elsewhere). The
63
+ * projection is deliberately TOTAL and shape-driven, not schema-validating: a
64
+ * v2 value with one malformed node must still open (the bad node rides in a
65
+ * carrier, visibly labelled) rather than blanking the whole field — the editor
66
+ * is not the validator.
9
67
  */
10
68
  export declare function toEditorDoc(value: unknown): RichTextDoc;
11
- /** Wrap an editor document back into a stamped, versioned storage value. */
12
- export declare function richTextValueFromDoc(doc: RichTextDoc): RichTextValue;
69
+ /**
70
+ * Project a (normalized) editor document back into the stored v2 sequence —
71
+ * the way-out half of the module contract: embed atoms → tagged
72
+ * `elytra/component` nodes, carriers → their carried JSON byte-for-byte,
73
+ * typography re-emitted as-is (lean canonicalisation is
74
+ * {@link normalizeEditorDoc}'s job, upstream).
75
+ */
76
+ export declare function blockSequenceValueFromDoc(doc: RichTextDoc): BlockSequenceValue;
13
77
  /**
14
78
  * Prune the `null` attribute values ProseMirror stamps from declared-but-unset
15
79
  * attributes, and drop the resulting empty `attrs` bags, so the editor's output
16
80
  * matches the lean storage convention. Recurses `content`; leaves `marks` attrs
17
- * pruned the same way. Pure; returns a fresh tree.
81
+ * pruned the same way. Pure; returns a fresh tree. (Embed and carrier payloads
82
+ * are single non-null `attrs` entries, so pruning passes them through by
83
+ * reference — their bytes are never rewritten here.)
18
84
  */
19
85
  export declare function normalizeEditorDoc(doc: RichTextDoc): RichTextDoc;
20
86
  /**
21
87
  * Whether a document carries no author-visible content — only empty paragraphs or
22
88
  * headings (TipTap keeps a mandatory trailing block even when the field is blank).
23
- * Any text, atom (image/embed/hardBreak/hr), list, or blockquote makes it non-empty.
89
+ * Any text, atom (image/embed/carrier/hardBreak/hr), list, or blockquote makes it
90
+ * non-empty.
24
91
  */
25
92
  export declare function isEffectivelyEmpty(doc: RichTextDoc): boolean;
26
93
  /**
27
94
  * Whether the (already normalized) editor document is semantically the stored one —
28
95
  * so the editor can re-emit the exact stored value instead of a re-serialization,
29
- * keeping the field un-dirtied. Two empty documents are always equivalent (the
30
- * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not a
31
- * change); otherwise defers to the shared structural comparison.
96
+ * keeping the field un-dirtied. Both sides are compared in editor-projection space
97
+ * ({@link toEditorDoc} is deterministic), so a stored v2 component/carrier and its
98
+ * on-screen atom compare equal. Two empty documents are always equivalent (the
99
+ * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not
100
+ * a change); otherwise defers to the shared structural comparison.
32
101
  */
33
102
  export declare function isEquivalentToStored(current: RichTextDoc, storedValue: unknown): boolean;
@@ -1,63 +1,191 @@
1
- import { createRichTextValue, parseRichTextValue, richTextDocsEquivalent, } from '@elytracms/core/rich-text';
1
+ import { createBlockSequenceValue, isBlockSequenceValueShape, componentBlockNode, richTextDocsEquivalent, } from '@elytracms/core/rich-text';
2
+ import { isComponentBlockNode, isComponentNode } from '@elytracms/core/project-graph';
3
+ import { COMPONENT_EMBED_NODE_TYPE } from './component-embed.js';
4
+ import { UNKNOWN_NODE_CARRIER_TYPE } from './unknown-node.js';
2
5
  /**
3
- * Storage ⟷ editor bridging for the CMS TipTap editor (EC-314).
6
+ * Storage ⟷ editor projection for the CMS TipTap flow editor (EC-314, converged
7
+ * by EC-547 per the EC-500 design).
4
8
  *
5
- * The storage format (`@elytracms/core/rich-text` `RichTextValue`) is a versioned,
6
- * ProseMirror-shaped `{ version, doc }` envelope — the *same* JSON TipTap's
7
- * `editor.getJSON()` produces for its document. So the bridge is thin: unwrap the
8
- * `doc` to feed TipTap, wrap it back on the way out. Two concerns make it more than
9
- * an identity function, both handled here so the editor component stays declarative:
9
+ * Storage is the converged block sequence `{ version: 2, content: BlockNode[] }`
10
+ * — typography nodes byte-identical to the historical rich-text shape plus
11
+ * first-class `elytra/component` nodes. TipTap requires ITS grammar (a
12
+ * `{ type: 'doc', content }` document over a CLOSED schema), so this module is
13
+ * the just-in-time projection between the two:
14
+ *
15
+ * - **Way in** ({@link toEditorDoc}): typography passes near-identically; an
16
+ * `elytra/component` node becomes the existing ComponentEmbed atom (the
17
+ * original node riding verbatim in `attrs.node`); any node type the editor
18
+ * does not natively edit is wrapped into the opaque
19
+ * {@link UNKNOWN_NODE_CARRIER_TYPE} atom so the closed ProseMirror schema
20
+ * cannot drop it.
21
+ * - **Way out** ({@link blockSequenceValueFromDoc}): embed atoms convert back to
22
+ * tagged `elytra/component` nodes, carriers restore their carried JSON
23
+ * byte-for-byte, typography is re-emitted with {@link normalizeEditorDoc}'s
24
+ * lean-attrs canonicalisation applied.
25
+ *
26
+ * The round-trip law (EC-500 D3): the projection may only rewrite nodes the flow
27
+ * editor natively edits; every other node survives byte-for-byte.
28
+ *
29
+ * Two editor-ergonomics concerns carry over unchanged from EC-314:
10
30
  *
11
31
  * 1. **Lean canonicalisation.** ProseMirror stamps every declared attribute onto a
12
32
  * node/mark, so TipTap emits `null` defaults the lean storage convention omits
13
33
  * (`link` → `ref: null`, `codeBlock` → `language: null`, `image` → `title: null`).
14
34
  * {@link normalizeEditorDoc} prunes those so an untouched document round-trips to
15
- * the same shape the extractor/renderer already read — a bare external `link`
16
- * stays `{ href }`, never `{ href, ref: null }`.
35
+ * the same shape the extractor/renderer already read.
17
36
  *
18
37
  * 2. **No false dirty.** Opening a field must not mark it changed, and typing then
19
38
  * undoing back to the original must clear dirty. {@link isEquivalentToStored}
20
- * compares semantically (via the shared `richTextDocsEquivalent`, plus an
21
- * empty-document tolerance for TipTap's mandatory trailing paragraph) so the
22
- * editor can re-emit the *exact stored value* when nothing really changed.
39
+ * compares semantically in editor-projection space so the editor can re-emit the
40
+ * *exact stored value* when nothing really changed.
41
+ */
42
+ /**
43
+ * The storage node types the flow editor natively edits — the projection's ONE
44
+ * wrap/pass decision: anything outside this set rides through the editor inside
45
+ * the opaque carrier atom. Everything listed here MUST be declared by
46
+ * `buildRichTextExtensions`' ProseMirror schema (an undeclared type would be
47
+ * silently dropped); `extensions.test.ts` pins the two sets equal, so drift
48
+ * between this declaration and the schema is loud.
49
+ *
50
+ * The ProseMirror schema declares TWO more types than this set: the
51
+ * ComponentEmbed atom and the carrier — both editor-only projection vehicles
52
+ * that are never valid STORAGE vocabulary (EC-548 retired `componentEmbed` at
53
+ * rest). Excluding them here is deliberate and load-bearing: a STORED node
54
+ * reusing either name is a foreigner and rides the carrier byte-for-byte,
55
+ * instead of being misread as an editor atom (which for a stored
56
+ * `componentEmbed` would silently rewrite it to `elytra/component` on save —
57
+ * a compat migration the owner ruled out).
23
58
  */
59
+ export const EDITOR_NATIVE_NODE_TYPES = new Set([
60
+ 'doc',
61
+ 'paragraph',
62
+ 'text',
63
+ 'heading',
64
+ 'bulletList',
65
+ 'orderedList',
66
+ 'listItem',
67
+ 'blockquote',
68
+ 'codeBlock',
69
+ 'hardBreak',
70
+ 'horizontalRule',
71
+ 'image',
72
+ ]);
24
73
  /** ProseMirror node types that carry no reference/atom payload and render as prose. */
25
74
  const EMPTY_TOLERANT_BLOCKS = new Set(['paragraph', 'heading']);
26
75
  /**
27
- * Interpret whatever a `richText` field currently holds as an editor document.
76
+ * Project whatever a body-capable field currently holds into an editor document.
28
77
  *
29
- * Accepts the canonical `{ version, doc }` object, its JSON-string form, a bare
30
- * `doc`, and — for graceful migration off the retired textarea stub — a plain
31
- * string, which becomes a single paragraph. Anything unparseable becomes an empty
32
- * document (never throws; validation is first-class elsewhere).
78
+ * Accepts the canonical v2 `{ version: 2, content }` sequence (object or JSON
79
+ * string) and projects it per the module contract. Anything unparseable becomes
80
+ * an empty document (never throws; validation is first-class elsewhere). The
81
+ * projection is deliberately TOTAL and shape-driven, not schema-validating: a
82
+ * v2 value with one malformed node must still open (the bad node rides in a
83
+ * carrier, visibly labelled) rather than blanking the whole field — the editor
84
+ * is not the validator.
33
85
  */
34
86
  export function toEditorDoc(value) {
35
- const parsed = parseRichTextValue(value);
36
- if (parsed.ok)
37
- return parsed.value.doc;
38
- // A bare doc (no version envelope) — accept it directly.
39
- if (isDocShape(value))
40
- return value;
41
- // Legacy plaintext written by the old textarea stub → one paragraph.
42
- if (typeof value === 'string' && value.trim().length > 0) {
43
- return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }] };
87
+ const sequence = asBlockSequenceShape(value);
88
+ if (sequence) {
89
+ return { type: 'doc', content: sequence.content.map(projectStoredNode) };
44
90
  }
91
+ // Anything that is not the v2 sequence (a pre-convergence envelope, a bare
92
+ // doc, legacy plaintext, junk) opens EMPTY — deliberately not projected:
93
+ // validation names those shapes with the explicit "re-seed" issue (EC-548
94
+ // deleted the staging acceptance), and quietly editing over them here would
95
+ // hide it.
45
96
  return { type: 'doc', content: [] };
46
97
  }
47
- function isDocShape(value) {
48
- return (typeof value === 'object' &&
49
- value !== null &&
50
- value.type === 'doc');
98
+ /** Structural v2 test, tolerating a JSON-string form of the same value. */
99
+ function asBlockSequenceShape(value) {
100
+ if (isBlockSequenceValueShape(value))
101
+ return value;
102
+ if (typeof value === 'string') {
103
+ try {
104
+ const parsed = JSON.parse(value);
105
+ if (isBlockSequenceValueShape(parsed))
106
+ return parsed;
107
+ }
108
+ catch {
109
+ /* not JSON — fall through */
110
+ }
111
+ }
112
+ return undefined;
51
113
  }
52
- /** Wrap an editor document back into a stamped, versioned storage value. */
53
- export function richTextValueFromDoc(doc) {
54
- return createRichTextValue(doc);
114
+ /**
115
+ * One stored node → its editor projection. Total over arbitrary JSON:
116
+ *
117
+ * - a tagged component node → the ComponentEmbed atom, the node itself riding
118
+ * verbatim (tag included) in `attrs.node`;
119
+ * - a node type the editor natively edits → passed through, `content` recursed
120
+ * (so a component nested in a blockquote still projects to an embed);
121
+ * - EVERYTHING else — foreign types, a tagged-but-malformed component node,
122
+ * even non-node JSON in the content array — → the opaque carrier atom, the
123
+ * original payload riding verbatim in `attrs.node`.
124
+ */
125
+ function projectStoredNode(node) {
126
+ if (isComponentBlockNode(node)) {
127
+ return { type: COMPONENT_EMBED_NODE_TYPE, attrs: { node: node } };
128
+ }
129
+ if (isEditorNativeNode(node)) {
130
+ const out = { ...node };
131
+ if (node.content)
132
+ out.content = node.content.map(projectStoredNode);
133
+ return out;
134
+ }
135
+ return { type: UNKNOWN_NODE_CARRIER_TYPE, attrs: { node: node } };
136
+ }
137
+ function isEditorNativeNode(node) {
138
+ return (typeof node === 'object' &&
139
+ node !== null &&
140
+ !Array.isArray(node) &&
141
+ typeof node.type === 'string' &&
142
+ EDITOR_NATIVE_NODE_TYPES.has(node.type));
143
+ }
144
+ /**
145
+ * Project a (normalized) editor document back into the stored v2 sequence —
146
+ * the way-out half of the module contract: embed atoms → tagged
147
+ * `elytra/component` nodes, carriers → their carried JSON byte-for-byte,
148
+ * typography re-emitted as-is (lean canonicalisation is
149
+ * {@link normalizeEditorDoc}'s job, upstream).
150
+ */
151
+ export function blockSequenceValueFromDoc(doc) {
152
+ return createBlockSequenceValue((doc.content ?? []).map(restoreStoredNode));
153
+ }
154
+ function restoreStoredNode(node) {
155
+ if (node.type === COMPONENT_EMBED_NODE_TYPE) {
156
+ const embedded = node.attrs?.node;
157
+ if (isComponentNode(embedded)) {
158
+ // Re-tag through the one constructor (identity on already-tagged nodes),
159
+ // so an atom payload the node factory built untagged leaves the editor
160
+ // as a first-class tagged citizen — "write current".
161
+ const { type: _tag, ...rest } = embedded;
162
+ return componentBlockNode(rest);
163
+ }
164
+ // An embed whose payload is missing/malformed: keep the node verbatim as
165
+ // typography — never a silent drop; validation reports it elsewhere.
166
+ return node;
167
+ }
168
+ if (node.type === UNKNOWN_NODE_CARRIER_TYPE) {
169
+ const carried = node.attrs?.node;
170
+ // Byte-for-byte restore of the carried original (the D3 round-trip law).
171
+ // An empty carrier (nothing to restore — not constructible via the
172
+ // projection) degrades to the carrier node itself staying visible.
173
+ if (carried !== null && carried !== undefined)
174
+ return carried;
175
+ return node;
176
+ }
177
+ const out = { ...node };
178
+ if (node.content)
179
+ out.content = node.content.map(restoreStoredNode);
180
+ return out;
55
181
  }
56
182
  /**
57
183
  * Prune the `null` attribute values ProseMirror stamps from declared-but-unset
58
184
  * attributes, and drop the resulting empty `attrs` bags, so the editor's output
59
185
  * matches the lean storage convention. Recurses `content`; leaves `marks` attrs
60
- * pruned the same way. Pure; returns a fresh tree.
186
+ * pruned the same way. Pure; returns a fresh tree. (Embed and carrier payloads
187
+ * are single non-null `attrs` entries, so pruning passes them through by
188
+ * reference — their bytes are never rewritten here.)
61
189
  */
62
190
  export function normalizeEditorDoc(doc) {
63
191
  return { type: 'doc', content: (doc.content ?? []).map(pruneNode) };
@@ -88,7 +216,8 @@ function pruneAttrs(attrs) {
88
216
  /**
89
217
  * Whether a document carries no author-visible content — only empty paragraphs or
90
218
  * headings (TipTap keeps a mandatory trailing block even when the field is blank).
91
- * Any text, atom (image/embed/hardBreak/hr), list, or blockquote makes it non-empty.
219
+ * Any text, atom (image/embed/carrier/hardBreak/hr), list, or blockquote makes it
220
+ * non-empty.
92
221
  */
93
222
  export function isEffectivelyEmpty(doc) {
94
223
  return (doc.content ?? []).every(isEmptyBlock);
@@ -101,9 +230,11 @@ function isEmptyBlock(node) {
101
230
  /**
102
231
  * Whether the (already normalized) editor document is semantically the stored one —
103
232
  * so the editor can re-emit the exact stored value instead of a re-serialization,
104
- * keeping the field un-dirtied. Two empty documents are always equivalent (the
105
- * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not a
106
- * change); otherwise defers to the shared structural comparison.
233
+ * keeping the field un-dirtied. Both sides are compared in editor-projection space
234
+ * ({@link toEditorDoc} is deterministic), so a stored v2 component/carrier and its
235
+ * on-screen atom compare equal. Two empty documents are always equivalent (the
236
+ * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not
237
+ * a change); otherwise defers to the shared structural comparison.
107
238
  */
108
239
  export function isEquivalentToStored(current, storedValue) {
109
240
  const stored = toEditorDoc(storedValue);
@@ -1 +1 @@
1
- {"version":3,"file":"value.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/value.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n createRichTextValue,\n parseRichTextValue,\n richTextDocsEquivalent,\n} from '@elytracms/core/rich-text'\nimport type { RichTextDoc, RichTextNode, RichTextValue } from '@elytracms/core/rich-text'\n\n/**\n * Storage ⟷ editor bridging for the CMS TipTap editor (EC-314).\n *\n * The storage format (`@elytracms/core/rich-text` `RichTextValue`) is a versioned,\n * ProseMirror-shaped `{ version, doc }` envelope — the *same* JSON TipTap's\n * `editor.getJSON()` produces for its document. So the bridge is thin: unwrap the\n * `doc` to feed TipTap, wrap it back on the way out. Two concerns make it more than\n * an identity function, both handled here so the editor component stays declarative:\n *\n * 1. **Lean canonicalisation.** ProseMirror stamps every declared attribute onto a\n * node/mark, so TipTap emits `null` defaults the lean storage convention omits\n * (`link` → `ref: null`, `codeBlock` → `language: null`, `image` → `title: null`).\n * {@link normalizeEditorDoc} prunes those so an untouched document round-trips to\n * the same shape the extractor/renderer already read — a bare external `link`\n * stays `{ href }`, never `{ href, ref: null }`.\n *\n * 2. **No false dirty.** Opening a field must not mark it changed, and typing then\n * undoing back to the original must clear dirty. {@link isEquivalentToStored}\n * compares semantically (via the shared `richTextDocsEquivalent`, plus an\n * empty-document tolerance for TipTap's mandatory trailing paragraph) so the\n * editor can re-emit the *exact stored value* when nothing really changed.\n */\n\n/** ProseMirror node types that carry no reference/atom payload and render as prose. */\nconst EMPTY_TOLERANT_BLOCKS = new Set(['paragraph', 'heading'])\n\n/**\n * Interpret whatever a `richText` field currently holds as an editor document.\n *\n * Accepts the canonical `{ version, doc }` object, its JSON-string form, a bare\n * `doc`, and — for graceful migration off the retired textarea stub — a plain\n * string, which becomes a single paragraph. Anything unparseable becomes an empty\n * document (never throws; validation is first-class elsewhere).\n */\nexport function toEditorDoc(value: unknown): RichTextDoc {\n const parsed = parseRichTextValue(value)\n if (parsed.ok) return parsed.value.doc\n // A bare doc (no version envelope) — accept it directly.\n if (isDocShape(value)) return value\n // Legacy plaintext written by the old textarea stub → one paragraph.\n if (typeof value === 'string' && value.trim().length > 0) {\n return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }] }\n }\n return { type: 'doc', content: [] }\n}\n\nfunction isDocShape(value: unknown): value is RichTextDoc {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'doc'\n )\n}\n\n/** Wrap an editor document back into a stamped, versioned storage value. */\nexport function richTextValueFromDoc(doc: RichTextDoc): RichTextValue {\n return createRichTextValue(doc)\n}\n\n/**\n * Prune the `null` attribute values ProseMirror stamps from declared-but-unset\n * attributes, and drop the resulting empty `attrs` bags, so the editor's output\n * matches the lean storage convention. Recurses `content`; leaves `marks` attrs\n * pruned the same way. Pure; returns a fresh tree.\n */\nexport function normalizeEditorDoc(doc: RichTextDoc): RichTextDoc {\n return { type: 'doc', content: (doc.content ?? []).map(pruneNode) }\n}\n\nfunction pruneNode(node: RichTextNode): RichTextNode {\n const out: RichTextNode = { type: node.type }\n const attrs = pruneAttrs(node.attrs)\n if (attrs) out.attrs = attrs\n if (typeof node.text === 'string') out.text = node.text\n if (node.marks && node.marks.length > 0) {\n out.marks = node.marks.map((mark) => {\n const markAttrs = pruneAttrs(mark.attrs)\n return markAttrs ? { type: mark.type, attrs: markAttrs } : { type: mark.type }\n })\n }\n if (node.content) out.content = node.content.map(pruneNode)\n return out\n}\n\nfunction pruneAttrs(\n attrs: Record<string, unknown> | undefined,\n): Record<string, never> | undefined {\n if (!attrs) return undefined\n const entries = Object.entries(attrs).filter(([, v]) => v !== null && v !== undefined)\n return entries.length > 0 ? (Object.fromEntries(entries) as Record<string, never>) : undefined\n}\n\n/**\n * Whether a document carries no author-visible content — only empty paragraphs or\n * headings (TipTap keeps a mandatory trailing block even when the field is blank).\n * Any text, atom (image/embed/hardBreak/hr), list, or blockquote makes it non-empty.\n */\nexport function isEffectivelyEmpty(doc: RichTextDoc): boolean {\n return (doc.content ?? []).every(isEmptyBlock)\n}\n\nfunction isEmptyBlock(node: RichTextNode): boolean {\n if (!EMPTY_TOLERANT_BLOCKS.has(node.type)) return false\n return (node.content ?? []).every(\n (child) => child.type === 'text' && (child.text ?? '').length === 0,\n )\n}\n\n/**\n * Whether the (already normalized) editor document is semantically the stored one —\n * so the editor can re-emit the exact stored value instead of a re-serialization,\n * keeping the field un-dirtied. Two empty documents are always equivalent (the\n * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not a\n * change); otherwise defers to the shared structural comparison.\n */\nexport function isEquivalentToStored(current: RichTextDoc, storedValue: unknown): boolean {\n const stored = toEditorDoc(storedValue)\n if (isEffectivelyEmpty(current) && isEffectivelyEmpty(stored)) return true\n return richTextDocsEquivalent(current, stored)\n}\n"]}
1
+ {"version":3,"file":"value.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/value.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n createBlockSequenceValue,\n isBlockSequenceValueShape,\n componentBlockNode,\n richTextDocsEquivalent,\n} from '@elytracms/core/rich-text'\nimport type {\n BlockNode,\n BlockSequenceValue,\n RichTextDoc,\n RichTextNode,\n TypographyNode,\n} from '@elytracms/core/rich-text'\nimport { isComponentBlockNode, isComponentNode } from '@elytracms/core/project-graph'\nimport type { JsonValue } from '@elytracms/core/project-graph'\nimport { COMPONENT_EMBED_NODE_TYPE } from './component-embed'\nimport { UNKNOWN_NODE_CARRIER_TYPE } from './unknown-node'\n\n/**\n * Storage ⟷ editor projection for the CMS TipTap flow editor (EC-314, converged\n * by EC-547 per the EC-500 design).\n *\n * Storage is the converged block sequence `{ version: 2, content: BlockNode[] }`\n * — typography nodes byte-identical to the historical rich-text shape plus\n * first-class `elytra/component` nodes. TipTap requires ITS grammar (a\n * `{ type: 'doc', content }` document over a CLOSED schema), so this module is\n * the just-in-time projection between the two:\n *\n * - **Way in** ({@link toEditorDoc}): typography passes near-identically; an\n * `elytra/component` node becomes the existing ComponentEmbed atom (the\n * original node riding verbatim in `attrs.node`); any node type the editor\n * does not natively edit is wrapped into the opaque\n * {@link UNKNOWN_NODE_CARRIER_TYPE} atom so the closed ProseMirror schema\n * cannot drop it.\n * - **Way out** ({@link blockSequenceValueFromDoc}): embed atoms convert back to\n * tagged `elytra/component` nodes, carriers restore their carried JSON\n * byte-for-byte, typography is re-emitted with {@link normalizeEditorDoc}'s\n * lean-attrs canonicalisation applied.\n *\n * The round-trip law (EC-500 D3): the projection may only rewrite nodes the flow\n * editor natively edits; every other node survives byte-for-byte.\n *\n * Two editor-ergonomics concerns carry over unchanged from EC-314:\n *\n * 1. **Lean canonicalisation.** ProseMirror stamps every declared attribute onto a\n * node/mark, so TipTap emits `null` defaults the lean storage convention omits\n * (`link` → `ref: null`, `codeBlock` → `language: null`, `image` → `title: null`).\n * {@link normalizeEditorDoc} prunes those so an untouched document round-trips to\n * the same shape the extractor/renderer already read.\n *\n * 2. **No false dirty.** Opening a field must not mark it changed, and typing then\n * undoing back to the original must clear dirty. {@link isEquivalentToStored}\n * compares semantically in editor-projection space so the editor can re-emit the\n * *exact stored value* when nothing really changed.\n */\n\n/**\n * The storage node types the flow editor natively edits — the projection's ONE\n * wrap/pass decision: anything outside this set rides through the editor inside\n * the opaque carrier atom. Everything listed here MUST be declared by\n * `buildRichTextExtensions`' ProseMirror schema (an undeclared type would be\n * silently dropped); `extensions.test.ts` pins the two sets equal, so drift\n * between this declaration and the schema is loud.\n *\n * The ProseMirror schema declares TWO more types than this set: the\n * ComponentEmbed atom and the carrier — both editor-only projection vehicles\n * that are never valid STORAGE vocabulary (EC-548 retired `componentEmbed` at\n * rest). Excluding them here is deliberate and load-bearing: a STORED node\n * reusing either name is a foreigner and rides the carrier byte-for-byte,\n * instead of being misread as an editor atom (which for a stored\n * `componentEmbed` would silently rewrite it to `elytra/component` on save —\n * a compat migration the owner ruled out).\n */\nexport const EDITOR_NATIVE_NODE_TYPES: ReadonlySet<string> = new Set([\n 'doc',\n 'paragraph',\n 'text',\n 'heading',\n 'bulletList',\n 'orderedList',\n 'listItem',\n 'blockquote',\n 'codeBlock',\n 'hardBreak',\n 'horizontalRule',\n 'image',\n])\n\n/** ProseMirror node types that carry no reference/atom payload and render as prose. */\nconst EMPTY_TOLERANT_BLOCKS = new Set(['paragraph', 'heading'])\n\n/**\n * Project whatever a body-capable field currently holds into an editor document.\n *\n * Accepts the canonical v2 `{ version: 2, content }` sequence (object or JSON\n * string) and projects it per the module contract. Anything unparseable becomes\n * an empty document (never throws; validation is first-class elsewhere). The\n * projection is deliberately TOTAL and shape-driven, not schema-validating: a\n * v2 value with one malformed node must still open (the bad node rides in a\n * carrier, visibly labelled) rather than blanking the whole field — the editor\n * is not the validator.\n */\nexport function toEditorDoc(value: unknown): RichTextDoc {\n const sequence = asBlockSequenceShape(value)\n if (sequence) {\n return { type: 'doc', content: sequence.content.map(projectStoredNode) }\n }\n // Anything that is not the v2 sequence (a pre-convergence envelope, a bare\n // doc, legacy plaintext, junk) opens EMPTY — deliberately not projected:\n // validation names those shapes with the explicit \"re-seed\" issue (EC-548\n // deleted the staging acceptance), and quietly editing over them here would\n // hide it.\n return { type: 'doc', content: [] }\n}\n\n/** Structural v2 test, tolerating a JSON-string form of the same value. */\nfunction asBlockSequenceShape(value: unknown): { version: 2; content: unknown[] } | undefined {\n if (isBlockSequenceValueShape(value)) return value\n if (typeof value === 'string') {\n try {\n const parsed: unknown = JSON.parse(value)\n if (isBlockSequenceValueShape(parsed)) return parsed\n } catch {\n /* not JSON — fall through */\n }\n }\n return undefined\n}\n\n/**\n * One stored node → its editor projection. Total over arbitrary JSON:\n *\n * - a tagged component node → the ComponentEmbed atom, the node itself riding\n * verbatim (tag included) in `attrs.node`;\n * - a node type the editor natively edits → passed through, `content` recursed\n * (so a component nested in a blockquote still projects to an embed);\n * - EVERYTHING else — foreign types, a tagged-but-malformed component node,\n * even non-node JSON in the content array — → the opaque carrier atom, the\n * original payload riding verbatim in `attrs.node`.\n */\nfunction projectStoredNode(node: unknown): RichTextNode {\n if (isComponentBlockNode(node)) {\n return { type: COMPONENT_EMBED_NODE_TYPE, attrs: { node: node as unknown as JsonValue } }\n }\n if (isEditorNativeNode(node)) {\n const out: RichTextNode = { ...node }\n if (node.content) out.content = node.content.map(projectStoredNode)\n return out\n }\n return { type: UNKNOWN_NODE_CARRIER_TYPE, attrs: { node: node as JsonValue } }\n}\n\nfunction isEditorNativeNode(node: unknown): node is RichTextNode {\n return (\n typeof node === 'object' &&\n node !== null &&\n !Array.isArray(node) &&\n typeof (node as { type?: unknown }).type === 'string' &&\n EDITOR_NATIVE_NODE_TYPES.has((node as { type: string }).type)\n )\n}\n\n/**\n * Project a (normalized) editor document back into the stored v2 sequence —\n * the way-out half of the module contract: embed atoms → tagged\n * `elytra/component` nodes, carriers → their carried JSON byte-for-byte,\n * typography re-emitted as-is (lean canonicalisation is\n * {@link normalizeEditorDoc}'s job, upstream).\n */\nexport function blockSequenceValueFromDoc(doc: RichTextDoc): BlockSequenceValue {\n return createBlockSequenceValue((doc.content ?? []).map(restoreStoredNode))\n}\n\nfunction restoreStoredNode(node: RichTextNode): BlockNode {\n if (node.type === COMPONENT_EMBED_NODE_TYPE) {\n const embedded: unknown = node.attrs?.node\n if (isComponentNode(embedded)) {\n // Re-tag through the one constructor (identity on already-tagged nodes),\n // so an atom payload the node factory built untagged leaves the editor\n // as a first-class tagged citizen — \"write current\".\n const { type: _tag, ...rest } = embedded\n return componentBlockNode(rest)\n }\n // An embed whose payload is missing/malformed: keep the node verbatim as\n // typography — never a silent drop; validation reports it elsewhere.\n return node as TypographyNode\n }\n if (node.type === UNKNOWN_NODE_CARRIER_TYPE) {\n const carried: unknown = node.attrs?.node\n // Byte-for-byte restore of the carried original (the D3 round-trip law).\n // An empty carrier (nothing to restore — not constructible via the\n // projection) degrades to the carrier node itself staying visible.\n if (carried !== null && carried !== undefined) return carried as BlockNode\n return node as TypographyNode\n }\n const out: TypographyNode = { ...(node as TypographyNode) }\n if (node.content) out.content = node.content.map(restoreStoredNode)\n return out\n}\n\n/**\n * Prune the `null` attribute values ProseMirror stamps from declared-but-unset\n * attributes, and drop the resulting empty `attrs` bags, so the editor's output\n * matches the lean storage convention. Recurses `content`; leaves `marks` attrs\n * pruned the same way. Pure; returns a fresh tree. (Embed and carrier payloads\n * are single non-null `attrs` entries, so pruning passes them through by\n * reference — their bytes are never rewritten here.)\n */\nexport function normalizeEditorDoc(doc: RichTextDoc): RichTextDoc {\n return { type: 'doc', content: (doc.content ?? []).map(pruneNode) }\n}\n\nfunction pruneNode(node: RichTextNode): RichTextNode {\n const out: RichTextNode = { type: node.type }\n const attrs = pruneAttrs(node.attrs)\n if (attrs) out.attrs = attrs\n if (typeof node.text === 'string') out.text = node.text\n if (node.marks && node.marks.length > 0) {\n out.marks = node.marks.map((mark) => {\n const markAttrs = pruneAttrs(mark.attrs)\n return markAttrs ? { type: mark.type, attrs: markAttrs } : { type: mark.type }\n })\n }\n if (node.content) out.content = node.content.map(pruneNode)\n return out\n}\n\nfunction pruneAttrs(\n attrs: Record<string, unknown> | undefined,\n): Record<string, never> | undefined {\n if (!attrs) return undefined\n const entries = Object.entries(attrs).filter(([, v]) => v !== null && v !== undefined)\n return entries.length > 0 ? (Object.fromEntries(entries) as Record<string, never>) : undefined\n}\n\n/**\n * Whether a document carries no author-visible content — only empty paragraphs or\n * headings (TipTap keeps a mandatory trailing block even when the field is blank).\n * Any text, atom (image/embed/carrier/hardBreak/hr), list, or blockquote makes it\n * non-empty.\n */\nexport function isEffectivelyEmpty(doc: RichTextDoc): boolean {\n return (doc.content ?? []).every(isEmptyBlock)\n}\n\nfunction isEmptyBlock(node: RichTextNode): boolean {\n if (!EMPTY_TOLERANT_BLOCKS.has(node.type)) return false\n return (node.content ?? []).every(\n (child) => child.type === 'text' && (child.text ?? '').length === 0,\n )\n}\n\n/**\n * Whether the (already normalized) editor document is semantically the stored one —\n * so the editor can re-emit the exact stored value instead of a re-serialization,\n * keeping the field un-dirtied. Both sides are compared in editor-projection space\n * ({@link toEditorDoc} is deterministic), so a stored v2 component/carrier and its\n * on-screen atom compare equal. Two empty documents are always equivalent (the\n * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not\n * a change); otherwise defers to the shared structural comparison.\n */\nexport function isEquivalentToStored(current: RichTextDoc, storedValue: unknown): boolean {\n const stored = toEditorDoc(storedValue)\n if (isEffectivelyEmpty(current) && isEffectivelyEmpty(stored)) return true\n return richTextDocsEquivalent(current, stored)\n}\n"]}
@@ -76,6 +76,6 @@ export function isRichTextValueEmpty(value) {
76
76
  const hasContent = (nodes) => (nodes ?? []).some((node) => (typeof node.text === 'string' && node.text.trim().length > 0) ||
77
77
  !PROSE_SCAFFOLD_TYPES.has(node.type) ||
78
78
  hasContent(node.content));
79
- return !hasContent(parsed.value.doc.content);
79
+ return !hasContent(parsed.value.content);
80
80
  }
81
81
  //# sourceMappingURL=rich-text-preview.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"rich-text-preview.js","sourceRoot":"","sources":["../../../src/components/cms/rich-text-preview.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAA;AAGnF,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAEhD;;;;;;;;;;;;GAYG;AAEH,2EAA2E;AAC3E,MAAM,wBAAwB,GAAG;IAC/B,iDAAiD;IACjD,uCAAuC;IACvC,qCAAqC;IACrC,+DAA+D;IAC/D,qFAAqF;IACrF,iHAAiH;IACjH,0CAA0C;IAC1C,0GAA0G;IAC1G,oCAAoC;IACpC,sBAAsB;CACvB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAEX,kFAAkF;AAClF,SAAS,SAAS,CAAC,EAAE,IAAI,EAA2B;IAClD,OAAO,CACL,KAAC,KAAK,IAAC,OAAO,EAAC,SAAS,EAAC,SAAS,EAAC,0BAA0B,YAC1D,IAAI,CAAC,WAAW,GACX,CACT,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,EAAE,KAAK,EAAE,SAAS,EAA0C;IAC1F,MAAM,MAAM,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAA;IACxC,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;QACf,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,SAAS,EAAC,iCAAiC,YACxD,KAAK,GACD,CACR,CAAA;QACH,CAAC;QACD,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,yBAAyB,CAAC,GAChC,CACR,CAAA;IACH,CAAC;IACD,OAAO,mBAAmB,CAAC,MAAM,CAAC,KAAK,EAAE;QACvC,SAAS,EAAE,EAAE,CAAC,wBAAwB,EAAE,SAAS,CAAC;QAClD,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAC,SAAS,IAAC,IAAI,EAAE,IAAI,GAAI;KACjD,CAAC,CAAA;AACJ,CAAC;AAED,6FAA6F;AAC7F,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC;IACnC,WAAW;IACX,SAAS;IACT,MAAM;IACN,WAAW;IACX,YAAY;IACZ,aAAa;IACb,UAAU;IACV,YAAY;CACb,CAAC,CAAA;AAEF;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,IAAI,CAAA;IACtE,MAAM,MAAM,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAA;IACxC,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,OAAO,KAAK,CAAA;IAC5B,MAAM,UAAU,GAAG,CAAC,KAA0C,EAAW,EAAE,CACzE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAChB,CAAC,IAAI,EAAE,EAAE,CACP,CAAC,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;QAC9D,CAAC,oBAAoB,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;QACpC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,CAC3B,CAAA;IACH,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;AAC9C,CAAC","sourcesContent":["import { parseRichTextValue, renderRichTextValue } from '@elytracms/core/rich-text'\nimport type { RichTextNode } from '@elytracms/core/rich-text'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { Badge, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { cn } from '@elytracms/studio/lib/utils'\n\n/**\n * Compact read-only rich-text rendering for review surfaces (EC-379).\n *\n * The AI proposal diff (and any other change-review lane) must show the editor\n * the ACTUAL prose they are approving — never a blind \"Content changed.\" note.\n * This renders a stored rich-text value through the engine's server-safe\n * read-only renderer (`@elytracms/core/rich-text`) with modest, compact styles;\n * component embeds render as an inspectable component-id chip (the diff reviews\n * content, not layout), and unknown nodes keep the renderer's visible fallback.\n *\n * Self-contained by design: EC-382 (proposal-diff layout) restyles the diff\n * chrome around it without touching this component's contract.\n */\n\n/** Compact prose styles applied to the renderer's `div.rich-text` root. */\nconst RICH_TEXT_PREVIEW_STYLES = [\n 'text-sm leading-relaxed break-words space-y-1.5',\n '[&_h1]:text-base [&_h1]:font-semibold',\n '[&_h2]:text-sm [&_h2]:font-semibold',\n '[&_:is(h3,h4,h5,h6)]:text-sm [&_:is(h3,h4,h5,h6)]:font-medium',\n '[&_:is(ul,ol)]:pl-5 [&_:is(ul,ol)]:space-y-0.5 [&_ul]:list-disc [&_ol]:list-decimal',\n '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-2 [&_blockquote]:text-muted-foreground',\n '[&_a]:underline [&_a]:underline-offset-2',\n '[&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted [&_pre]:p-2 [&_code]:font-mono [&_code]:text-xs',\n '[&_img]:max-w-full [&_img]:rounded',\n '[&_hr]:border-border',\n].join(' ')\n\n/** The inspectable chip a `componentEmbed` renders as inside a review preview. */\nfunction EmbedChip({ node }: { node: ComponentNode }) {\n return (\n <Badge variant=\"outline\" className=\"font-mono text-[0.65rem]\">\n {node.componentId}\n </Badge>\n )\n}\n\n/**\n * Render a stored rich-text value as compact read-only prose. A legacy plain\n * string stays visible as text; any other unparseable value degrades to the\n * generic object note — visible, never silent, never a crash.\n */\nexport function RichTextPreview({ value, className }: { value: unknown; className?: string }) {\n const parsed = parseRichTextValue(value)\n if (!parsed.ok) {\n if (typeof value === 'string') {\n return (\n <Text size=\"sm\" className=\"whitespace-pre-wrap break-words\">\n {value}\n </Text>\n )\n }\n return (\n <Text size=\"sm\" tone=\"muted\">\n {translate('cms.version.objectValue')}\n </Text>\n )\n }\n return renderRichTextValue(parsed.value, {\n className: cn(RICH_TEXT_PREVIEW_STYLES, className),\n renderEmbed: (node) => <EmbedChip node={node} />,\n })\n}\n\n/** Node types that carry no content of their own — an empty scaffold of these is \"empty\". */\nconst PROSE_SCAFFOLD_TYPES = new Set([\n 'paragraph',\n 'heading',\n 'text',\n 'hardBreak',\n 'bulletList',\n 'orderedList',\n 'listItem',\n 'blockquote',\n])\n\n/**\n * Whether a rich-text value is EMPTY for review purposes: unset, an empty doc,\n * or nothing but blank prose scaffolding. A doc carrying any atom (image, embed,\n * custom node) or non-blank text is content — it must be shown, not skipped.\n */\nexport function isRichTextValueEmpty(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return true\n const parsed = parseRichTextValue(value)\n if (!parsed.ok) return false\n const hasContent = (nodes: readonly RichTextNode[] | undefined): boolean =>\n (nodes ?? []).some(\n (node) =>\n (typeof node.text === 'string' && node.text.trim().length > 0) ||\n !PROSE_SCAFFOLD_TYPES.has(node.type) ||\n hasContent(node.content),\n )\n return !hasContent(parsed.value.doc.content)\n}\n"]}
1
+ {"version":3,"file":"rich-text-preview.js","sourceRoot":"","sources":["../../../src/components/cms/rich-text-preview.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAA;AAGnF,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAEhD;;;;;;;;;;;;GAYG;AAEH,2EAA2E;AAC3E,MAAM,wBAAwB,GAAG;IAC/B,iDAAiD;IACjD,uCAAuC;IACvC,qCAAqC;IACrC,+DAA+D;IAC/D,qFAAqF;IACrF,iHAAiH;IACjH,0CAA0C;IAC1C,0GAA0G;IAC1G,oCAAoC;IACpC,sBAAsB;CACvB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAEX,kFAAkF;AAClF,SAAS,SAAS,CAAC,EAAE,IAAI,EAA2B;IAClD,OAAO,CACL,KAAC,KAAK,IAAC,OAAO,EAAC,SAAS,EAAC,SAAS,EAAC,0BAA0B,YAC1D,IAAI,CAAC,WAAW,GACX,CACT,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,EAAE,KAAK,EAAE,SAAS,EAA0C;IAC1F,MAAM,MAAM,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAA;IACxC,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;QACf,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,SAAS,EAAC,iCAAiC,YACxD,KAAK,GACD,CACR,CAAA;QACH,CAAC;QACD,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,yBAAyB,CAAC,GAChC,CACR,CAAA;IACH,CAAC;IACD,OAAO,mBAAmB,CAAC,MAAM,CAAC,KAAK,EAAE;QACvC,SAAS,EAAE,EAAE,CAAC,wBAAwB,EAAE,SAAS,CAAC;QAClD,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAC,SAAS,IAAC,IAAI,EAAE,IAAI,GAAI;KACjD,CAAC,CAAA;AACJ,CAAC;AAED,6FAA6F;AAC7F,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC;IACnC,WAAW;IACX,SAAS;IACT,MAAM;IACN,WAAW;IACX,YAAY;IACZ,aAAa;IACb,UAAU;IACV,YAAY;CACb,CAAC,CAAA;AAEF;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,IAAI,CAAA;IACtE,MAAM,MAAM,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAA;IACxC,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,OAAO,KAAK,CAAA;IAC5B,MAAM,UAAU,GAAG,CAAC,KAA0C,EAAW,EAAE,CACzE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAChB,CAAC,IAAI,EAAE,EAAE,CACP,CAAC,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;QAC9D,CAAC,oBAAoB,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;QACpC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,CAC3B,CAAA;IACH,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,OAAgB,CAAC,CAAA;AACnD,CAAC","sourcesContent":["import { parseRichTextValue, renderRichTextValue } from '@elytracms/core/rich-text'\nimport type { RichTextNode } from '@elytracms/core/rich-text'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { Badge, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { cn } from '@elytracms/studio/lib/utils'\n\n/**\n * Compact read-only rich-text rendering for review surfaces (EC-379).\n *\n * The AI proposal diff (and any other change-review lane) must show the editor\n * the ACTUAL prose they are approving — never a blind \"Content changed.\" note.\n * This renders a stored rich-text value through the engine's server-safe\n * read-only renderer (`@elytracms/core/rich-text`) with modest, compact styles;\n * component embeds render as an inspectable component-id chip (the diff reviews\n * content, not layout), and unknown nodes keep the renderer's visible fallback.\n *\n * Self-contained by design: EC-382 (proposal-diff layout) restyles the diff\n * chrome around it without touching this component's contract.\n */\n\n/** Compact prose styles applied to the renderer's `div.rich-text` root. */\nconst RICH_TEXT_PREVIEW_STYLES = [\n 'text-sm leading-relaxed break-words space-y-1.5',\n '[&_h1]:text-base [&_h1]:font-semibold',\n '[&_h2]:text-sm [&_h2]:font-semibold',\n '[&_:is(h3,h4,h5,h6)]:text-sm [&_:is(h3,h4,h5,h6)]:font-medium',\n '[&_:is(ul,ol)]:pl-5 [&_:is(ul,ol)]:space-y-0.5 [&_ul]:list-disc [&_ol]:list-decimal',\n '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-2 [&_blockquote]:text-muted-foreground',\n '[&_a]:underline [&_a]:underline-offset-2',\n '[&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted [&_pre]:p-2 [&_code]:font-mono [&_code]:text-xs',\n '[&_img]:max-w-full [&_img]:rounded',\n '[&_hr]:border-border',\n].join(' ')\n\n/** The inspectable chip a `componentEmbed` renders as inside a review preview. */\nfunction EmbedChip({ node }: { node: ComponentNode }) {\n return (\n <Badge variant=\"outline\" className=\"font-mono text-[0.65rem]\">\n {node.componentId}\n </Badge>\n )\n}\n\n/**\n * Render a stored rich-text value as compact read-only prose. A legacy plain\n * string stays visible as text; any other unparseable value degrades to the\n * generic object note — visible, never silent, never a crash.\n */\nexport function RichTextPreview({ value, className }: { value: unknown; className?: string }) {\n const parsed = parseRichTextValue(value)\n if (!parsed.ok) {\n if (typeof value === 'string') {\n return (\n <Text size=\"sm\" className=\"whitespace-pre-wrap break-words\">\n {value}\n </Text>\n )\n }\n return (\n <Text size=\"sm\" tone=\"muted\">\n {translate('cms.version.objectValue')}\n </Text>\n )\n }\n return renderRichTextValue(parsed.value, {\n className: cn(RICH_TEXT_PREVIEW_STYLES, className),\n renderEmbed: (node) => <EmbedChip node={node} />,\n })\n}\n\n/** Node types that carry no content of their own — an empty scaffold of these is \"empty\". */\nconst PROSE_SCAFFOLD_TYPES = new Set([\n 'paragraph',\n 'heading',\n 'text',\n 'hardBreak',\n 'bulletList',\n 'orderedList',\n 'listItem',\n 'blockquote',\n])\n\n/**\n * Whether a rich-text value is EMPTY for review purposes: unset, an empty doc,\n * or nothing but blank prose scaffolding. A doc carrying any atom (image, embed,\n * custom node) or non-blank text is content — it must be shown, not skipped.\n */\nexport function isRichTextValueEmpty(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return true\n const parsed = parseRichTextValue(value)\n if (!parsed.ok) return false\n const hasContent = (nodes: readonly RichTextNode[] | undefined): boolean =>\n (nodes ?? []).some(\n (node) =>\n (typeof node.text === 'string' && node.text.trim().length > 0) ||\n !PROSE_SCAFFOLD_TYPES.has(node.type) ||\n hasContent(node.content),\n )\n return !hasContent(parsed.value.content as never)\n}\n"]}
@@ -95,7 +95,7 @@ export function extractRichTextUnits(raw) {
95
95
  const blocks = new Map();
96
96
  const units = [];
97
97
  if (!parsed.ok) {
98
- return { units, plan: { value: { version: 1, doc: { type: 'doc', content: [] } }, blocks } };
98
+ return { units, plan: { value: { version: 2, content: [] }, blocks } };
99
99
  }
100
100
  const value = parsed.value;
101
101
  const walk = (node, path) => {
@@ -112,7 +112,7 @@ export function extractRichTextUnits(raw) {
112
112
  }
113
113
  node.content?.forEach((child, i) => walk(child, [...path, i]));
114
114
  };
115
- value.doc.content.forEach((node, i) => walk(node, [i]));
115
+ value.content.forEach((node, i) => walk(node, [i]));
116
116
  return { units, plan: { value, blocks } };
117
117
  }
118
118
  // --- Reconstruction ---------------------------------------------------------------
@@ -194,7 +194,7 @@ function replaceAtPath(nodes, path, content) {
194
194
  * ORIGINAL content — never a broken structure. Returns a fresh `{ version, doc }` value.
195
195
  */
196
196
  export function rebuildRichText(plan, translations) {
197
- let content = plan.value.doc.content.map((n) => n);
197
+ let content = plan.value.content.map((n) => n);
198
198
  for (const [id, tables] of plan.blocks) {
199
199
  const translated = translations.get(id);
200
200
  if (translated === undefined)