@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,158 @@
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
+ });
@@ -0,0 +1,195 @@
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
+ };
@@ -0,0 +1,55 @@
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
+ });
@@ -1,6 +1,6 @@
1
1
  import { isURL, isEmail, isTel } from '@utils/validator.util';
2
2
  import type { ItemFieldType, Validator } from '@brand-page-maker/models';
3
- import { YOUTUBE_URL_RE } from '@brand-page-maker/utils';
3
+ import { YOUTUBE_URL_RE, isTiptapDocument } from '@brand-page-maker/utils';
4
4
  import { validatorTr } from '@src/composables/useTranslate';
5
5
 
6
6
  const addressValueValidator: Validator = {
@@ -76,7 +76,15 @@ export const dummyValidator: Validator = {
76
76
 
77
77
  const nonEmptyStringValidator: Validator = {
78
78
  validate: (name, value: any) => {
79
- const isValid = typeof value === 'string' && value.length > 0;
79
+ // BP-5787/BP-5857: match legacy (pre-JSON) validation semantics.
80
+ // An RTE field's stored value used to be inline HTML — an empty editor was
81
+ // "<p></p>", a NON-empty string, so the required check always passed for RTE
82
+ // fields (it only ever failed for genuinely empty PLAIN inputs). Now RTE
83
+ // fields are Tiptap JSON docs; treat any doc as "present" so behaviour
84
+ // stays identical. A plain string is still required to be non-empty.
85
+ const isValid = isTiptapDocument(value)
86
+ ? true
87
+ : typeof value === 'string' && value.length > 0;
80
88
 
81
89
  return {
82
90
  isValid,
package/src/lib.ts CHANGED
@@ -85,6 +85,15 @@ export {
85
85
  recalculateHeaderLogoSizes,
86
86
  } from './brand-page/utils/logo.util';
87
87
  export { sanitizeRTEHtml } from './brand-page-maker/utils';
88
+ // BP-5787: RTE JSON storage boundary helpers (render/normalise/sanitise/extract).
89
+ export {
90
+ renderRteToHtml,
91
+ toRteJson,
92
+ isTiptapDocument,
93
+ sanitizeRteJson,
94
+ getRteJsonPlainText,
95
+ type RichTextValue,
96
+ } from './brand-page-maker/utils';
88
97
  export { getPageSlugFromPageTitle } from './brand-page-maker/utils/slug.util';
89
98
 
90
99
  // AI-pipeline preview (longclaw per-turn snapshots): web-renderer's
@@ -1,7 +1,5 @@
1
1
  import { type BrandPageModel } from '../brand-page/models';
2
2
  import type { EventTracker } from '../brand-page-maker/composables/useEventTracker';
3
- import { buildUpdateTextRequest } from './builders/update-content-text-builder';
4
- import { buildUpdateTextStyleRequest } from './builders/update-content-text-style-builder';
5
3
  import type { UndoHandler } from './builders/undo-builder';
6
4
  import type { Logger } from './logger';
7
5
  import { MetadataExecutor } from './metadata-executor';
@@ -15,8 +13,6 @@ import {
15
13
  AI_ASSISTANT_TOOL_NAMES,
16
14
  SECTION_TOOL_MAP,
17
15
  type ParsedToolCall,
18
- type UpdateStyleToolArguments,
19
- type UpdateTextToolArguments,
20
16
  type DeleteSectionArguments,
21
17
  type MoveSectionArguments,
22
18
  type AddPageArguments,
@@ -37,7 +33,6 @@ export const executeToolCallsAsync = async (
37
33
  toolcalls: ParsedToolCall[],
38
34
  options: {
39
35
  logger?: Logger;
40
- maxCharacters?: number;
41
36
  /**
42
37
  * Callback invoked by the UndoBuilder at execution time to resolve the
43
38
  * pre-prompt snapshot to restore to. The library is agnostic about where
@@ -62,27 +57,6 @@ export const executeToolCallsAsync = async (
62
57
  const requests: MetadataBuilderRequest[] = toolcalls
63
58
  .map((toolcall): MetadataBuilderRequest | null => {
64
59
  switch (toolcall.function) {
65
- case AI_ASSISTANT_TOOL_NAMES.UPDATE_TEXT: {
66
- const arg = toolcall.arguments as unknown as UpdateTextToolArguments;
67
-
68
- return buildUpdateTextRequest(toolcall.toolId, {
69
- contentId: arg.contentId,
70
- fieldName: arg.fieldName,
71
- newText: arg.metadata.text,
72
- maxCharacters: options.maxCharacters,
73
- });
74
- }
75
-
76
- case AI_ASSISTANT_TOOL_NAMES.UPDATE_STYLE: {
77
- const arg = toolcall.arguments as unknown as UpdateStyleToolArguments;
78
-
79
- return buildUpdateTextStyleRequest(toolcall.toolId, {
80
- contentId: arg.contentId,
81
- fieldName: arg.fieldName,
82
- newStyles: arg.metadata.style,
83
- });
84
- }
85
-
86
60
  case AI_ASSISTANT_TOOL_NAMES.DELETE_SECTION: {
87
61
  const arg = toolcall.arguments as unknown as DeleteSectionArguments;
88
62
 
@@ -3,7 +3,6 @@ import type {
3
3
  BrandPageModel,
4
4
  TextStyleDeclaration,
5
5
  } from '../../../brand-page/models';
6
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
7
6
  import type { BuilderResult } from '../../models';
8
7
  import {
9
8
  isTiptapDocument,
@@ -54,7 +53,7 @@ export class AboutSectionBuilder extends BaseSectionBuilder<AboutSectionContent>
54
53
 
55
54
  if (aboutArgs.title !== undefined) {
56
55
  if (isTiptapDocument(aboutArgs.title)) {
57
- resolved.content.title = tiptapJsonToHtml(aboutArgs.title);
56
+ resolved.content.title = aboutArgs.title;
58
57
  resolved.content.titleStyle = resolveGlobalPresetIntoStyle(
59
58
  resolved.content.titleStyle,
60
59
  textStyleOptions,
@@ -66,7 +65,7 @@ export class AboutSectionBuilder extends BaseSectionBuilder<AboutSectionContent>
66
65
 
67
66
  if (aboutArgs.paragraph !== undefined) {
68
67
  if (isTiptapDocument(aboutArgs.paragraph)) {
69
- resolved.content.paragraph = tiptapJsonToHtml(aboutArgs.paragraph);
68
+ resolved.content.paragraph = aboutArgs.paragraph;
70
69
  resolved.content.paragraphStyle = resolveGlobalPresetIntoStyle(
71
70
  resolved.content.paragraphStyle,
72
71
  textStyleOptions,
@@ -3,7 +3,6 @@ import type {
3
3
  BrandPageModel,
4
4
  TextStyleDeclaration,
5
5
  } from '../../../brand-page/models';
6
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
7
6
  import type { BuilderResult } from '../../models';
8
7
  import {
9
8
  isTiptapDocument,
@@ -91,7 +90,7 @@ export class BannerSectionBuilder extends BaseSectionBuilder<BannerSectionConten
91
90
 
92
91
  if (bannerArgs.title !== undefined) {
93
92
  if (isTiptapDocument(bannerArgs.title)) {
94
- resolved.content.title = tiptapJsonToHtml(bannerArgs.title);
93
+ resolved.content.title = bannerArgs.title;
95
94
  resolved.content.titleStyle = resolveGlobalPresetIntoStyle(
96
95
  resolved.content.titleStyle,
97
96
  textStyleOptions,
@@ -103,7 +102,7 @@ export class BannerSectionBuilder extends BaseSectionBuilder<BannerSectionConten
103
102
 
104
103
  if (bannerArgs.description !== undefined) {
105
104
  if (isTiptapDocument(bannerArgs.description)) {
106
- resolved.content.description = tiptapJsonToHtml(bannerArgs.description);
105
+ resolved.content.description = bannerArgs.description;
107
106
  resolved.content.descriptionStyle = resolveGlobalPresetIntoStyle(
108
107
  resolved.content.descriptionStyle,
109
108
  textStyleOptions,
@@ -3,7 +3,6 @@ import type {
3
3
  BrandPageModel,
4
4
  TextStyleDeclaration,
5
5
  } from '../../../brand-page/models';
6
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
7
6
  import type { BuilderResult } from '../../models';
8
7
  import {
9
8
  isTiptapDocument,
@@ -51,7 +50,7 @@ export class BookingSectionBuilder extends BaseSectionBuilder<BookingSectionCont
51
50
 
52
51
  if (bookingArgs.title !== undefined) {
53
52
  if (isTiptapDocument(bookingArgs.title)) {
54
- resolved.content.title = tiptapJsonToHtml(bookingArgs.title);
53
+ resolved.content.title = bookingArgs.title;
55
54
  resolved.content.titleStyle = resolveGlobalPresetIntoStyle(
56
55
  resolved.content.titleStyle,
57
56
  textStyleOptions,
@@ -63,9 +62,7 @@ export class BookingSectionBuilder extends BaseSectionBuilder<BookingSectionCont
63
62
 
64
63
  if (bookingArgs.description !== undefined) {
65
64
  if (isTiptapDocument(bookingArgs.description)) {
66
- resolved.content.description = tiptapJsonToHtml(
67
- bookingArgs.description,
68
- );
65
+ resolved.content.description = bookingArgs.description;
69
66
  resolved.content.descriptionStyle = resolveGlobalPresetIntoStyle(
70
67
  resolved.content.descriptionStyle,
71
68
  textStyleOptions,
@@ -3,7 +3,6 @@ import type {
3
3
  BrandPageModel,
4
4
  TextStyleDeclaration,
5
5
  } from '../../../brand-page/models';
6
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
7
6
  import {
8
7
  isTiptapDocument,
9
8
  type ContactFormSectionArguments,
@@ -50,7 +49,7 @@ export class ContactFormBuilder extends BaseSectionBuilder<ContactFormContent> {
50
49
 
51
50
  if (args.title !== undefined) {
52
51
  if (isTiptapDocument(args.title)) {
53
- content.title = tiptapJsonToHtml(args.title);
52
+ content.title = args.title;
54
53
  content.titleStyle = resolveGlobalPresetIntoStyle(
55
54
  content.titleStyle,
56
55
  textStyleOptions,
@@ -62,7 +61,7 @@ export class ContactFormBuilder extends BaseSectionBuilder<ContactFormContent> {
62
61
 
63
62
  if (args.description !== undefined) {
64
63
  if (isTiptapDocument(args.description)) {
65
- content.description = tiptapJsonToHtml(args.description);
64
+ content.description = args.description;
66
65
  content.descriptionStyle = resolveGlobalPresetIntoStyle(
67
66
  content.descriptionStyle,
68
67
  textStyleOptions,
@@ -74,7 +73,7 @@ export class ContactFormBuilder extends BaseSectionBuilder<ContactFormContent> {
74
73
 
75
74
  if (args.success_message !== undefined) {
76
75
  if (isTiptapDocument(args.success_message)) {
77
- content.successMessage = tiptapJsonToHtml(args.success_message);
76
+ content.successMessage = args.success_message;
78
77
  content.successMessageStyle = resolveGlobalPresetIntoStyle(
79
78
  content.successMessageStyle,
80
79
  textStyleOptions,
@@ -3,7 +3,6 @@ import type {
3
3
  BrandPageModel,
4
4
  TextStyleDeclaration,
5
5
  } from '../../../brand-page/models';
6
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
7
6
  import type { BuilderResult } from '../../models';
8
7
  import {
9
8
  isTiptapDocument,
@@ -142,7 +141,7 @@ export class ImagesSectionBuilder extends BaseSectionBuilder<ImagesSectionConten
142
141
 
143
142
  if (args.title !== undefined) {
144
143
  if (isTiptapDocument(args.title)) {
145
- content.title = tiptapJsonToHtml(args.title);
144
+ content.title = args.title;
146
145
  content.titleStyle = resolveGlobalPresetIntoStyle(
147
146
  content.titleStyle,
148
147
  textStyleOptions,
@@ -154,7 +153,7 @@ export class ImagesSectionBuilder extends BaseSectionBuilder<ImagesSectionConten
154
153
 
155
154
  if (args.description !== undefined) {
156
155
  if (isTiptapDocument(args.description)) {
157
- content.description = tiptapJsonToHtml(args.description);
156
+ content.description = args.description;
158
157
  content.descriptionStyle = resolveGlobalPresetIntoStyle(
159
158
  content.descriptionStyle,
160
159
  textStyleOptions,
@@ -4,7 +4,6 @@ import type {
4
4
  MailingListContent,
5
5
  TextStyleDeclaration,
6
6
  } from '../../../brand-page/models';
7
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
8
7
  import {
9
8
  isTiptapDocument,
10
9
  type MailingListSectionArguments,
@@ -50,7 +49,7 @@ export class MailingListBuilder extends BaseSectionBuilder<MailingListContent> {
50
49
 
51
50
  if (mailingListArgs.title !== undefined) {
52
51
  if (isTiptapDocument(mailingListArgs.title)) {
53
- content.title = tiptapJsonToHtml(mailingListArgs.title);
52
+ content.title = mailingListArgs.title;
54
53
  content.titleStyle = resolveGlobalPresetIntoStyle(
55
54
  content.titleStyle,
56
55
  textStyleOptions,
@@ -3,7 +3,6 @@ import type {
3
3
  BrandPageModel,
4
4
  TextStyleDeclaration,
5
5
  } from '../../../brand-page/models';
6
- import { tiptapJsonToHtml } from '../../helpers/rte.helpers';
7
6
  import type { BuilderResult } from '../../models';
8
7
  import {
9
8
  isTiptapDocument,
@@ -48,7 +47,7 @@ export class MailingListSectionBuilder extends BaseSectionBuilder<MailingListCon
48
47
 
49
48
  if (mailingListArgs.title !== undefined) {
50
49
  if (isTiptapDocument(mailingListArgs.title)) {
51
- resolved.content.title = tiptapJsonToHtml(mailingListArgs.title);
50
+ resolved.content.title = mailingListArgs.title;
52
51
  resolved.content.titleStyle = resolveGlobalPresetIntoStyle(
53
52
  resolved.content.titleStyle,
54
53
  textStyleOptions,