@designcrowd/library.brand-page 6.0.25 → 6.0.26-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.
Files changed (176) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-CS3ct0wa.js → CreatedOnBrandCrowd-de-de-CKBqjDH3.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-CmBcnDu6.js → CreatedOnBrandCrowd-en-us-CRXiimvT.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BdTuJtqV.js → CreatedOnBrandCrowd-es-es-DCYdY6sj.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-Cx_H9X6l.js → CreatedOnBrandCrowd-fr-ca-Dk7LOhxE.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-Cx_H9X6l.js → CreatedOnBrandCrowd-fr-fr-Dk7LOhxE.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-BZZ9hjUi.js → CreatedOnBrandCrowd-pt-br-Cp4ufHJP.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-BZZ9hjUi.js → CreatedOnBrandCrowd-pt-pt-Cp4ufHJP.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-SbONDQN1.js → CreatedOnDcom-de-de-ZSLkWMwM.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-Beno1_gw.js → CreatedOnDcom-en-us-BgdIGfT3.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-BBE_zdRu.js → CreatedOnDcom-es-es-D0KemkCZ.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-C6pgDB5x.js → CreatedOnDcom-fr-ca-BuwNjNbC.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-FD1eqtg3.js → CreatedOnDcom-fr-fr-DCQcsFPf.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-BiSx3OwM.js → CreatedOnDcom-pt-br-DD-oLAS1.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-Dn4DmKGA.js → CreatedOnDcom-pt-pt-D8qLq2wW.js} +1 -1
  15. package/lib/src/brand-page/components/contact-form/ContactForm.mixin.js +5 -2
  16. package/lib/src/brand-page/models/index.d.ts +34 -26
  17. package/lib/src/brand-page/utils/migration/migration-20.d.ts +2 -0
  18. package/lib/src/brand-page/utils/migration/migration-20.js +66 -0
  19. package/lib/src/brand-page/utils/migration.util.js +11 -0
  20. package/lib/src/brand-page/utils/text.util.d.ts +1 -1
  21. package/lib/src/brand-page/utils/text.util.js +32 -0
  22. package/lib/src/brand-page/utils/vcard.util.js +4 -4
  23. package/lib/src/brand-page/utils/view-model.util.d.ts +1 -0
  24. package/lib/src/brand-page/utils/view-model.util.js +68 -20
  25. package/lib/src/brand-page/view-models.d.ts +3 -3
  26. package/lib/src/brand-page-maker/__fixtures__/complex-rte.fixture.d.ts +22 -0
  27. package/lib/src/brand-page-maker/__fixtures__/complex-rte.fixture.js +22 -0
  28. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +1 -1
  29. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +1 -1
  30. package/lib/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.d.ts +0 -1
  31. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +21 -9
  32. package/lib/src/brand-page-maker/composables/rich-text-editor/bubbleMenu.d.ts +2 -9
  33. package/lib/src/brand-page-maker/composables/rich-text-editor/bubbleMenu.js +6 -10
  34. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.d.ts +14 -0
  35. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.js +39 -0
  36. package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.d.ts +28 -0
  37. package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.js +90 -0
  38. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.d.ts +1 -1
  39. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +24 -103
  40. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.d.ts +8 -2
  41. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.js +20 -0
  42. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +7 -0
  43. package/lib/src/brand-page-maker/utils/index.d.ts +2 -0
  44. package/lib/src/brand-page-maker/utils/index.js +2 -0
  45. package/lib/src/brand-page-maker/utils/rich-text-editor.util.d.ts +28 -5
  46. package/lib/src/brand-page-maker/utils/rich-text-editor.util.js +48 -5
  47. package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +63 -0
  48. package/lib/src/brand-page-maker/utils/rte-render.util.js +95 -0
  49. package/lib/src/brand-page-maker/utils/rte-sanitize.util.d.ts +6 -0
  50. package/lib/src/brand-page-maker/utils/rte-sanitize.util.js +158 -0
  51. package/lib/src/brand-page-maker/validators/field.validator.js +10 -2
  52. package/lib/src/css/library.brand-page-brandCrowd.min.css +39 -39
  53. package/lib/src/css/library.brand-page-brandPage.min.css +39 -39
  54. package/lib/src/css/library.brand-page-designCom.min.css +39 -39
  55. package/lib/src/css/library.brand-page-preview.min.css +39 -39
  56. package/lib/src/index.d.ts +1 -0
  57. package/lib/src/index.mjs +143 -138
  58. package/lib/src/{lib-BVHFYSLL.js → lib-Db6j9fQZ.js} +56262 -52768
  59. package/lib/src/lib.js +2 -0
  60. package/lib/src/metadata-builder/ai-metadata-executor.d.ts +0 -1
  61. package/lib/src/metadata-builder/ai-metadata-executor.js +0 -19
  62. package/lib/src/metadata-builder/builders/section-builders/about-section-builder.js +2 -3
  63. package/lib/src/metadata-builder/builders/section-builders/banner-section-builder.js +2 -3
  64. package/lib/src/metadata-builder/builders/section-builders/booking-section-builder.js +2 -3
  65. package/lib/src/metadata-builder/builders/section-builders/contact-form-builder.js +3 -4
  66. package/lib/src/metadata-builder/builders/section-builders/images-section-builder.js +2 -3
  67. package/lib/src/metadata-builder/builders/section-builders/mailing-list-builder.js +1 -2
  68. package/lib/src/metadata-builder/builders/section-builders/mailing-list-section-builder.js +1 -2
  69. package/lib/src/metadata-builder/builders/section-builders/profile-section-builder.js +3 -4
  70. package/lib/src/metadata-builder/builders/section-builders/shapes-section-builder.js +2 -3
  71. package/lib/src/metadata-builder/builders/section-builders/testimonial-section-builder.js +1 -2
  72. package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +15 -11
  73. package/lib/src/metadata-builder/helpers/rte.helpers.d.ts +11 -13
  74. package/lib/src/metadata-builder/helpers/rte.helpers.js +14 -73
  75. package/lib/src/metadata-builder/metadata-executor.js +0 -4
  76. package/lib/src/metadata-builder/models.d.ts +0 -2
  77. package/lib/src/metadata-builder/models.js +0 -2
  78. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +0 -19
  79. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +0 -4
  80. package/lib/src/metadata-builder/sanitizers/rte-schema-check.d.ts +7 -8
  81. package/lib/src/metadata-builder/sanitizers/rte-schema-check.js +21 -52
  82. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.d.ts +2 -2
  83. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.js +14 -1
  84. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +1 -1
  85. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +1 -1
  86. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.js +12 -3
  87. package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +2 -2
  88. package/lib/src/shared/content-settings/components/radio-field/RadioField.mixin.d.ts +1 -1
  89. package/lib/src/shared/content-settings/components/slider-field/SliderField.mixin.d.ts +1 -1
  90. package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +2 -2
  91. package/lib/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.mixin.d.ts +3 -2
  92. package/lib/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.mixin.js +10 -3
  93. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +2 -2
  94. package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.d.ts +2 -1
  95. package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.js +4 -1
  96. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +2 -2
  97. package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +2 -2
  98. package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +2 -2
  99. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +1 -1
  100. package/lib/src/utils/minify-brand-page-metadata.js +4 -1
  101. package/package.json +17 -15
  102. package/src/brand-page/components/contact-form/ContactForm.mixin.ts +5 -2
  103. package/src/brand-page/components/text-wrapper/TextWrapper.vue +17 -6
  104. package/src/brand-page/models/index.ts +35 -26
  105. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +3 -3
  106. package/src/brand-page/utils/migration/migration-20.server.test.ts +125 -0
  107. package/src/brand-page/utils/migration/migration-20.test.ts +72 -0
  108. package/src/brand-page/utils/migration/migration-20.ts +87 -0
  109. package/src/brand-page/utils/migration.util.ts +12 -0
  110. package/src/brand-page/utils/style.util.test.ts +51 -0
  111. package/src/brand-page/utils/text.util.test.ts +47 -0
  112. package/src/brand-page/utils/text.util.ts +43 -4
  113. package/src/brand-page/utils/vcard.util.ts +6 -4
  114. package/src/brand-page/utils/view-model.pageid-rte.test.ts +104 -0
  115. package/src/brand-page/utils/view-model.util.ts +94 -26
  116. package/src/brand-page/view-models.ts +3 -2
  117. package/src/brand-page-maker/__fixtures__/complex-rte.fixture.ts +23 -0
  118. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +4 -3
  119. package/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.ts +0 -1
  120. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +28 -9
  121. package/src/brand-page-maker/composables/rich-text-editor/bubbleMenu.ts +6 -10
  122. package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.test.ts +52 -0
  123. package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.ts +42 -0
  124. package/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.ts +137 -0
  125. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +27 -111
  126. package/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.ts +23 -0
  127. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +8 -0
  128. package/src/brand-page-maker/utils/__snapshots__/rte-render-parity.test.ts.snap +202 -0
  129. package/src/brand-page-maker/utils/index.ts +2 -0
  130. package/src/brand-page-maker/utils/rich-text-editor.util.test.ts +37 -3
  131. package/src/brand-page-maker/utils/rich-text-editor.util.ts +66 -11
  132. package/src/brand-page-maker/utils/rte-features.test.ts +351 -0
  133. package/src/brand-page-maker/utils/rte-render-parity.test.ts +157 -0
  134. package/src/brand-page-maker/utils/rte-render.util.ts +165 -0
  135. package/src/brand-page-maker/utils/rte-sanitize-json.test.ts +158 -0
  136. package/src/brand-page-maker/utils/rte-sanitize.util.ts +195 -0
  137. package/src/brand-page-maker/validators/field.validator.test.ts +55 -0
  138. package/src/brand-page-maker/validators/field.validator.ts +10 -2
  139. package/src/lib.ts +9 -0
  140. package/src/metadata-builder/ai-metadata-executor.ts +0 -26
  141. package/src/metadata-builder/builders/section-builders/about-section-builder.ts +2 -3
  142. package/src/metadata-builder/builders/section-builders/banner-section-builder.ts +2 -3
  143. package/src/metadata-builder/builders/section-builders/booking-section-builder.ts +2 -5
  144. package/src/metadata-builder/builders/section-builders/contact-form-builder.ts +3 -4
  145. package/src/metadata-builder/builders/section-builders/images-section-builder.ts +2 -3
  146. package/src/metadata-builder/builders/section-builders/mailing-list-builder.ts +1 -2
  147. package/src/metadata-builder/builders/section-builders/mailing-list-section-builder.ts +1 -2
  148. package/src/metadata-builder/builders/section-builders/profile-section-builder.ts +3 -6
  149. package/src/metadata-builder/builders/section-builders/shapes-section-builder.ts +2 -3
  150. package/src/metadata-builder/builders/section-builders/testimonial-section-builder.ts +1 -2
  151. package/src/metadata-builder/builders/section-builders/text-section-builder.ts +19 -13
  152. package/src/metadata-builder/helpers/rte.helpers.ts +15 -101
  153. package/src/metadata-builder/metadata-executor.ts +0 -5
  154. package/src/metadata-builder/models.ts +0 -3
  155. package/src/metadata-builder/parsers/ai-toolcall-parser.ts +0 -27
  156. package/src/metadata-builder/sanitizers/rte-schema-check.ts +24 -53
  157. package/src/shared/content-settings/components/GenericTextInput.mixin.ts +17 -1
  158. package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.ts +16 -3
  159. package/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.mixin.ts +13 -4
  160. package/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.vue +6 -4
  161. package/src/shared/content-settings/components/text-image-field/TextImageField.vue +6 -3
  162. package/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.ts +6 -1
  163. package/src/shared/content-settings/components/text-items-field/TextItemsField.vue +1 -1
  164. package/src/utils/minify-brand-page-metadata.ts +4 -1
  165. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontSize.d.ts +0 -20
  166. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontSize.js +0 -44
  167. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/LineHeight.d.ts +0 -27
  168. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/LineHeight.js +0 -44
  169. package/lib/src/metadata-builder/builders/update-content-text-builder.d.ts +0 -17
  170. package/lib/src/metadata-builder/builders/update-content-text-builder.js +0 -103
  171. package/lib/src/metadata-builder/builders/update-content-text-style-builder.d.ts +0 -16
  172. package/lib/src/metadata-builder/builders/update-content-text-style-builder.js +0 -216
  173. package/src/brand-page-maker/composables/rich-text-editor/extensions/FontSize.ts +0 -73
  174. package/src/brand-page-maker/composables/rich-text-editor/extensions/LineHeight.ts +0 -85
  175. package/src/metadata-builder/builders/update-content-text-builder.ts +0 -148
  176. package/src/metadata-builder/builders/update-content-text-style-builder.ts +0 -352
@@ -0,0 +1,351 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Editor } from '@tiptap/vue-3';
3
+ import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
4
+ import { toRteJson, renderRteToHtml } from './rte-render.util';
5
+ import { sanitizeRteJson } from './rte-sanitize.util';
6
+
7
+ /**
8
+ * BP-5787 — feature-by-feature coverage of the whole RTE JSON pipeline.
9
+ *
10
+ * Each case runs a piece of authored HTML through the exact production path:
11
+ * HTML → toRteJson (headless editor parse) → sanitizeRteJson → renderRteToHtml
12
+ * (DOM-less serialiser)
13
+ * and asserts the feature survives end-to-end. One combined golden-master lives
14
+ * in rte-render-parity.test.ts; this file isolates every feature so a
15
+ * regression in any single one (a mark, a textStyle attr, a link, alignment, a
16
+ * block node) fails on its own line instead of inside one opaque snapshot.
17
+ */
18
+ const pipe = (html: string): string => {
19
+ const json = toRteJson(html);
20
+
21
+ return renderRteToHtml(sanitizeRteJson(json));
22
+ };
23
+
24
+ describe('RTE features — inline marks', () => {
25
+ it('bold → <strong>', () => {
26
+ expect(pipe('<p><strong>x</strong></p>')).toContain('<strong>x</strong>');
27
+ });
28
+ it('italic → <em>', () => {
29
+ expect(pipe('<p><em>x</em></p>')).toContain('<em>x</em>');
30
+ });
31
+ it('underline → <u>', () => {
32
+ expect(pipe('<p><u>x</u></p>')).toContain('<u>x</u>');
33
+ });
34
+ it('strikethrough → <s>', () => {
35
+ expect(pipe('<p><s>x</s></p>')).toContain('<s>x</s>');
36
+ });
37
+ it('inline code → <code>', () => {
38
+ expect(pipe('<p><code>x</code></p>')).toContain('<code>x</code>');
39
+ });
40
+ });
41
+
42
+ describe('RTE features — mark nesting (decoration must inherit font-size)', () => {
43
+ // BP-5787 regression: the textStyle <span> that carries font-size must render
44
+ // OUTSIDE <u>/<s>, so the decoration marks inherit the applied size. Otherwise
45
+ // text-decoration thickness AND vertical position are computed from the base
46
+ // paragraph font, giving thin, mis-centred lines on large text (v3 default
47
+ // priority regression vs v2). Guarded by TextStyle priority: 99.
48
+ it('font-size <span> wraps <u> (not the reverse)', () => {
49
+ const out = pipe('<p><span style="font-size: 40px"><u>x</u></span></p>');
50
+
51
+ expect(out).toContain('<span style="font-size: 40px"><u>x</u></span>');
52
+ expect(out).not.toMatch(/<u><span/);
53
+ });
54
+
55
+ it('font-size <span> wraps <s> (not the reverse)', () => {
56
+ const out = pipe('<p><span style="font-size: 40px"><s>x</s></span></p>');
57
+
58
+ expect(out).toContain('<span style="font-size: 40px"><s>x</s></span>');
59
+ expect(out).not.toMatch(/<s><span/);
60
+ });
61
+ });
62
+
63
+ describe('RTE features — textStyle attrs (colour/font/spacing)', () => {
64
+ it('rgb colour is preserved', () => {
65
+ expect(
66
+ pipe('<p><span style="color: rgb(255, 0, 0)">x</span></p>'),
67
+ ).toContain('color: rgb(255, 0, 0)');
68
+ });
69
+
70
+ it('hex colour is preserved verbatim (Tiptap v3 no longer normalises to rgb)', () => {
71
+ expect(pipe('<p><span style="color: #00ff00">x</span></p>')).toContain(
72
+ 'color: #00ff00',
73
+ );
74
+ });
75
+
76
+ it('theme token var(--…) colour survives verbatim (NOT flattened to rgb)', () => {
77
+ const out = pipe(
78
+ '<p><span style="color: var(--logo-text-color)">x</span></p>',
79
+ );
80
+
81
+ expect(out).toContain('color: var(--logo-text-color)');
82
+ expect(out).not.toContain('rgb(');
83
+ });
84
+
85
+ it('single-word font-family is preserved', () => {
86
+ expect(
87
+ pipe('<p><span style="font-family: Montserrat">x</span></p>'),
88
+ ).toContain('font-family: Montserrat');
89
+ });
90
+
91
+ it('multi-word font-family is preserved UNQUOTED (BP-5787 quote-strip)', () => {
92
+ const out = pipe('<p><span style="font-family: Open Sans">x</span></p>');
93
+
94
+ expect(out).toContain('font-family: Open Sans');
95
+ // must not carry the CSS quotes that the sanitiser would otherwise drop
96
+ expect(out).not.toContain('font-family: "Open Sans"');
97
+ expect(out).not.toContain('&quot;');
98
+ });
99
+
100
+ it('font-size / letter-spacing / line-height are preserved', () => {
101
+ expect(pipe('<p><span style="font-size: 2em">x</span></p>')).toContain(
102
+ 'font-size: 2em',
103
+ );
104
+ expect(
105
+ pipe('<p><span style="letter-spacing: 0.1em">x</span></p>'),
106
+ ).toContain('letter-spacing: 0.1em');
107
+ expect(pipe('<p><span style="line-height: 1.5">x</span></p>')).toContain(
108
+ 'line-height: 1.5',
109
+ );
110
+ });
111
+ });
112
+
113
+ describe('RTE features — background colour (highlight)', () => {
114
+ it('highlight → <mark> with data-color + background-color + color:inherit', () => {
115
+ const out = pipe(
116
+ '<p><mark data-color="#ffe08a" style="background-color: #ffe08a">x</mark></p>',
117
+ );
118
+
119
+ expect(out).toContain(
120
+ '<mark data-color="#ffe08a" style="background-color: #ffe08a; color: inherit">x</mark>',
121
+ );
122
+ });
123
+
124
+ it('theme token background colour survives verbatim', () => {
125
+ const out = pipe(
126
+ '<p><mark data-color="var(--logo-primary-color)" style="background-color: var(--logo-primary-color)">x</mark></p>',
127
+ );
128
+
129
+ expect(out).toContain('background-color: var(--logo-primary-color)');
130
+ });
131
+ });
132
+
133
+ describe('RTE features — hyperlinks', () => {
134
+ it('external link keeps href + target + rel', () => {
135
+ const out = pipe(
136
+ '<p><a target="_blank" rel="noopener noreferrer" href="https://example.com">x</a></p>',
137
+ );
138
+
139
+ expect(out).toContain('href="https://example.com"');
140
+ expect(out).toContain('target="_blank"');
141
+ expect(out).toContain('rel="noopener noreferrer"');
142
+ });
143
+
144
+ it('internal relative link keeps its href', () => {
145
+ expect(pipe('<p><a href="/about">x</a></p>')).toContain('href="/about"');
146
+ });
147
+
148
+ it('internal page reference (pageid:) survives the sanitiser', () => {
149
+ // pageid: hrefs are resolved to real routes later in the view-model
150
+ // transform; the sanitiser must not strip them.
151
+ expect(pipe('<p><a href="pageid:abc-123">x</a></p>')).toContain(
152
+ 'href="pageid:abc-123"',
153
+ );
154
+ });
155
+
156
+ it('javascript: link is dropped (mark removed, text kept)', () => {
157
+ const out = pipe('<p><a href="javascript:alert(1)">x</a></p>');
158
+
159
+ expect(out).not.toContain('<a');
160
+ expect(out).not.toContain('javascript:');
161
+ expect(out).toContain('x');
162
+ });
163
+
164
+ it('data: link is dropped', () => {
165
+ const out = pipe(
166
+ '<p><a href="data:text/html,<script>1</script>">x</a></p>',
167
+ );
168
+
169
+ expect(out).not.toContain('<a');
170
+ expect(out).not.toContain('data:');
171
+ });
172
+ });
173
+
174
+ describe('RTE features — text alignment', () => {
175
+ for (const align of ['center', 'right', 'justify', 'left'] as const) {
176
+ it(`paragraph text-align: ${align}`, () => {
177
+ expect(pipe(`<p style="text-align: ${align}">x</p>`)).toContain(
178
+ `<p style="text-align: ${align}">`,
179
+ );
180
+ });
181
+ }
182
+
183
+ it('heading carries its alignment', () => {
184
+ expect(pipe('<h2 style="text-align: center">x</h2>')).toContain(
185
+ '<h2 style="text-align: center">',
186
+ );
187
+ });
188
+ });
189
+
190
+ describe('RTE features — block nodes', () => {
191
+ it('headings h1–h6', () => {
192
+ for (const level of [1, 2, 3, 4, 5, 6]) {
193
+ expect(pipe(`<h${level}>x</h${level}>`)).toContain(`<h${level}`);
194
+ expect(pipe(`<h${level}>x</h${level}>`)).toContain(`</h${level}>`);
195
+ }
196
+ });
197
+
198
+ it('bullet list → <ul><li>', () => {
199
+ const out = pipe('<ul><li>a</li><li>b</li></ul>');
200
+
201
+ expect(out).toContain('<ul>');
202
+ expect(out).toContain('<li>');
203
+ expect(out).toContain('a');
204
+ expect(out).toContain('b');
205
+ });
206
+
207
+ it('ordered list → <ol><li>', () => {
208
+ expect(pipe('<ol><li>a</li></ol>')).toContain('<ol>');
209
+ });
210
+
211
+ it('blockquote → <blockquote>', () => {
212
+ expect(pipe('<blockquote><p>q</p></blockquote>')).toContain('<blockquote>');
213
+ });
214
+
215
+ it('code block → <pre><code>', () => {
216
+ expect(pipe('<pre><code>c</code></pre>')).toContain('<pre><code>');
217
+ });
218
+
219
+ it('hard break → <br/>', () => {
220
+ expect(pipe('<p>a<br>b</p>')).toContain('<br/>');
221
+ });
222
+
223
+ it('horizontal rule → <hr/>', () => {
224
+ expect(pipe('<p>a</p><hr><p>b</p>')).toContain('<hr/>');
225
+ });
226
+ });
227
+
228
+ describe('RTE features — combinations & escaping', () => {
229
+ it('nested marks (colour + bold + italic) all survive on the same run', () => {
230
+ const out = pipe(
231
+ '<p><span style="color: rgb(1, 2, 3)"><strong><em>x</em></strong></span></p>',
232
+ );
233
+
234
+ expect(out).toContain('color: rgb(1, 2, 3)');
235
+ expect(out).toContain('<strong>');
236
+ expect(out).toContain('<em>');
237
+ });
238
+
239
+ it('a styled hyperlink keeps both the colour and the link', () => {
240
+ const out = pipe(
241
+ '<p><a href="https://x.com"><span style="color: rgb(0, 151, 215)">x</span></a></p>',
242
+ );
243
+
244
+ expect(out).toContain('href="https://x.com"');
245
+ expect(out).toContain('color: rgb(0, 151, 215)');
246
+ });
247
+
248
+ it('HTML-special characters in text are escaped', () => {
249
+ const out = pipe('<p>a &lt;b&gt; &amp; "c"</p>');
250
+
251
+ expect(out).toContain('&lt;b&gt;');
252
+ expect(out).toContain('&amp;');
253
+ expect(out).not.toContain('<b>');
254
+ });
255
+
256
+ it('serialisation is idempotent (serialize(json) === serialize(reparse))', () => {
257
+ const html =
258
+ '<h2 style="text-align: center"><span style="color: var(--logo-text-color); font-family: Open Sans; font-size: 1.5em">Heading</span></h2>' +
259
+ '<p><strong>bold</strong> <em>italic</em> <u>u</u> <s>s</s> ' +
260
+ '<mark data-color="#ffe08a" style="background-color: #ffe08a">hl</mark> ' +
261
+ '<a target="_blank" rel="noopener noreferrer" href="https://x.com">link</a></p>';
262
+ const once = pipe(html);
263
+ const twice = renderRteToHtml(sanitizeRteJson(toRteJson(once)));
264
+
265
+ expect(twice).toEqual(once);
266
+ });
267
+ });
268
+
269
+ describe('RTE features — maker toolbar commands (emit path)', () => {
270
+ let editors: Editor[] = [];
271
+
272
+ afterEach(() => {
273
+ editors.forEach((e) => e.destroy());
274
+ editors = [];
275
+ });
276
+
277
+ // Apply a real editor command to a full selection (as the toolbar does),
278
+ // then run the emitted JSON through sanitise + serialise.
279
+ const applied = (fn: (e: Editor) => void): string => {
280
+ const editor = new Editor({
281
+ extensions: getRteExtensions({ restrictStarterKit: true }),
282
+ });
283
+
284
+ editors.push(editor);
285
+ editor.commands.setContent('<p>styled text</p>');
286
+ editor.commands.selectAll();
287
+ fn(editor);
288
+
289
+ return renderRteToHtml(sanitizeRteJson(editor.getJSON()));
290
+ };
291
+
292
+ it('setColor', () => {
293
+ expect(applied((e) => e.commands.setColor('rgb(255, 0, 0)'))).toContain(
294
+ 'color: rgb(255, 0, 0)',
295
+ );
296
+ });
297
+
298
+ it('setHighlight (background colour)', () => {
299
+ const out = applied((e) => e.commands.setHighlight({ color: '#ffe08a' }));
300
+
301
+ expect(out).toContain('background-color: #ffe08a');
302
+ expect(out).toContain('<mark');
303
+ });
304
+
305
+ it('toggleBold / toggleItalic / toggleUnderline / toggleStrike', () => {
306
+ expect(applied((e) => e.commands.toggleBold())).toContain('<strong>');
307
+ expect(applied((e) => e.commands.toggleItalic())).toContain('<em>');
308
+ expect(applied((e) => e.commands.toggleUnderline())).toContain('<u>');
309
+ expect(applied((e) => e.commands.toggleStrike())).toContain('<s>');
310
+ });
311
+
312
+ it('setFontFamily (multi-word, unquoted)', () => {
313
+ const out = applied((e) =>
314
+ (e.commands as { setFontFamily: (f: string) => boolean }).setFontFamily(
315
+ 'Open Sans',
316
+ ),
317
+ );
318
+
319
+ expect(out).toContain('font-family: Open Sans');
320
+ expect(out).not.toContain('&quot;');
321
+ });
322
+
323
+ it('setFontSize / setLetterSpacing', () => {
324
+ expect(
325
+ applied((e) =>
326
+ (e.commands as { setFontSize: (v: string) => boolean }).setFontSize(
327
+ '2em',
328
+ ),
329
+ ),
330
+ ).toContain('font-size: 2em');
331
+ expect(
332
+ applied((e) =>
333
+ (
334
+ e.commands as { setLetterSpacing: (v: string) => boolean }
335
+ ).setLetterSpacing('0.1em'),
336
+ ),
337
+ ).toContain('letter-spacing: 0.1em');
338
+ });
339
+
340
+ it('setTextAlign', () => {
341
+ expect(applied((e) => e.commands.setTextAlign('center'))).toContain(
342
+ 'text-align: center',
343
+ );
344
+ });
345
+
346
+ it('setLink keeps the href through the pipeline', () => {
347
+ expect(
348
+ applied((e) => e.commands.setLink({ href: 'https://x.com' })),
349
+ ).toContain('href="https://x.com"');
350
+ });
351
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import type { Editor } from '@tiptap/vue-3';
3
+ import { useExternalRichTextEditor } from '../composables/rich-text-editor/useRichTextEditor';
4
+ import {
5
+ renderRteToHtml,
6
+ toRteJson,
7
+ getRteJsonPlainText,
8
+ } from './rte-render.util';
9
+ import {
10
+ COMPLEX_RTE_HTML,
11
+ THEME_TOKEN_REF,
12
+ } from '../__fixtures__/complex-rte.fixture';
13
+
14
+ /**
15
+ * BP-5787 — golden-master render-parity baseline.
16
+ *
17
+ * Captured on the CURRENT (HTML) pipeline. These committed snapshots are the
18
+ * frozen reference for the HTML→JSON migration:
19
+ *
20
+ * - `editor-normalized-html` is what the editor renders the fixture to today.
21
+ * After the migration, `htmlFromJson(getJSON())` MUST equal this snapshot —
22
+ * i.e. the JSON pipeline reproduces identical rendered output (no drift).
23
+ *
24
+ * - `editor-json` freezes the JSON shape that BECOMES the stored form. It is
25
+ * expected to change exactly once, intentionally, when storage flips to JSON
26
+ * (see "expected vs forbidden drift" in docs/plans/BP-5787-...html).
27
+ *
28
+ * The headless editor uses the SAME extension set as the live editor
29
+ * (useExternalRichTextEditor), so it is a faithful stand-in for the renderer.
30
+ */
31
+ describe('BP-5787 RTE render parity (golden master)', () => {
32
+ let editor: Editor;
33
+
34
+ const makeEditor = (): Editor => {
35
+ editor = useExternalRichTextEditor().externalEditor;
36
+
37
+ return editor;
38
+ };
39
+
40
+ afterEach(() => {
41
+ editor?.destroy();
42
+ });
43
+
44
+ it('T1 — editor renders the complex fixture to the frozen HTML baseline', () => {
45
+ const ed = makeEditor();
46
+
47
+ ed.commands.setContent(COMPLEX_RTE_HTML);
48
+
49
+ // The golden master. The JSON pipeline must reproduce this exactly.
50
+ expect(ed.getHTML()).toMatchSnapshot('editor-normalized-html');
51
+ });
52
+
53
+ it('T1 — round-trip is idempotent (html → json → html === json → html)', () => {
54
+ const ed = makeEditor();
55
+
56
+ ed.commands.setContent(COMPLEX_RTE_HTML);
57
+ const pass1 = ed.getHTML();
58
+ const json = ed.getJSON();
59
+
60
+ // Re-seed from the JSON the migration will store, render again.
61
+ ed.commands.setContent(json);
62
+ const pass2 = ed.getHTML();
63
+
64
+ expect(pass2).toEqual(pass1);
65
+ });
66
+
67
+ it('T6 — freezes the JSON shape that becomes the stored form', () => {
68
+ const ed = makeEditor();
69
+
70
+ ed.commands.setContent(COMPLEX_RTE_HTML);
71
+
72
+ expect(ed.getJSON()).toMatchSnapshot('editor-json');
73
+ });
74
+
75
+ it('T2 — theme token ref survives the round-trip (NOT flattened to rgb)', () => {
76
+ const ed = makeEditor();
77
+
78
+ ed.commands.setContent(COMPLEX_RTE_HTML);
79
+
80
+ // Today the editor preserves var(--…) in marks; the migration must keep it.
81
+ expect(ed.getHTML()).toContain(THEME_TOKEN_REF);
82
+ expect(JSON.stringify(ed.getJSON())).toContain(THEME_TOKEN_REF);
83
+ });
84
+
85
+ it('T1 — render-from-JSON (DOM-less renderer) produces equivalent HTML', () => {
86
+ // renderRteToHtml serialises JSON → HTML via @tiptap/static-renderer from the
87
+ // extension schema (no editor/DOM, so it works on the server). Output is
88
+ // semantically identical to the editor's — the Playwright visual golden-master
89
+ // is the authority on pixel parity; here we freeze the renderer output and
90
+ // assert structure.
91
+ const viaJson = renderRteToHtml(toRteJson(COMPLEX_RTE_HTML));
92
+
93
+ expect(viaJson).toMatchSnapshot('serializer-html');
94
+
95
+ // marks/nodes preserved
96
+ expect(viaJson).toContain('<strong>bold</strong>');
97
+ expect(viaJson).toContain('<em>italic</em>');
98
+ expect(viaJson).toContain('<u>underline</u>');
99
+ expect(viaJson).toContain('<s>strike</s>');
100
+ expect(viaJson).toContain('font-family: Montserrat');
101
+ expect(viaJson).toContain('font-size: 1.5em');
102
+ // theme token ref preserved (the crux of BP-5787)
103
+ expect(viaJson).toContain('color: var(--logo-text-color)');
104
+ // links + alignment
105
+ expect(viaJson).toContain('href="/about"');
106
+ expect(viaJson).toContain('href="https://example.com"');
107
+ expect(viaJson).toContain('text-align: center');
108
+ expect(viaJson).toContain('text-align: right');
109
+ });
110
+
111
+ it('T4 — legacy HTML strings render unchanged (back-compat passthrough)', () => {
112
+ expect(renderRteToHtml(COMPLEX_RTE_HTML)).toEqual(COMPLEX_RTE_HTML);
113
+ });
114
+
115
+ it('T8 — getRteJsonPlainText extracts visible text only (for fraud/translate)', () => {
116
+ const json = toRteJson(COMPLEX_RTE_HTML)!;
117
+ const text = getRteJsonPlainText(json);
118
+
119
+ expect(text).toContain('Themed heading');
120
+ expect(text).toContain('bold');
121
+ expect(text).toContain('external link');
122
+ // no markup or token refs leak into the extracted text
123
+ expect(text).not.toContain('<');
124
+ expect(text).not.toContain('var(--');
125
+ expect(text).not.toContain('font-size');
126
+ });
127
+
128
+ it('T1 — preserves every mark/node in the fixture', () => {
129
+ const ed = makeEditor();
130
+
131
+ ed.commands.setContent(COMPLEX_RTE_HTML);
132
+ const html = ed.getHTML();
133
+
134
+ // bold / italic / underline / strike
135
+ expect(html).toContain('<strong>');
136
+ expect(html).toContain('<em>');
137
+ expect(html).toContain('<u>');
138
+ expect(html).toContain('<s>');
139
+ // textStyle attrs
140
+ expect(html).toContain('font-family: Montserrat');
141
+ expect(html).toContain('font-size: 1.5em');
142
+ expect(html).toContain('letter-spacing: 0.05em');
143
+ expect(html).toContain('line-height: 1.4');
144
+ // highlight: data-color keeps the hex; the rendered style is normalised to rgb()
145
+ expect(html).toContain('data-color="#ffe08a"');
146
+ expect(html).toContain('background-color: rgb(255, 224, 138)');
147
+ // literal hex colour is normalised to rgb() by the editor...
148
+ expect(html).toContain('color: rgb(255, 0, 0)');
149
+ // ...but the THEME TOKEN ref is preserved verbatim (the crux of BP-5787)
150
+ expect(html).toContain('color: var(--logo-text-color)');
151
+ expect(html).toContain('text-align: center');
152
+ expect(html).toContain('text-align: right');
153
+ // links
154
+ expect(html).toContain('href="/about"');
155
+ expect(html).toContain('href="https://example.com"');
156
+ });
157
+ });
@@ -0,0 +1,165 @@
1
+ import { getSchema } from '@tiptap/core';
2
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
3
+ import type { JSONContent } from '@tiptap/vue-3';
4
+ import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string';
5
+ import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
6
+ import { isTiptapDocument, getRteJsonPlainText } from './rich-text-editor.util';
7
+
8
+ export { isTiptapDocument, getRteJsonPlainText };
9
+
10
+ /**
11
+ * BP-5787 — the boundary plumbing for storing RTE as Tiptap JSON.
12
+ *
13
+ * An RTE field is either a legacy inline-HTML string (pre-migration, or a
14
+ * not-yet-migrated published site) or a Tiptap JSON document (post-migration).
15
+ * These helpers let every consumer treat the two uniformly:
16
+ *
17
+ * - isTiptapDocument — the canonical discriminator (object with type 'doc').
18
+ * - renderRteToHtml — render either form to final HTML for display.
19
+ * - toRteJson — normalise either form to JSON for storage.
20
+ *
21
+ * The schema-driven core of both directions is Tiptap/ProseMirror, fed by the
22
+ * single-source-of-truth extension set (getRteExtensions) — so the mapping lives
23
+ * in the extensions' schema, not a hand-maintained copy that drifts:
24
+ *
25
+ * - renderRteToHtml uses @tiptap/static-renderer (renderToHTMLString). It is
26
+ * DOM-less, so it runs on the server (SSR/publish/preview), not just the
27
+ * browser, and needs no Editor instance.
28
+ * - toRteJson turns an HTML string into a DOM node with an INJECTED parser and
29
+ * feeds it to ProseMirror's schema-driven DOMParser. The HTML→DOM step needs
30
+ * a DOM parser; rather than read the global `window.DOMParser` (as Tiptap's
31
+ * `generateJSON` does — unusable on a pure-node server and fragile across SSR
32
+ * execution contexts), the parser is supplied by the caller: the native
33
+ * `DOMParser` in the browser, a jsdom `DOMParser` *instance* on the server
34
+ * (see resolveHtmlParser). No global is ever mutated, so the conversion is
35
+ * fully isomorphic regardless of how the SSR host bundles/executes this code.
36
+ */
37
+ export type RichTextValue = string | JSONContent;
38
+
39
+ // One extension set shared by both directions (render + parse). Schema knowledge
40
+ // lives here, once — Tiptap/extension upgrades propagate to both automatically.
41
+ const RTE_EXTENSIONS = getRteExtensions({ includeLinkRel: true });
42
+
43
+ // ProseMirror schema derived once from the shared extension set. This is the
44
+ // schema-driven heart of HTML→JSON (no drift); only the HTML→DOM step is injected.
45
+ const RTE_SCHEMA = getSchema(RTE_EXTENSIONS);
46
+
47
+ /**
48
+ * A `DOMParser`-compatible object: turns an HTML string into a Document whose
49
+ * `body` is a DOM node ProseMirror can parse. The browser's native `DOMParser`
50
+ * and jsdom's `DOMParser` both satisfy this, so the DOM dependency is injected
51
+ * rather than read from a global.
52
+ */
53
+ export interface HtmlParser {
54
+ parseFromString(html: string, type: 'text/html'): Document;
55
+ }
56
+
57
+ /**
58
+ * Render an RTE value to final HTML. Legacy HTML strings pass through untouched
59
+ * (they are already HTML); Tiptap JSON is serialised by @tiptap/static-renderer
60
+ * from the extension schema — no editor, no DOM — so it works on the server
61
+ * (SSR/publish), not just the browser.
62
+ */
63
+ export const renderRteToHtml = (
64
+ value: RichTextValue | null | undefined,
65
+ ): string => {
66
+ if (value == null || value === '') return '';
67
+
68
+ if (typeof value === 'string') return value;
69
+
70
+ return renderToHTMLString({ content: value, extensions: RTE_EXTENSIONS });
71
+ };
72
+
73
+ // Tiptap's elementFromString strips whitespace-only text nodes (pretty-print
74
+ // indentation between tags) before parsing; replicate it so injected-parser
75
+ // output stays identical to the previous generateJSON-based conversion.
76
+ const stripIndentationTextNodes = (node: Node): Node => {
77
+ const children = node.childNodes;
78
+
79
+ for (let i = children.length - 1; i >= 0; i -= 1) {
80
+ const child = children[i];
81
+
82
+ if (
83
+ child.nodeType === 3 &&
84
+ child.nodeValue &&
85
+ /^(\n\s\s|\n)$/.test(child.nodeValue)
86
+ ) {
87
+ node.removeChild(child);
88
+ } else if (child.nodeType === 1) {
89
+ stripIndentationTextNodes(child);
90
+ }
91
+ }
92
+
93
+ return node;
94
+ };
95
+
96
+ const htmlToRteJson = (html: string, parser: HtmlParser): JSONContent => {
97
+ const body = parser.parseFromString(`<body>${html}</body>`, 'text/html').body;
98
+ const dom = stripIndentationTextNodes(body);
99
+
100
+ return ProseMirrorDOMParser.fromSchema(RTE_SCHEMA).parse(dom).toJSON();
101
+ };
102
+
103
+ let serverParserPromise: Promise<HtmlParser> | null = null;
104
+
105
+ /**
106
+ * Resolve an HtmlParser for the current runtime: the native `DOMParser` in the
107
+ * browser (and jsdom-based test envs), or a jsdom `DOMParser` INSTANCE on a
108
+ * pure-node server — created and returned directly, never assigned to a global,
109
+ * so it is immune to SSR execution-context differences. The jsdom specifier is
110
+ * assembled at runtime so no bundler follows it into a browser build. Memoised
111
+ * on the server (JSDOM construction is relatively expensive).
112
+ */
113
+ export const resolveHtmlParser = async (): Promise<HtmlParser> => {
114
+ if (typeof DOMParser !== 'undefined') return new DOMParser();
115
+
116
+ if (!serverParserPromise) {
117
+ const jsdomModuleId = ['js', 'dom'].join('');
118
+
119
+ serverParserPromise = import(/* @vite-ignore */ jsdomModuleId)
120
+ .then((mod) => {
121
+ const { JSDOM } = mod as {
122
+ JSDOM: new (html?: string) => {
123
+ window: { DOMParser: new () => HtmlParser };
124
+ };
125
+ };
126
+ const { DOMParser: JsdomDOMParser } = new JSDOM('').window;
127
+
128
+ return new JsdomDOMParser();
129
+ })
130
+ .catch((error) => {
131
+ // Don't cache the failure — let a later call retry the import.
132
+ serverParserPromise = null;
133
+
134
+ throw error;
135
+ });
136
+ }
137
+
138
+ return serverParserPromise;
139
+ };
140
+
141
+ const getDefaultParser = (): HtmlParser => {
142
+ if (typeof DOMParser !== 'undefined') return new DOMParser();
143
+
144
+ throw new Error(
145
+ 'toRteJson: no global DOMParser in this environment. On the server, pass an ' +
146
+ 'explicit parser as the second argument (see resolveHtmlParser).',
147
+ );
148
+ };
149
+
150
+ /**
151
+ * Normalise an RTE value to a Tiptap JSON document. HTML strings are parsed with
152
+ * `parser` (defaults to the browser's native DOMParser); JSON documents pass
153
+ * through unchanged. Returns undefined for empty/blank input so callers can omit
154
+ * empty fields.
155
+ */
156
+ export const toRteJson = (
157
+ value: RichTextValue | null | undefined,
158
+ parser?: HtmlParser,
159
+ ): JSONContent | undefined => {
160
+ if (value == null || value === '') return undefined;
161
+
162
+ if (isTiptapDocument(value)) return value;
163
+
164
+ return htmlToRteJson(value, parser ?? getDefaultParser());
165
+ };