@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.
- package/dist/components/builder/blocks-model.js +5 -2
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/canvas-inline-editing.js +1 -1
- package/dist/components/builder/canvas-inline-editing.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +48 -0
- package/dist/components/builder/composition-field-editor.js +94 -22
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/inline-edit.js +3 -2
- package/dist/components/builder/inline-edit.js.map +1 -1
- package/dist/components/builder/node-factory.d.ts +4 -0
- package/dist/components/builder/node-factory.js +14 -9
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/cms/cms-composition-validators.d.ts +7 -16
- package/dist/components/cms/cms-composition-validators.js +101 -52
- package/dist/components/cms/cms-composition-validators.js.map +1 -1
- package/dist/components/cms/gate-validation.js +3 -1
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/rich-text/component-embed.d.ts +13 -7
- package/dist/components/cms/rich-text/component-embed.js +21 -13
- package/dist/components/cms/rich-text/component-embed.js.map +1 -1
- package/dist/components/cms/rich-text/editor.d.ts +1 -1
- package/dist/components/cms/rich-text/editor.js +2 -2
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/extensions.d.ts +4 -0
- package/dist/components/cms/rich-text/extensions.js +6 -0
- package/dist/components/cms/rich-text/extensions.js.map +1 -1
- package/dist/components/cms/rich-text/insert.js +1 -1
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/rich-text/unknown-node.d.ts +66 -0
- package/dist/components/cms/rich-text/unknown-node.js +101 -0
- package/dist/components/cms/rich-text/unknown-node.js.map +1 -0
- package/dist/components/cms/rich-text/value.d.ts +82 -13
- package/dist/components/cms/rich-text/value.js +169 -38
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/rich-text-preview.js +1 -1
- package/dist/components/cms/rich-text-preview.js.map +1 -1
- package/dist/lib/ai/rich-text-translate.js +3 -3
- package/dist/lib/ai/rich-text-translate.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +7 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +4 -0
- package/dist/lib/i18n/messages.en.js +7 -3
- package/dist/lib/i18n/messages.en.js.map +1 -1
- 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 {
|
|
1
|
+
import type { BlockSequenceValue, RichTextDoc } from '@elytracms/core/rich-text';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* document
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
|
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.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
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
|
|
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
|
|
21
|
-
*
|
|
22
|
-
|
|
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
|
-
*
|
|
76
|
+
* Project whatever a body-capable field currently holds into an editor document.
|
|
28
77
|
*
|
|
29
|
-
* Accepts the canonical `{ version,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
36
|
-
if (
|
|
37
|
-
return
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
value
|
|
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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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.
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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.
|
|
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,
|
|
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:
|
|
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.
|
|
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.
|
|
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)
|