@designcrowd/library.brand-page 6.0.19 → 6.0.20

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 (97) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-DCwwdYPj.js → CreatedOnBrandCrowd-de-de-C52IyXZu.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DTmZeDQ1.js → CreatedOnBrandCrowd-en-us-DhTy_dEV.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-Dbrz6TXv.js → CreatedOnBrandCrowd-es-es-BJelIAQ4.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-DLQhgn8T.js → CreatedOnBrandCrowd-fr-ca-DwJohy0T.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-DLQhgn8T.js → CreatedOnBrandCrowd-fr-fr-DwJohy0T.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-yAzzD2ik.js → CreatedOnBrandCrowd-pt-br-CMzKbxdG.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-yAzzD2ik.js → CreatedOnBrandCrowd-pt-pt-CMzKbxdG.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-CNmNNE5o.js → CreatedOnDcom-de-de-DAy1GHrw.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-BUmLFlS_.js → CreatedOnDcom-en-us-CgBDAkJN.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-DfygM_0q.js → CreatedOnDcom-es-es-Ds9d5rem.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-DIEft8xE.js → CreatedOnDcom-fr-ca-CZL0BQ-u.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-Cn3jWG2n.js → CreatedOnDcom-fr-fr-BVYh5Wvo.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-DXhgGwEh.js → CreatedOnDcom-pt-br-1hsWNXfq.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-BvE9ckw1.js → CreatedOnDcom-pt-pt-DvM0N-3M.js} +1 -1
  15. package/lib/src/brand-page/components/contact-form/ContactForm.mixin.js +2 -5
  16. package/lib/src/brand-page/models/index.d.ts +26 -34
  17. package/lib/src/brand-page/utils/migration.util.js +0 -11
  18. package/lib/src/brand-page/utils/text.util.d.ts +1 -1
  19. package/lib/src/brand-page/utils/text.util.js +0 -32
  20. package/lib/src/brand-page/utils/vcard.util.js +4 -4
  21. package/lib/src/brand-page/utils/view-model.util.d.ts +0 -1
  22. package/lib/src/brand-page/utils/view-model.util.js +20 -68
  23. package/lib/src/brand-page/view-models.d.ts +3 -3
  24. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +100 -21
  25. package/lib/src/brand-page-maker/utils/index.d.ts +0 -2
  26. package/lib/src/brand-page-maker/utils/index.js +0 -2
  27. package/lib/src/brand-page-maker/utils/rich-text-editor.util.d.ts +5 -19
  28. package/lib/src/brand-page-maker/utils/rich-text-editor.util.js +5 -35
  29. package/lib/src/brand-page-maker/validators/field.validator.js +2 -10
  30. package/lib/src/css/library.brand-page-brandCrowd.min.css +1 -1
  31. package/lib/src/css/library.brand-page-brandPage.min.css +1 -1
  32. package/lib/src/css/library.brand-page-designCom.min.css +1 -1
  33. package/lib/src/css/library.brand-page-preview.min.css +1 -1
  34. package/lib/src/index.d.ts +0 -1
  35. package/lib/src/index.mjs +138 -143
  36. package/lib/src/{lib-Bm-fYap1.js → lib-CK2FPBv5.js} +55098 -55367
  37. package/lib/src/lib.js +0 -2
  38. package/lib/src/metadata-builder/builders/update-content-text-builder.js +15 -20
  39. package/lib/src/metadata-builder/builders/update-content-text-style-builder.js +3 -8
  40. package/lib/src/metadata-builder/helpers/rte.helpers.d.ts +2 -2
  41. package/lib/src/metadata-builder/helpers/rte.helpers.js +3 -6
  42. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.js +1 -14
  43. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.js +3 -12
  44. package/lib/src/utils/minify-brand-page-metadata.js +1 -4
  45. package/package.json +1 -1
  46. package/src/brand-page/components/contact-form/ContactForm.mixin.ts +2 -5
  47. package/src/brand-page/components/text-wrapper/TextWrapper.vue +6 -17
  48. package/src/brand-page/models/index.ts +26 -35
  49. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +3 -3
  50. package/src/brand-page/utils/migration.util.ts +0 -12
  51. package/src/brand-page/utils/style.util.test.ts +0 -51
  52. package/src/brand-page/utils/text.util.ts +4 -43
  53. package/src/brand-page/utils/vcard.util.ts +4 -6
  54. package/src/brand-page/utils/view-model.util.ts +26 -94
  55. package/src/brand-page/view-models.ts +2 -3
  56. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +107 -23
  57. package/src/brand-page-maker/utils/index.ts +0 -2
  58. package/src/brand-page-maker/utils/rich-text-editor.util.test.ts +3 -4
  59. package/src/brand-page-maker/utils/rich-text-editor.util.ts +11 -50
  60. package/src/brand-page-maker/validators/field.validator.ts +2 -10
  61. package/src/lib.ts +0 -9
  62. package/src/metadata-builder/builders/update-content-text-builder.ts +18 -32
  63. package/src/metadata-builder/builders/update-content-text-style-builder.ts +4 -14
  64. package/src/metadata-builder/helpers/rte.helpers.ts +7 -11
  65. package/src/shared/content-settings/components/GenericTextInput.mixin.ts +1 -17
  66. package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.ts +3 -16
  67. package/src/utils/minify-brand-page-metadata.ts +1 -4
  68. package/lib/src/brand-page/utils/migration/migration-19.d.ts +0 -2
  69. package/lib/src/brand-page/utils/migration/migration-19.js +0 -71
  70. package/lib/src/brand-page-maker/__fixtures__/complex-rte.fixture.d.ts +0 -22
  71. package/lib/src/brand-page-maker/__fixtures__/complex-rte.fixture.js +0 -22
  72. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.d.ts +0 -14
  73. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.js +0 -39
  74. package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.d.ts +0 -28
  75. package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.js +0 -66
  76. package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +0 -33
  77. package/lib/src/brand-page-maker/utils/rte-render.util.js +0 -50
  78. package/lib/src/brand-page-maker/utils/rte-sanitize.util.d.ts +0 -6
  79. package/lib/src/brand-page-maker/utils/rte-sanitize.util.js +0 -158
  80. package/lib/src/brand-page-maker/utils/rte-serializer.d.ts +0 -5
  81. package/lib/src/brand-page-maker/utils/rte-serializer.js +0 -112
  82. package/src/brand-page/utils/migration/migration-19.test.ts +0 -72
  83. package/src/brand-page/utils/migration/migration-19.ts +0 -83
  84. package/src/brand-page/utils/text.util.test.ts +0 -47
  85. package/src/brand-page/utils/view-model.pageid-rte.test.ts +0 -104
  86. package/src/brand-page-maker/__fixtures__/complex-rte.fixture.ts +0 -23
  87. package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.test.ts +0 -52
  88. package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.ts +0 -42
  89. package/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.ts +0 -113
  90. package/src/brand-page-maker/utils/__snapshots__/rte-render-parity.test.ts.snap +0 -200
  91. package/src/brand-page-maker/utils/rte-features.test.ts +0 -330
  92. package/src/brand-page-maker/utils/rte-render-parity.test.ts +0 -156
  93. package/src/brand-page-maker/utils/rte-render.util.ts +0 -77
  94. package/src/brand-page-maker/utils/rte-sanitize-json.test.ts +0 -158
  95. package/src/brand-page-maker/utils/rte-sanitize.util.ts +0 -195
  96. package/src/brand-page-maker/utils/rte-serializer.ts +0 -172
  97. package/src/brand-page-maker/validators/field.validator.test.ts +0 -55
@@ -1,77 +0,0 @@
1
- import { Editor, type JSONContent } from '@tiptap/vue-3';
2
- import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
3
- import { isTiptapDocument, getRteJsonPlainText } from './rich-text-editor.util';
4
- import { serializeRteJsonToHtml } from './rte-serializer';
5
-
6
- export { isTiptapDocument, getRteJsonPlainText };
7
-
8
- /**
9
- * BP-5787 — the boundary plumbing for storing RTE as Tiptap JSON.
10
- *
11
- * An RTE field is either a legacy inline-HTML string (pre-migration, or a
12
- * not-yet-migrated published site) or a Tiptap JSON document (post-migration).
13
- * These helpers let every consumer treat the two uniformly:
14
- *
15
- * - isTiptapDocument — the canonical discriminator (object with type 'doc').
16
- * - renderRteToHtml — render either form to final HTML for display.
17
- * - toRteJson — normalise either form to JSON for storage.
18
- *
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).
23
- */
24
- export type RichTextValue = string | JSONContent;
25
-
26
- /**
27
- * Run a function with a short-lived headless editor (created then destroyed).
28
- * Used only by toRteJson (HTML → JSON parsing), which needs a real DOM. Not
29
- * used by the render path, which is DOM-less.
30
- */
31
- const withHeadlessEditor = <T>(fn: (editor: Editor) => T): T => {
32
- const editor = new Editor({
33
- editable: false,
34
- extensions: getRteExtensions({ includeLinkRel: true }),
35
- });
36
-
37
- try {
38
- return fn(editor);
39
- } finally {
40
- editor.destroy();
41
- }
42
- };
43
-
44
- /**
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.
49
- */
50
- export const renderRteToHtml = (
51
- value: RichTextValue | null | undefined,
52
- ): string => {
53
- if (value == null || value === '') return '';
54
-
55
- if (typeof value === 'string') return value;
56
-
57
- return serializeRteJsonToHtml(value);
58
- };
59
-
60
- /**
61
- * Normalise an RTE value to a Tiptap JSON document. HTML strings are parsed
62
- * via the headless editor; documents pass through unchanged. Returns
63
- * undefined for empty/blank input so callers can omit empty fields.
64
- */
65
- export const toRteJson = (
66
- value: RichTextValue | null | undefined,
67
- ): JSONContent | undefined => {
68
- if (value == null || value === '') return undefined;
69
-
70
- if (isTiptapDocument(value)) return value;
71
-
72
- return withHeadlessEditor((editor) => {
73
- editor.commands.setContent(value);
74
-
75
- return editor.getJSON();
76
- });
77
- };
@@ -1,158 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { sanitizeRteJson } from './rte-sanitize.util';
3
- import { toRteJson } from './rte-render.util';
4
- import { COMPLEX_RTE_HTML } from '../__fixtures__/complex-rte.fixture';
5
-
6
- /**
7
- * BP-5787 — native JSON sanitiser (T5). Operates on the Tiptap tree directly
8
- * (no editor, no HTML, no DOMPurify). Mirrors the HTML allowlist's guarantees.
9
- */
10
- const doc = (content: unknown[]): unknown => ({ type: 'doc', content });
11
- const para = (content: unknown[]) => ({ type: 'paragraph', content });
12
- const text = (t: string, marks?: unknown[]) => ({
13
- type: 'text',
14
- text: t,
15
- marks,
16
- });
17
- const json = (v: unknown) => JSON.stringify(v);
18
-
19
- describe('sanitizeRteJson (native)', () => {
20
- it('drops unknown node types but keeps their valid text siblings', () => {
21
- const out = sanitizeRteJson(
22
- doc([
23
- para([text('keep me')]),
24
- { type: 'image', attrs: { src: 'x', onerror: 'alert(1)' } },
25
- { type: 'script', content: [text('alert(1)')] },
26
- ]),
27
- );
28
-
29
- expect(json(out)).toContain('keep me');
30
- expect(json(out)).not.toContain('image');
31
- expect(json(out)).not.toContain('script');
32
- expect(json(out)).not.toContain('alert(1)');
33
- });
34
-
35
- it('drops unknown mark types, keeps allow-listed ones', () => {
36
- const out = sanitizeRteJson(
37
- doc([
38
- para([
39
- text('bold', [{ type: 'bold' }]),
40
- text('evil', [{ type: 'onclick', attrs: { js: 'alert(1)' } }]),
41
- ]),
42
- ]),
43
- );
44
-
45
- expect(json(out)).toContain('"type":"bold"');
46
- expect(json(out)).not.toContain('onclick');
47
- expect(json(out)).not.toContain('alert(1)');
48
- });
49
-
50
- it('strips javascript: / data: hrefs from link marks (drops the mark)', () => {
51
- const out = sanitizeRteJson(
52
- doc([
53
- para([
54
- // eslint-disable-next-line no-script-url
55
- text('click', [
56
- { type: 'link', attrs: { href: 'javascript:alert(1)' } },
57
- ]),
58
- ]),
59
- ]),
60
- );
61
-
62
- expect(json(out)).toContain('click'); // text kept
63
- expect(json(out)).not.toContain('javascript:');
64
- expect(json(out)).not.toContain('"type":"link"');
65
- });
66
-
67
- it('keeps safe links', () => {
68
- const out = sanitizeRteJson(
69
- doc([
70
- para([
71
- text('site', [
72
- {
73
- type: 'link',
74
- attrs: { href: 'https://example.com', target: '_blank' },
75
- },
76
- ]),
77
- ]),
78
- ]),
79
- );
80
-
81
- expect(json(out)).toContain('https://example.com');
82
- });
83
-
84
- it('keeps safe textStyle attrs incl. theme token, drops injection values', () => {
85
- const out = sanitizeRteJson(
86
- doc([
87
- para([
88
- text('ok', [
89
- {
90
- type: 'textStyle',
91
- attrs: {
92
- color: 'var(--logo-text-color)',
93
- fontFamily: 'Open Sans',
94
- fontSize: '1.5em',
95
- },
96
- },
97
- ]),
98
- text('bad', [
99
- {
100
- type: 'textStyle',
101
- attrs: {
102
- color: 'red; position: fixed; top: 0',
103
- fontSize: '1em" onload="x',
104
- },
105
- },
106
- ]),
107
- ]),
108
- ]),
109
- );
110
-
111
- const s = json(out);
112
-
113
- expect(s).toContain('var(--logo-text-color)');
114
- expect(s).toContain('Open Sans');
115
- expect(s).toContain('1.5em');
116
- // injection values dropped
117
- expect(s).not.toContain('position: fixed');
118
- expect(s).not.toContain('onload');
119
- });
120
-
121
- it('clamps heading level and keeps valid textAlign', () => {
122
- const out: any = sanitizeRteJson(
123
- doc([
124
- {
125
- type: 'heading',
126
- attrs: { level: 99, textAlign: 'center' },
127
- content: [text('H')],
128
- },
129
- {
130
- type: 'paragraph',
131
- attrs: { textAlign: 'evil' },
132
- content: [text('P')],
133
- },
134
- ]),
135
- );
136
-
137
- expect(out.content[0].attrs.level).toBe(1);
138
- expect(out.content[0].attrs.textAlign).toBe('center');
139
- expect(out.content[1].attrs?.textAlign).toBeUndefined();
140
- });
141
-
142
- it('passes the clean complex fixture through unchanged (idempotent)', () => {
143
- const clean = toRteJson(COMPLEX_RTE_HTML);
144
- const once = sanitizeRteJson(clean);
145
- const twice = sanitizeRteJson(once);
146
-
147
- expect(twice).toEqual(once);
148
- // legitimate content preserved
149
- expect(json(once)).toContain('var(--logo-text-color)');
150
- expect(json(once)).toContain('Montserrat');
151
- expect(json(once)).toContain('https://example.com');
152
- });
153
-
154
- it('returns an empty doc for non-document input', () => {
155
- expect(sanitizeRteJson(null)).toEqual({ type: 'doc', content: [] });
156
- expect(sanitizeRteJson('<p>x</p>')).toEqual({ type: 'doc', content: [] });
157
- });
158
- });
@@ -1,195 +0,0 @@
1
- import type { JSONContent } from '@tiptap/vue-3';
2
- import { validateAndSanitizeHref } from './rich-text-editor.util';
3
-
4
- /**
5
- * BP-5787 — native JSON sanitiser for RTE content.
6
- *
7
- * Operates directly on the Tiptap document tree: no editor instantiation, no
8
- * HTML round-trip, no DOMPurify. This is the hot path (runs on every editor
9
- * update and on the server sanitise gate), so it is a single, allocation-light
10
- * recursive walk.
11
- *
12
- * Security model (mirrors the HTML allowlist it replaces):
13
- * - only allow-listed node and mark types survive; everything else is dropped;
14
- * - textStyle / highlight attrs are restricted to known keys whose values must
15
- * be "safe CSS values" (no quotes/angle-brackets/extra declarations/url()/
16
- * expression()), preventing CSS-injection when the value is later rendered
17
- * into a `style="…"` attribute;
18
- * - link hrefs go through the same validateAndSanitizeHref used by the HTML
19
- * sanitiser; unsafe links lose their href (and the mark);
20
- * - heading levels are clamped to 1–6.
21
- */
22
-
23
- // ProseMirror node types the editor schema can legitimately produce.
24
- const ALLOWED_NODE_TYPES = new Set([
25
- 'doc',
26
- 'paragraph',
27
- 'heading',
28
- 'text',
29
- 'hardBreak',
30
- 'bulletList',
31
- 'orderedList',
32
- 'listItem',
33
- 'blockquote',
34
- 'codeBlock',
35
- 'horizontalRule',
36
- ]);
37
-
38
- // Inline mark types the editor produces (strong/em/u/s/code/span/mark/a).
39
- const ALLOWED_MARK_TYPES = new Set([
40
- 'bold',
41
- 'italic',
42
- 'underline',
43
- 'strike',
44
- 'code',
45
- 'textStyle',
46
- 'highlight',
47
- 'link',
48
- ]);
49
-
50
- // textStyle attrs map to the allow-listed CSS properties (font-weight →
51
- // bold mark, text-decoration → underline/strike, text-align → node attr,
52
- // background-color → highlight, so they are intentionally not here).
53
- const ALLOWED_TEXT_STYLE_ATTRS = new Set([
54
- 'color',
55
- 'fontFamily',
56
- 'fontSize',
57
- 'letterSpacing',
58
- 'lineHeight',
59
- ]);
60
-
61
- type Mark = { type?: string; attrs?: Record<string, unknown> };
62
-
63
- /**
64
- * A safe CSS value is a short string free of characters that could inject an
65
- * extra declaration or escape the attribute, and free of the url()/expression()
66
- * vectors. Legitimate values — `rgb(255, 0, 0)`, `#FF0000`,
67
- * `var(--logo-text-color)`, `Open Sans`, `1.5em` — all pass.
68
- */
69
- const isSafeCssValue = (value: unknown): value is string =>
70
- typeof value === 'string' &&
71
- value.length > 0 &&
72
- value.length <= 200 &&
73
- !/[<>"';]/.test(value) &&
74
- !/url\s*\(/i.test(value) &&
75
- !/expression\s*\(/i.test(value);
76
-
77
- const sanitizeTextStyleAttrs = (
78
- attrs: Record<string, unknown>,
79
- ): Record<string, unknown> | null => {
80
- const clean: Record<string, unknown> = {};
81
-
82
- for (const key of Object.keys(attrs)) {
83
- if (!ALLOWED_TEXT_STYLE_ATTRS.has(key)) continue;
84
-
85
- const value = attrs[key];
86
-
87
- // Tiptap emits '' for unset textStyle slots — preserve them.
88
- if (value === '' || value == null) {
89
- clean[key] = '';
90
- } else if (isSafeCssValue(value)) {
91
- clean[key] = value;
92
- }
93
- // else: drop the unsafe value
94
- }
95
-
96
- return Object.keys(clean).length ? clean : null;
97
- };
98
-
99
- const sanitizeMark = (mark: Mark): Mark | null => {
100
- const type = mark?.type;
101
-
102
- if (!type || !ALLOWED_MARK_TYPES.has(type)) return null;
103
-
104
- const attrs = mark.attrs;
105
-
106
- if (type === 'link') {
107
- const href =
108
- typeof attrs?.href === 'string'
109
- ? validateAndSanitizeHref(attrs.href)
110
- : null;
111
-
112
- if (!href) return null; // drop links with unsafe/empty href
113
-
114
- const cleanAttrs: Record<string, unknown> = { href };
115
-
116
- if (attrs?.target != null) cleanAttrs.target = attrs.target;
117
-
118
- if (attrs?.rel != null) cleanAttrs.rel = attrs.rel;
119
-
120
- return { type, attrs: cleanAttrs };
121
- }
122
-
123
- if (type === 'textStyle') {
124
- const clean = attrs ? sanitizeTextStyleAttrs(attrs) : null;
125
-
126
- return clean ? { type, attrs: clean } : null;
127
- }
128
-
129
- if (type === 'highlight') {
130
- return isSafeCssValue(attrs?.color)
131
- ? { type, attrs: { color: attrs!.color } }
132
- : { type };
133
- }
134
-
135
- // Plain marks (bold/italic/underline/strike/code) carry no attrs.
136
- return { type };
137
- };
138
-
139
- const ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify']);
140
-
141
- const sanitizeNode = (node: JSONContent): JSONContent | null => {
142
- const type = node?.type;
143
-
144
- if (!type || !ALLOWED_NODE_TYPES.has(type)) return null;
145
-
146
- const clean: JSONContent = { type };
147
- const attrs: Record<string, unknown> = {};
148
-
149
- if (type === 'heading') {
150
- const level = Number((node.attrs as { level?: unknown })?.level);
151
-
152
- attrs.level = level >= 1 && level <= 6 ? level : 1;
153
- }
154
-
155
- if (type === 'heading' || type === 'paragraph') {
156
- const align = (node.attrs as { textAlign?: unknown })?.textAlign;
157
-
158
- if (typeof align === 'string' && ALIGN_VALUES.has(align)) {
159
- attrs.textAlign = align;
160
- }
161
- }
162
-
163
- if (Object.keys(attrs).length) clean.attrs = attrs;
164
-
165
- if (typeof node.text === 'string') clean.text = node.text;
166
-
167
- if (Array.isArray(node.marks)) {
168
- const marks = node.marks
169
- .map((m) => sanitizeMark(m as Mark))
170
- .filter(Boolean) as JSONContent['marks'];
171
-
172
- if (marks && marks.length) clean.marks = marks;
173
- }
174
-
175
- if (Array.isArray(node.content)) {
176
- const content = node.content
177
- .map(sanitizeNode)
178
- .filter(Boolean) as JSONContent[];
179
-
180
- if (content.length) clean.content = content;
181
- }
182
-
183
- return clean;
184
- };
185
-
186
- /**
187
- * Sanitise a Tiptap JSON document (returns a new clean doc). Returns a minimal
188
- * empty doc if the input isn't a usable document.
189
- */
190
- export const sanitizeRteJson = (doc: unknown): JSONContent => {
191
- const clean =
192
- doc && typeof doc === 'object' ? sanitizeNode(doc as JSONContent) : null;
193
-
194
- return clean && clean.type === 'doc' ? clean : { type: 'doc', content: [] };
195
- };
@@ -1,172 +0,0 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
29
-
30
- const escapeAttr = (value: string): string =>
31
- escapeHtml(value).replace(/"/g, '&quot;');
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);
@@ -1,55 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { validatorDict } from './field.validator';
3
-
4
- /**
5
- * BP-5857 — the `title` validator must preserve pre-JSON (staging) semantics.
6
- * An RTE field's value used to be inline HTML: an empty editor was "<p></p>",
7
- * a non-empty string, so the required check always PASSED for RTE fields — it
8
- * only failed for genuinely empty PLAIN inputs. RTE fields are now Tiptap JSON
9
- * docs, so any doc must likewise count as "present" (valid); a plain string is
10
- * still required to be non-empty.
11
- */
12
- describe('field.validator — title (nonEmptyString)', () => {
13
- const titleValidator = validatorDict.title;
14
-
15
- const jsonDoc = (text: string) => ({
16
- type: 'doc',
17
- content: [
18
- {
19
- type: 'paragraph',
20
- content: text
21
- ? [{ type: 'text', text, marks: [{ type: 'bold' }] }]
22
- : [],
23
- },
24
- ],
25
- });
26
-
27
- it('accepts a non-empty plain string', () => {
28
- expect(titleValidator.validate('title', 'Payments').isValid).toBe(true);
29
- });
30
-
31
- it('rejects an empty plain string (required still enforced for plain inputs)', () => {
32
- expect(titleValidator.validate('title', '').isValid).toBe(false);
33
- });
34
-
35
- it('accepts legacy empty RTE HTML "<p></p>" (non-empty string — staging parity)', () => {
36
- expect(titleValidator.validate('title', '<p></p>').isValid).toBe(true);
37
- });
38
-
39
- it('accepts a Tiptap JSON doc with visible text', () => {
40
- expect(titleValidator.validate('title', jsonDoc('Payments')).isValid).toBe(
41
- true,
42
- );
43
- });
44
-
45
- it('accepts an empty Tiptap JSON doc (RTE parity — empty editor is "present")', () => {
46
- expect(titleValidator.validate('title', jsonDoc('')).isValid).toBe(true);
47
- });
48
-
49
- it('rejects null/undefined without throwing', () => {
50
- expect(titleValidator.validate('title', null as any).isValid).toBe(false);
51
- expect(titleValidator.validate('title', undefined as any).isValid).toBe(
52
- false,
53
- );
54
- });
55
- });