@designcrowd/library.brand-page 6.0.6-json-prereq-1 → 6.0.6-json-prereq-2
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/lib/src/{CreatedOnBrandCrowd-de-de-Dx3e-vQ6.js → CreatedOnBrandCrowd-de-de-F1Ldbx6c.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-CbchLDa2.js → CreatedOnBrandCrowd-en-us-D8z2m0Et.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-BXbpN7li.js → CreatedOnBrandCrowd-es-es-CLSkbkYu.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-hIk7h9bV.js → CreatedOnBrandCrowd-fr-ca-Cyc4YWYR.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-hIk7h9bV.js → CreatedOnBrandCrowd-fr-fr-Cyc4YWYR.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-CqsCRIqc.js → CreatedOnBrandCrowd-pt-br-DSSUcSmV.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-CqsCRIqc.js → CreatedOnBrandCrowd-pt-pt-DSSUcSmV.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-NEOtgsvz.js → CreatedOnDcom-de-de-DF1JaJPE.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-CKtEdcoG.js → CreatedOnDcom-en-us-Dek2GCu0.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-DZR9o6DK.js → CreatedOnDcom-es-es-C3JD7_bP.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-Bo_oTp-T.js → CreatedOnDcom-fr-ca-Bllxm84y.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-DE6Fa3ie.js → CreatedOnDcom-fr-fr-B3cFZgRB.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-Cws2QJ7J.js → CreatedOnDcom-pt-br-DHCnZdZk.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-DYGQo6q3.js → CreatedOnDcom-pt-pt-BLooAZOd.js} +1 -1
- package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +8 -7
- package/lib/src/brand-page-maker/utils/rte-render.util.js +8 -36
- package/lib/src/brand-page-maker/utils/rte-serializer.d.ts +5 -0
- package/lib/src/brand-page-maker/utils/rte-serializer.js +112 -0
- package/lib/src/index.mjs +1 -1
- package/lib/src/{lib-C5H_bvMR.js → lib-BWEaceMZ.js} +16212 -16135
- package/package.json +1 -1
- package/src/brand-page-maker/utils/__snapshots__/rte-render-parity.test.ts.snap +2 -0
- package/src/brand-page-maker/utils/rte-render-parity.test.ts +21 -9
- package/src/brand-page-maker/utils/rte-render.util.ts +12 -47
- package/src/brand-page-maker/utils/rte-serializer.ts +172 -0
package/package.json
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
exports[`BP-5787 RTE render parity (golden master) > T1 — editor renders the complex fixture to the frozen HTML baseline > editor-normalized-html 1`] = `"<h2 style="text-align: center;"><span style="color: var(--logo-text-color);">Themed heading</span></h2><p><strong>bold</strong> <em>italic</em> <u>underline</u> <s>strike</s> <span style="font-family: Montserrat; font-size: 1.5em; letter-spacing: 0.05em; line-height: 1.4;">styled span</span> <mark data-color="#ffe08a" style="background-color: rgb(255, 224, 138); color: inherit;">highlight</mark> <span style="color: rgb(255, 0, 0);">literal hex</span> <a href="/about" target="_self">internal link</a> <a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">external link</a></p><p style="text-align: right;">second paragraph, right-aligned</p>"`;
|
|
4
4
|
|
|
5
|
+
exports[`BP-5787 RTE render parity (golden master) > T1 — render-from-JSON (DOM-less serialiser) produces equivalent HTML > serializer-html 1`] = `"<h2 style="text-align: center"><span style="color: var(--logo-text-color)">Themed heading</span></h2><p><strong>bold</strong> <em>italic</em> <u>underline</u> <s>strike</s> <span style="font-family: Montserrat; font-size: 1.5em; letter-spacing: 0.05em; line-height: 1.4">styled span</span> <mark data-color="#ffe08a" style="background-color: #ffe08a; color: inherit;">highlight</mark> <span style="color: rgb(255, 0, 0)">literal hex</span> <a href="/about" target="_self">internal link</a> <a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">external link</a></p><p style="text-align: right">second paragraph, right-aligned</p>"`;
|
|
6
|
+
|
|
5
7
|
exports[`BP-5787 RTE render parity (golden master) > T6 — freezes the JSON shape that becomes the stored form > editor-json 1`] = `
|
|
6
8
|
{
|
|
7
9
|
"content": [
|
|
@@ -82,17 +82,29 @@ describe('BP-5787 RTE render parity (golden master)', () => {
|
|
|
82
82
|
expect(JSON.stringify(ed.getJSON())).toContain(THEME_TOKEN_REF);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it('T1 — render-from-JSON
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ed.commands.setContent(COMPLEX_RTE_HTML);
|
|
91
|
-
const reference = ed.getHTML();
|
|
92
|
-
|
|
85
|
+
it('T1 — render-from-JSON (DOM-less serialiser) produces equivalent HTML', () => {
|
|
86
|
+
// renderRteToHtml now serialises JSON → HTML with a pure function (no
|
|
87
|
+
// editor/DOM, so it works on the server). Output is semantically identical
|
|
88
|
+
// to the editor's — the Playwright visual golden-master is the authority on
|
|
89
|
+
// pixel parity; here we freeze the serialiser output and assert structure.
|
|
93
90
|
const viaJson = renderRteToHtml(toRteJson(COMPLEX_RTE_HTML));
|
|
94
91
|
|
|
95
|
-
expect(viaJson).
|
|
92
|
+
expect(viaJson).toMatchSnapshot('serializer-html');
|
|
93
|
+
|
|
94
|
+
// marks/nodes preserved
|
|
95
|
+
expect(viaJson).toContain('<strong>bold</strong>');
|
|
96
|
+
expect(viaJson).toContain('<em>italic</em>');
|
|
97
|
+
expect(viaJson).toContain('<u>underline</u>');
|
|
98
|
+
expect(viaJson).toContain('<s>strike</s>');
|
|
99
|
+
expect(viaJson).toContain('font-family: Montserrat');
|
|
100
|
+
expect(viaJson).toContain('font-size: 1.5em');
|
|
101
|
+
// theme token ref preserved (the crux of BP-5787)
|
|
102
|
+
expect(viaJson).toContain('color: var(--logo-text-color)');
|
|
103
|
+
// links + alignment
|
|
104
|
+
expect(viaJson).toContain('href="/about"');
|
|
105
|
+
expect(viaJson).toContain('href="https://example.com"');
|
|
106
|
+
expect(viaJson).toContain('text-align: center');
|
|
107
|
+
expect(viaJson).toContain('text-align: right');
|
|
96
108
|
});
|
|
97
109
|
|
|
98
110
|
it('T4 — legacy HTML strings render unchanged (back-compat passthrough)', () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Editor, type JSONContent } from '@tiptap/vue-3';
|
|
2
2
|
import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
|
|
3
3
|
import { isTiptapDocument, getRteJsonPlainText } from './rich-text-editor.util';
|
|
4
|
+
import { serializeRteJsonToHtml } from './rte-serializer';
|
|
4
5
|
|
|
5
6
|
export { isTiptapDocument, getRteJsonPlainText };
|
|
6
7
|
|
|
@@ -15,20 +16,17 @@ export { isTiptapDocument, getRteJsonPlainText };
|
|
|
15
16
|
* - renderRteToHtml — render either form to final HTML for display.
|
|
16
17
|
* - toRteJson — normalise either form to JSON for storage.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* - renderRteToHtml uses a DOM-less serialiser (works on the server).
|
|
20
|
+
* - toRteJson uses a headless editor to PARSE HTML → JSON, so it only runs
|
|
21
|
+
* where a DOM exists (browser, or jsdom in tests); the server never parses
|
|
22
|
+
* HTML (migration-18 is DOM-guarded, translation/fraud walk JSON directly).
|
|
21
23
|
*/
|
|
22
24
|
export type RichTextValue = string | JSONContent;
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Run a function with a short-lived headless editor (created then destroyed).
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* editor throws "document is not defined" when a test DOM (jsdom) is torn down.
|
|
29
|
-
* The expensive repeat-render case (re-rendering unchanged content on a
|
|
30
|
-
* display-style/layout change) is handled by the render cache below, so this
|
|
31
|
-
* only runs on a cache miss — i.e. genuinely new/changed content.
|
|
28
|
+
* Used only by toRteJson (HTML → JSON parsing), which needs a real DOM. Not
|
|
29
|
+
* used by the render path, which is DOM-less.
|
|
32
30
|
*/
|
|
33
31
|
const withHeadlessEditor = <T>(fn: (editor: Editor) => T): T => {
|
|
34
32
|
const editor = new Editor({
|
|
@@ -44,20 +42,10 @@ const withHeadlessEditor = <T>(fn: (editor: Editor) => T): T => {
|
|
|
44
42
|
};
|
|
45
43
|
|
|
46
44
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* keyed by the document makes those repeat renders a near-free map lookup.
|
|
52
|
-
*/
|
|
53
|
-
const RENDER_CACHE_LIMIT = 200;
|
|
54
|
-
const renderCache = new Map<string, string>();
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Render an RTE value to final HTML. Legacy HTML strings pass through
|
|
58
|
-
* untouched (they are already HTML); Tiptap JSON is rendered via the
|
|
59
|
-
* headless editor's renderHTML, matching the stored-HTML output exactly,
|
|
60
|
-
* and memoised so repeat renders of unchanged content don't re-convert.
|
|
45
|
+
* Render an RTE value to final HTML. Legacy HTML strings pass through untouched
|
|
46
|
+
* (they are already HTML); Tiptap JSON is serialised by a DOM-less pure
|
|
47
|
+
* function (rte-serializer) — no editor, no DOM — so it works on the server
|
|
48
|
+
* (SSR/publish), not just the browser, and is cheap enough to run per render.
|
|
61
49
|
*/
|
|
62
50
|
export const renderRteToHtml = (
|
|
63
51
|
value: RichTextValue | null | undefined,
|
|
@@ -66,30 +54,7 @@ export const renderRteToHtml = (
|
|
|
66
54
|
|
|
67
55
|
if (typeof value === 'string') return value;
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
const cached = renderCache.get(key);
|
|
71
|
-
|
|
72
|
-
if (cached !== undefined) {
|
|
73
|
-
// LRU bump: re-insert so the most-recently-used key is evicted last.
|
|
74
|
-
renderCache.delete(key);
|
|
75
|
-
renderCache.set(key, cached);
|
|
76
|
-
|
|
77
|
-
return cached;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const html = withHeadlessEditor((editor) => {
|
|
81
|
-
editor.commands.setContent(value);
|
|
82
|
-
|
|
83
|
-
return editor.getHTML();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
renderCache.set(key, html);
|
|
87
|
-
|
|
88
|
-
if (renderCache.size > RENDER_CACHE_LIMIT) {
|
|
89
|
-
renderCache.delete(renderCache.keys().next().value as string);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return html;
|
|
57
|
+
return serializeRteJsonToHtml(value);
|
|
93
58
|
};
|
|
94
59
|
|
|
95
60
|
/**
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { JSONContent } from '@tiptap/vue-3';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BP-5787 — DOM-less Tiptap-JSON → HTML serialiser.
|
|
5
|
+
*
|
|
6
|
+
* The editor-based render (`new Editor().getHTML()`) needs a browser DOM and
|
|
7
|
+
* throws "document is not defined" on the server (web-renderer SSR / publish),
|
|
8
|
+
* which is where published pages are rendered. This pure-function serialiser
|
|
9
|
+
* walks the JSON and emits the equivalent HTML with no editor and no DOM, so it
|
|
10
|
+
* runs anywhere (SSR, node, browser) and is far cheaper than instantiating an
|
|
11
|
+
* editor per field.
|
|
12
|
+
*
|
|
13
|
+
* The output is semantically identical to the editor's (same tags, same inline
|
|
14
|
+
* styles); the Playwright visual golden-master is the authority on pixel parity.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Mark = { type?: string; attrs?: Record<string, unknown> };
|
|
18
|
+
|
|
19
|
+
type Node = {
|
|
20
|
+
type?: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
marks?: Mark[];
|
|
23
|
+
attrs?: Record<string, unknown>;
|
|
24
|
+
content?: Node[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const escapeHtml = (value: string): string =>
|
|
28
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
29
|
+
|
|
30
|
+
const escapeAttr = (value: string): string =>
|
|
31
|
+
escapeHtml(value).replace(/"/g, '"');
|
|
32
|
+
|
|
33
|
+
// textStyle attr → CSS property, in a stable order.
|
|
34
|
+
const TEXT_STYLE_PROPS: [string, string][] = [
|
|
35
|
+
['fontFamily', 'font-family'],
|
|
36
|
+
['fontSize', 'font-size'],
|
|
37
|
+
['letterSpacing', 'letter-spacing'],
|
|
38
|
+
['lineHeight', 'line-height'],
|
|
39
|
+
['color', 'color'],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const textStyleCss = (attrs: Record<string, unknown>): string =>
|
|
43
|
+
TEXT_STYLE_PROPS.filter(
|
|
44
|
+
([key]) => typeof attrs[key] === 'string' && attrs[key],
|
|
45
|
+
)
|
|
46
|
+
.map(([key, css]) => `${css}: ${attrs[key] as string}`)
|
|
47
|
+
.join('; ');
|
|
48
|
+
|
|
49
|
+
// Outermost → innermost. Reversed at wrap time so links wrap outside spans,
|
|
50
|
+
// which wrap outside the plain formatting marks.
|
|
51
|
+
const MARK_ORDER = [
|
|
52
|
+
'link',
|
|
53
|
+
'textStyle',
|
|
54
|
+
'highlight',
|
|
55
|
+
'strike',
|
|
56
|
+
'underline',
|
|
57
|
+
'italic',
|
|
58
|
+
'bold',
|
|
59
|
+
'code',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const wrapMark = (
|
|
63
|
+
type: string,
|
|
64
|
+
attrs: Record<string, unknown>,
|
|
65
|
+
inner: string,
|
|
66
|
+
): string => {
|
|
67
|
+
switch (type) {
|
|
68
|
+
case 'bold':
|
|
69
|
+
return `<strong>${inner}</strong>`;
|
|
70
|
+
case 'italic':
|
|
71
|
+
return `<em>${inner}</em>`;
|
|
72
|
+
case 'underline':
|
|
73
|
+
return `<u>${inner}</u>`;
|
|
74
|
+
case 'strike':
|
|
75
|
+
return `<s>${inner}</s>`;
|
|
76
|
+
case 'code':
|
|
77
|
+
return `<code>${inner}</code>`;
|
|
78
|
+
|
|
79
|
+
case 'textStyle': {
|
|
80
|
+
const css = textStyleCss(attrs);
|
|
81
|
+
|
|
82
|
+
return css ? `<span style="${escapeAttr(css)}">${inner}</span>` : inner;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case 'highlight': {
|
|
86
|
+
const color = typeof attrs.color === 'string' ? attrs.color : '';
|
|
87
|
+
|
|
88
|
+
return color
|
|
89
|
+
? `<mark data-color="${escapeAttr(color)}" style="background-color: ${escapeAttr(color)}; color: inherit;">${inner}</mark>`
|
|
90
|
+
: `<mark>${inner}</mark>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case 'link': {
|
|
94
|
+
const href = typeof attrs.href === 'string' ? attrs.href : '';
|
|
95
|
+
let open = `<a href="${escapeAttr(href)}"`;
|
|
96
|
+
|
|
97
|
+
if (attrs.target) open += ` target="${escapeAttr(String(attrs.target))}"`;
|
|
98
|
+
|
|
99
|
+
if (attrs.rel) open += ` rel="${escapeAttr(String(attrs.rel))}"`;
|
|
100
|
+
|
|
101
|
+
return `${open}>${inner}</a>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
default:
|
|
105
|
+
return inner;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const renderText = (node: Node): string => {
|
|
110
|
+
let html = escapeHtml(node.text ?? '');
|
|
111
|
+
const marks = Array.isArray(node.marks) ? node.marks : [];
|
|
112
|
+
|
|
113
|
+
for (const type of [...MARK_ORDER].reverse()) {
|
|
114
|
+
const mark = marks.find((m) => m?.type === type);
|
|
115
|
+
|
|
116
|
+
if (mark) html = wrapMark(type, mark.attrs ?? {}, html);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return html;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const alignAttr = (node: Node): string => {
|
|
123
|
+
const align = node.attrs?.textAlign;
|
|
124
|
+
|
|
125
|
+
return typeof align === 'string' && align
|
|
126
|
+
? ` style="text-align: ${escapeAttr(align)}"`
|
|
127
|
+
: '';
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const renderChildren = (node: Node): string =>
|
|
131
|
+
(node.content ?? []).map(renderNode).join('');
|
|
132
|
+
|
|
133
|
+
const renderNode = (node: Node): string => {
|
|
134
|
+
if (!node || typeof node !== 'object') return '';
|
|
135
|
+
|
|
136
|
+
switch (node.type) {
|
|
137
|
+
case 'text':
|
|
138
|
+
return renderText(node);
|
|
139
|
+
case 'hardBreak':
|
|
140
|
+
return '<br>';
|
|
141
|
+
case 'horizontalRule':
|
|
142
|
+
return '<hr>';
|
|
143
|
+
case 'paragraph':
|
|
144
|
+
return `<p${alignAttr(node)}>${renderChildren(node)}</p>`;
|
|
145
|
+
|
|
146
|
+
case 'heading': {
|
|
147
|
+
const level = Math.min(6, Math.max(1, Number(node.attrs?.level) || 1));
|
|
148
|
+
|
|
149
|
+
return `<h${level}${alignAttr(node)}>${renderChildren(node)}</h${level}>`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'bulletList':
|
|
153
|
+
return `<ul>${renderChildren(node)}</ul>`;
|
|
154
|
+
case 'orderedList':
|
|
155
|
+
return `<ol>${renderChildren(node)}</ol>`;
|
|
156
|
+
case 'listItem':
|
|
157
|
+
return `<li>${renderChildren(node)}</li>`;
|
|
158
|
+
case 'blockquote':
|
|
159
|
+
return `<blockquote>${renderChildren(node)}</blockquote>`;
|
|
160
|
+
case 'codeBlock':
|
|
161
|
+
return `<pre><code>${renderChildren(node)}</code></pre>`;
|
|
162
|
+
// doc and any unknown container: render children.
|
|
163
|
+
default:
|
|
164
|
+
return renderChildren(node);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Serialise a Tiptap JSON document to HTML without an editor/DOM.
|
|
170
|
+
*/
|
|
171
|
+
export const serializeRteJsonToHtml = (doc: JSONContent): string =>
|
|
172
|
+
renderChildren(doc as Node);
|