@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
@@ -235,9 +235,11 @@ const addProfileInfo = (
235
235
  profile: ProfileViewModel,
236
236
  vcardLines: string[],
237
237
  ): void => {
238
- const name = sanitizeForVCard(profile.name || '');
239
- const title = sanitizeForVCard(profile.title || '');
240
- const description = sanitizeForVCard(profile.description || '');
238
+ const name = sanitizeForVCard(getPlainTextFromRTE(profile.name) || '');
239
+ const title = sanitizeForVCard(getPlainTextFromRTE(profile.title) || '');
240
+ const description = sanitizeForVCard(
241
+ getPlainTextFromRTE(profile.description) || '',
242
+ );
241
243
 
242
244
  if (name) {
243
245
  const nameParts = name.trim().split(/\s+/);
@@ -316,7 +318,7 @@ export const saveContact = (brandPageData: {
316
318
  }): void => {
317
319
  const vcard = generateVCard(brandPageData);
318
320
  const profile = findProfileContent(brandPageData);
319
- const filename = generateVCardFilename(profile?.name);
321
+ const filename = generateVCardFilename(getPlainTextFromRTE(profile?.name));
320
322
 
321
323
  downloadVCard(vcard, filename);
322
324
  };
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolvePageIdsInRteJson } from './view-model.util';
3
+ import type { NavViewModel } from '../view-models';
4
+
5
+ /**
6
+ * BP-5847 — internal RTE hyperlinks are stored as `href="pageid:<id>"`. On the
7
+ * published site these must resolve to the page's relative path. The string
8
+ * (legacy HTML) path already did this; once RTE moved to Tiptap JSON the link
9
+ * marks were skipped, so links rendered as raw `pageid:` and didn't navigate.
10
+ */
11
+ describe('resolvePageIdsInRteJson (BP-5847)', () => {
12
+ const nav: NavViewModel = {
13
+ navItems: [
14
+ {
15
+ pageId: 'home-id',
16
+ slug: 'home',
17
+ displayName: 'Home',
18
+ routeName: 'home',
19
+ routeParams: { slug: 'mysite' },
20
+ isRoot: true,
21
+ isHidden: false,
22
+ relativePath: '',
23
+ nestedNavItems: [],
24
+ },
25
+ {
26
+ pageId: 'about-id',
27
+ slug: 'about',
28
+ displayName: 'About',
29
+ routeName: 'about',
30
+ routeParams: { slug: 'mysite' },
31
+ isRoot: false,
32
+ isHidden: false,
33
+ relativePath: 'about',
34
+ nestedNavItems: [],
35
+ },
36
+ ],
37
+ };
38
+
39
+ const options = { brandPageSlug: 'mysite' } as any;
40
+
41
+ const docWithLink = (href: string) => ({
42
+ type: 'doc',
43
+ content: [
44
+ {
45
+ type: 'paragraph',
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: 'link',
50
+ marks: [{ type: 'link', attrs: { href, target: '_blank' } }],
51
+ },
52
+ ],
53
+ },
54
+ ],
55
+ });
56
+
57
+ const linkHref = (doc: any) => doc.content[0].content[0].marks[0].attrs.href;
58
+
59
+ it('resolves a pageid link to a sub-page relative path', () => {
60
+ const out = resolvePageIdsInRteJson(
61
+ docWithLink('pageid:about-id'),
62
+ nav,
63
+ options,
64
+ );
65
+
66
+ expect(linkHref(out)).toBe('/mysite/about');
67
+ });
68
+
69
+ it('resolves a root pageid link to the site root', () => {
70
+ const out = resolvePageIdsInRteJson(
71
+ docWithLink('pageid:home-id'),
72
+ nav,
73
+ options,
74
+ );
75
+
76
+ expect(linkHref(out)).toBe('/mysite');
77
+ });
78
+
79
+ it('falls back to root when the pageid cannot be matched', () => {
80
+ const out = resolvePageIdsInRteJson(
81
+ docWithLink('pageid:missing-id'),
82
+ nav,
83
+ options,
84
+ );
85
+
86
+ expect(linkHref(out)).toBe('/mysite');
87
+ });
88
+
89
+ it('leaves external links untouched and returns the same reference', () => {
90
+ const doc = docWithLink('https://example.com');
91
+ const out = resolvePageIdsInRteJson(doc, nav, options);
92
+
93
+ expect(linkHref(out)).toBe('https://example.com');
94
+ expect(out).toBe(doc);
95
+ });
96
+
97
+ it('does not mutate the source doc (returns a new object) when resolving', () => {
98
+ const doc = docWithLink('pageid:about-id');
99
+ const out = resolvePageIdsInRteJson(doc, nav, options);
100
+
101
+ expect(out).not.toBe(doc);
102
+ expect(linkHref(doc)).toBe('pageid:about-id');
103
+ });
104
+ });
@@ -119,7 +119,7 @@ import {
119
119
  getRouteNameByPageIndex,
120
120
  getRouteParamsByPage,
121
121
  } from './route.util';
122
- import { hasRTEContent } from '../../brand-page-maker/utils';
122
+ import { hasRTEContent, isTiptapDocument } from '../../brand-page-maker/utils';
123
123
  import {
124
124
  getPageIdFromHref,
125
125
  isHrefPageId,
@@ -1519,6 +1519,86 @@ const mapPageIdReferencesToRelativePaths = (
1519
1519
  });
1520
1520
  };
1521
1521
 
1522
+ // Resolve a `pageid:<id>` internal-link href to the published relative path.
1523
+ // Returns the resolved href, or null when `href` is not an internal pageid link
1524
+ // (external/absolute links are left untouched). Falls back to the site root when
1525
+ // the pageid can't be matched to a nav route.
1526
+ const resolvePageIdHref = (
1527
+ href: string,
1528
+ nav: NavViewModel,
1529
+ options: ViewModelMappingOptions,
1530
+ ): string | null => {
1531
+ // Only process internal links (no protocol, doesn't start with /)
1532
+ if (!href || href.match(/^(https?:\/\/|\/)/) || !isHrefPageId(href)) {
1533
+ return null;
1534
+ }
1535
+
1536
+ const pageId = getPageIdFromHref(href);
1537
+ const route = findNavItemByPageId(nav.navItems, pageId);
1538
+ const brandPageSlugRoute = options.brandPageSlug
1539
+ ? `/${options.brandPageSlug}`
1540
+ : '/';
1541
+
1542
+ if (route) {
1543
+ if (route.isRoot) {
1544
+ return brandPageSlugRoute;
1545
+ }
1546
+
1547
+ return `${brandPageSlugRoute}${brandPageSlugRoute === '/' ? '' : '/'}${route.relativePath}`;
1548
+ }
1549
+
1550
+ // fallback to root if pageid cannot be resolved.
1551
+ return brandPageSlugRoute;
1552
+ };
1553
+
1554
+ // BP-5847: walk a Tiptap JSON doc and rewrite every link mark whose href is a
1555
+ // `pageid:` reference into its published relative path. Returns a NEW doc when
1556
+ // something changed (so the shared model doc is never mutated), else the same
1557
+ // reference.
1558
+ export const resolvePageIdsInRteJson = (
1559
+ doc: unknown,
1560
+ nav: NavViewModel,
1561
+ options: ViewModelMappingOptions,
1562
+ ): unknown => {
1563
+ let changed = false;
1564
+
1565
+ const walk = (node: any): any => {
1566
+ if (!node || typeof node !== 'object') {
1567
+ return node;
1568
+ }
1569
+
1570
+ let next = node;
1571
+
1572
+ if (Array.isArray(node.marks)) {
1573
+ const marks = node.marks.map((mark: any) => {
1574
+ if (mark?.type === 'link' && typeof mark.attrs?.href === 'string') {
1575
+ const resolved = resolvePageIdHref(mark.attrs.href, nav, options);
1576
+
1577
+ if (resolved !== null) {
1578
+ changed = true;
1579
+
1580
+ return { ...mark, attrs: { ...mark.attrs, href: resolved } };
1581
+ }
1582
+ }
1583
+
1584
+ return mark;
1585
+ });
1586
+
1587
+ next = { ...next, marks };
1588
+ }
1589
+
1590
+ if (Array.isArray(node.content)) {
1591
+ next = { ...next, content: node.content.map(walk) };
1592
+ }
1593
+
1594
+ return next;
1595
+ };
1596
+
1597
+ const result = walk(doc);
1598
+
1599
+ return changed ? result : doc;
1600
+ };
1601
+
1522
1602
  const dynamicallyMapPageIdInHyperlinksToRelativePaths = (
1523
1603
  content: any,
1524
1604
  nav: NavViewModel,
@@ -1531,7 +1611,15 @@ const dynamicallyMapPageIdInHyperlinksToRelativePaths = (
1531
1611
  return;
1532
1612
  }
1533
1613
 
1534
- if (typeof value === 'object' && (key === 'items' || key === 'textItems')) {
1614
+ if (isTiptapDocument(value)) {
1615
+ // BP-5847: RTE fields may now be Tiptap JSON. Resolve pageid link marks
1616
+ // here too, or internal links render as raw `pageid:` and don't navigate.
1617
+ (content as unknown as Record<string, unknown>)[key] =
1618
+ resolvePageIdsInRteJson(value, nav, options);
1619
+ } else if (
1620
+ typeof value === 'object' &&
1621
+ (key === 'items' || key === 'textItems')
1622
+ ) {
1535
1623
  if (Array.isArray(value)) {
1536
1624
  value.forEach((item) => {
1537
1625
  dynamicallyMapPageIdInHyperlinksToRelativePaths(item, nav, options);
@@ -1543,31 +1631,11 @@ const dynamicallyMapPageIdInHyperlinksToRelativePaths = (
1543
1631
  (content as unknown as Record<string, unknown>)[key] = value.replace(
1544
1632
  /<a\s+([^>]*?)href="([^"]*?)"([^>]*?)>/gi,
1545
1633
  (match, existingAttrsStart, href, existingAttrsEnd) => {
1546
- // Only process internal links (no protocol, doesn't start with /)
1547
- if (href && !href.match(/^(https?:\/\/|\/)/) && isHrefPageId(href)) {
1548
- const pageId = getPageIdFromHref(href);
1549
-
1550
- const route = findNavItemByPageId(nav.navItems, pageId);
1551
-
1552
- const brandPageSlugRoute = options.brandPageSlug
1553
- ? `/${options.brandPageSlug}`
1554
- : '/';
1555
-
1556
- if (route) {
1557
- let href = `${brandPageSlugRoute}`;
1558
-
1559
- if (!route.isRoot) {
1560
- href = `${brandPageSlugRoute}${brandPageSlugRoute === '/' ? '' : '/'}${route.relativePath}`;
1561
- }
1562
-
1563
- return `<a ${existingAttrsStart}href="${href}"${existingAttrsEnd}>`;
1564
- }
1565
-
1566
- // fallback to root if pageid cannot be resolved.
1567
- return `<a ${existingAttrsStart}href="${brandPageSlugRoute}"${existingAttrsEnd}>`;
1568
- }
1634
+ const resolved = resolvePageIdHref(href, nav, options);
1569
1635
 
1570
- return match;
1636
+ return resolved === null
1637
+ ? match
1638
+ : `<a ${existingAttrsStart}href="${resolved}"${existingAttrsEnd}>`;
1571
1639
  },
1572
1640
  );
1573
1641
  }
@@ -41,6 +41,7 @@ import type {
41
41
  BackgroundShape,
42
42
  PersonalisationRole,
43
43
  SaveContactOptions,
44
+ RichTextValue,
44
45
  } from './models';
45
46
  import type { ImagesSectionContentDisplayStyle } from './constants';
46
47
 
@@ -196,8 +197,8 @@ export interface TextImageSectionViewModel {
196
197
  id: string;
197
198
  type: 'images-section';
198
199
  role: ContentType;
199
- title: string;
200
- description: string;
200
+ title: RichTextValue;
201
+ description: RichTextValue;
201
202
  displayStyle: ImagesSectionContentDisplayStyle;
202
203
  aspectRatio: AspectRatio;
203
204
  areImagesFullWidth: boolean;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * BP-5787 — golden-master fixture for the RTE HTML→JSON migration.
3
+ *
4
+ * A single rich-text value that exercises every mark/node the editor can emit,
5
+ * so one snapshot guards the whole serialisation surface. Captured as a baseline
6
+ * on the current HTML pipeline; after the migration the JSON pipeline must
7
+ * reproduce byte-identical rendered output (see rte-render-parity.test.ts).
8
+ *
9
+ * Coverage:
10
+ * - block nodes: heading (h2), paragraph, text-align (center / right)
11
+ * - inline marks: bold, italic, underline, strike
12
+ * - textStyle attrs: fontFamily, fontSize (em), letterSpacing (em), lineHeight (unitless)
13
+ * - colour: theme TOKEN ref (var(--logo-text-color)) AND a literal hex (#FF0000)
14
+ * - highlight (background colour via <mark>)
15
+ * - links: internal (target=_self) and external (target=_blank rel=noopener)
16
+ */
17
+ export const COMPLEX_RTE_HTML = `<h2 style="text-align: center"><span style="color: var(--logo-text-color)">Themed heading</span></h2><p><strong>bold</strong> <em>italic</em> <u>underline</u> <s>strike</s> <span style="font-family: Montserrat; font-size: 1.5em; letter-spacing: 0.05em; line-height: 1.4">styled span</span> <mark data-color="#ffe08a" style="background-color: #ffe08a">highlight</mark> <span style="color: #FF0000">literal hex</span> <a target="_self" href="/about">internal link</a> <a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com">external link</a></p><p style="text-align: right">second paragraph, right-aligned</p>`;
18
+
19
+ /**
20
+ * The theme-token CSS variable that MUST survive the HTML→JSON round-trip
21
+ * (BP-5787 stores token refs instead of flattening them to rgb()).
22
+ */
23
+ export const THEME_TOKEN_REF = 'var(--logo-text-color)';
@@ -384,7 +384,7 @@
384
384
  <div class="tw-relative" :class="{ 'editor-compact': isCompact }">
385
385
  <BubbleMenu
386
386
  :editor="editor"
387
- :tippy-options="tippyOptions"
387
+ :options="bubbleMenuOptions"
388
388
  :update-delay="0"
389
389
  :should-show="shouldShow"
390
390
  class="tw-w-fit"
@@ -453,7 +453,8 @@ import {
453
453
  ButtonGroup,
454
454
  TextInput,
455
455
  } from '@designcrowd/fe-shared-lib';
456
- import { EditorContent, BubbleMenu } from '@tiptap/vue-3';
456
+ import { EditorContent } from '@tiptap/vue-3';
457
+ import { BubbleMenu } from '@tiptap/vue-3/menus';
457
458
  import { useRichTextEditor } from '../../composables/rich-text-editor/useRichTextEditor';
458
459
  import FontPicker from '@shared/font-family-selector/FontPicker.vue';
459
460
  import ColorField from '@shared/content-settings/components/color-field/ColorField.vue';
@@ -951,7 +952,7 @@ const {
951
952
  hyperlinkValue,
952
953
  setHyperlinkUrlValue,
953
954
  shouldShow,
954
- tippyOptions,
955
+ bubbleMenuOptions,
955
956
  handleAdminColorChange,
956
957
  } = useRichTextEditor(
957
958
  model,
@@ -33,7 +33,6 @@ export function useAiMetadataBuilder(logger: Logger) {
33
33
  model: BrandPageModel,
34
34
  aiAssistantToolCall: AiAssistantToolCall[],
35
35
  options: {
36
- maxCharacters?: number;
37
36
  /**
38
37
  * Invoked by the UndoBuilder to resolve the pre-prompt snapshot.
39
38
  * Required when the response includes an `undo` tool call.
@@ -70,19 +70,20 @@ export function useAiWebsiteBuilderChat(): UseAiChatResult {
70
70
  const checkpointStore = useAiCheckpointStore();
71
71
  const eventTracker = useEventTracker();
72
72
 
73
+ const createWelcomeMessage = (): TextThreadMessage =>
74
+ ({
75
+ id: '1',
76
+ type: 'text',
77
+ role: 'agent',
78
+ text: 'What changes would you like to make to your website?',
79
+ timestamp: new Date(),
80
+ }) as TextThreadMessage;
81
+
73
82
  const hasStoredMessages = aiThreadStore.messages.length > 0;
74
83
  const threadItems = ref<ThreadMessage[]>(
75
84
  aiThreadStore.messages.length > 0
76
85
  ? aiThreadStore.messages
77
- : [
78
- {
79
- id: '1',
80
- type: 'text',
81
- role: 'agent',
82
- text: 'What changes would you like to make to your website?',
83
- timestamp: new Date(),
84
- } as TextThreadMessage,
85
- ],
86
+ : [createWelcomeMessage()],
86
87
  );
87
88
 
88
89
  // ---------------------------------------------------------------------------
@@ -148,6 +149,24 @@ export function useAiWebsiteBuilderChat(): UseAiChatResult {
148
149
  const generatedText = ref<string | null>(null);
149
150
  const generatedStyle = ref<BrandPageCssStyle | null>(null);
150
151
 
152
+ // External reset (e.g. template switch, BP-5896): tear down the build WS
153
+ // client so a late push can't repopulate the fresh thread, then re-seed
154
+ // the welcome bubble. Keyed off `resetToken` — NOT `messages` — to avoid
155
+ // looping with the deep `threadItems` → `messages` watch above.
156
+ watch(
157
+ () => aiThreadStore.resetToken,
158
+ () => {
159
+ if (activeBuildWsClient) {
160
+ activeBuildWsClient.disconnect();
161
+ activeBuildWsClient = null;
162
+ }
163
+
164
+ currentTypingMessageId.value = null;
165
+ isStopped.value = false;
166
+ threadItems.value = [createWelcomeMessage()];
167
+ },
168
+ );
169
+
151
170
  const SERVICE_UNAVAILABLE_MESSAGE =
152
171
  'This service is not available right now. Please try again later.';
153
172
 
@@ -23,20 +23,16 @@ export const useBubbleMenu = (
23
23
  return false;
24
24
  };
25
25
 
26
- const tippyOptions = {
27
- duration: 100,
26
+ // v3 BubbleMenu uses Floating UI (was tippy.js in v2). Only placement + offset
27
+ // carry over; the rest of the old tippy config (trigger/interactive/hideOnClick/
28
+ // zIndex) is either the v3 default or handled in CSS.
29
+ const bubbleMenuOptions = {
28
30
  placement: 'bottom' as const,
29
- interactive: true,
30
- appendTo: 'parent' as const,
31
- hideOnClick: false,
32
- arrow: false,
33
- offset: [0, 10] as [number, number],
34
- trigger: 'manual' as const,
35
- zIndex: 10,
31
+ offset: 10,
36
32
  };
37
33
 
38
34
  return {
39
35
  shouldShow,
40
- tippyOptions,
36
+ bubbleMenuOptions,
41
37
  };
42
38
  };
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Editor } from '@tiptap/vue-3';
3
+ import { getRteExtensions } from '../getRteExtensions';
4
+ import { sanitizeRteJson } from '@brand-page-maker/utils/rte-sanitize.util';
5
+
6
+ /**
7
+ * BP-5787 — multi-word font families ("Open Sans") are quoted in CSS, so
8
+ * re-parsing HTML (pasting RTE-styled text between editors) previously yielded
9
+ * a value WITH quote chars, which the native sanitiser then dropped — losing
10
+ * the font. The custom FontFamily extension strips the quotes on parse.
11
+ */
12
+ describe('FontFamily — quote-stripping parse (BP-5787)', () => {
13
+ let editors: Editor[] = [];
14
+ const mk = () => {
15
+ const e = new Editor({
16
+ extensions: getRteExtensions({ restrictStarterKit: true }),
17
+ });
18
+
19
+ editors.push(e);
20
+
21
+ return e;
22
+ };
23
+
24
+ afterEach(() => {
25
+ editors.forEach((e) => e.destroy());
26
+ editors = [];
27
+ });
28
+
29
+ const fontFamilyOf = (json: any) =>
30
+ json.content?.[0]?.content?.[0]?.marks?.find(
31
+ (m: any) => m.type === 'textStyle',
32
+ )?.attrs?.fontFamily;
33
+
34
+ for (const font of ['Montserrat', 'Open Sans', 'Playwrite AU']) {
35
+ it(`"${font}" survives an HTML re-parse + sanitise unquoted`, () => {
36
+ const src = mk();
37
+
38
+ src.commands.setContent('<p>Hello world</p>');
39
+ src.commands.selectAll();
40
+ src.commands.setFontFamily(font);
41
+
42
+ // Re-parse the serialised HTML — mirrors an RTE→RTE paste.
43
+ const tgt = mk();
44
+
45
+ tgt.commands.setContent(src.getHTML());
46
+
47
+ expect(fontFamilyOf(tgt.getJSON())).toBe(font);
48
+ // and the sanitiser must keep it (isSafeCssValue rejects quote chars).
49
+ expect(fontFamilyOf(sanitizeRteJson(tgt.getJSON()))).toBe(font);
50
+ });
51
+ }
52
+ });
@@ -0,0 +1,42 @@
1
+ import TiptapFontFamily from '@tiptap/extension-font-family';
2
+
3
+ /**
4
+ * BP-5787 — font-family with quote-stripping parse.
5
+ *
6
+ * The stock extension stores whatever `element.style.fontFamily` returns. For
7
+ * multi-word families (e.g. "Open Sans", "Playwrite AU") the browser quotes the
8
+ * value, so parsing HTML — pasting RTE-styled text between editors, or the
9
+ * HTML→JSON migration — yields a value that still contains the quote chars
10
+ * (`"Open Sans"`). That value then (a) no longer matches the font list and,
11
+ * worse, (b) is dropped by the native RTE sanitiser (`sanitizeRteJson`), whose
12
+ * `isSafeCssValue` rejects any value containing `"` `'` `;` — so the font is
13
+ * silently lost. Mirror the FontSize/LetterSpacing extensions and strip the
14
+ * quotes on parse so the stored value is always the bare family name.
15
+ */
16
+ export const FontFamily = TiptapFontFamily.extend({
17
+ addGlobalAttributes() {
18
+ return [
19
+ {
20
+ types: this.options.types,
21
+ attributes: {
22
+ fontFamily: {
23
+ default: null,
24
+ // Strip quotes only — keep '' for an absent value so the stored
25
+ // JSON shape matches the other textStyle attrs (FontSize etc.).
26
+ parseHTML: (element) =>
27
+ element.style.fontFamily?.replace(/['"]+/g, ''),
28
+ renderHTML: (attributes) => {
29
+ if (!attributes.fontFamily) {
30
+ return {};
31
+ }
32
+
33
+ return {
34
+ style: `font-family: ${attributes.fontFamily}`,
35
+ };
36
+ },
37
+ },
38
+ },
39
+ },
40
+ ];
41
+ },
42
+ });
@@ -0,0 +1,137 @@
1
+ import type { Extensions } from '@tiptap/vue-3';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import Underline from '@tiptap/extension-underline';
4
+ import { TextStyle, FontSize, LineHeight } from '@tiptap/extension-text-style';
5
+ import TextAlign from '@tiptap/extension-text-align';
6
+ import ColorExtension from '@tiptap/extension-color';
7
+ import Highlight from '@tiptap/extension-highlight';
8
+ import Placeholder from '@tiptap/extension-placeholder';
9
+ import Link from '@tiptap/extension-link';
10
+ import { FontFamily } from './extensions/FontFamily';
11
+ import { LetterSpacing } from './extensions/LetterSpacing';
12
+ import { MaxLength } from './extensions/MaxLength';
13
+ import { AutoLink } from './extensions/AutoLink';
14
+ import { isHrefPageId } from './hyperlink';
15
+
16
+ /**
17
+ * Single source of truth for the Tiptap extension set (BP-5787).
18
+ *
19
+ * The live editor, the headless serialiser (useExternalRichTextEditor), and
20
+ * the upcoming render-from-JSON path must all use the SAME extensions, or
21
+ * rendered output drifts from what the user authored. This factory removes the
22
+ * previous duplication while preserving each consumer's exact configuration.
23
+ *
24
+ * Known, intentional differences are parameterised:
25
+ * - `restrictStarterKit` — the live editor disables list/hr/code nodes.
26
+ * - `includeLinkRel` — the headless/render path emits rel="noopener noreferrer";
27
+ * the live editing surface does not.
28
+ * - `editing` — only the live editor carries the Placeholder + MaxLength plugins
29
+ * (neither affects serialised output; they are editing-time only).
30
+ */
31
+ export interface RteExtensionOptions {
32
+ /** Disable StarterKit nodes the editor doesn't use (lists, hr, code). */
33
+ restrictStarterKit?: boolean;
34
+ /** Emit rel="noopener noreferrer" on link HTMLAttributes (render path). */
35
+ includeLinkRel?: boolean;
36
+ /** When present, add the live-editing-only plugins (Placeholder, MaxLength). */
37
+ editing?: {
38
+ placeholder?: string;
39
+ maxLength?: number;
40
+ };
41
+ }
42
+
43
+ export const getRteExtensions = (
44
+ options: RteExtensionOptions = {},
45
+ ): Extensions => {
46
+ const {
47
+ restrictStarterKit = false,
48
+ includeLinkRel = false,
49
+ editing,
50
+ } = options;
51
+
52
+ const extensions: Extensions = [
53
+ // v3 StarterKit bundles Link + Underline; we register both separately (with
54
+ // custom Link config), so disable the bundled copies to avoid duplicate
55
+ // extension errors.
56
+ // trailingNode: false — v3 StarterKit adds a TrailingNode plugin (not in v2)
57
+ // that appends an empty paragraph after any non-paragraph trailing block.
58
+ // Disabled to keep editor/stored-content behaviour identical to v2 (BP-5787
59
+ // is backward-compat-first); revisit as a standalone UX change if wanted.
60
+ restrictStarterKit
61
+ ? StarterKit.configure({
62
+ link: false,
63
+ underline: false,
64
+ trailingNode: false,
65
+ bulletList: false,
66
+ orderedList: false,
67
+ listItem: false,
68
+ horizontalRule: false,
69
+ codeBlock: false,
70
+ code: false,
71
+ })
72
+ : StarterKit.configure({
73
+ link: false,
74
+ underline: false,
75
+ trailingNode: false,
76
+ }),
77
+ Underline,
78
+ // priority: 99 — render the textStyle <span> (which carries font-size) as the
79
+ // OUTERMOST inline mark, so decoration marks (<u>/<s>) nest INSIDE it and
80
+ // inherit the applied font-size. v3's extension-text-style defaults to
81
+ // priority 101, which flips this: <u>/<s> end up outside the font-size span
82
+ // and inherit only the base paragraph size. Since text-decoration thickness
83
+ // AND vertical position are computed from the decorating element's own font,
84
+ // that produced thin, mis-centred underline/strike on large text — a
85
+ // regression vs v2's serializer, which emitted the font-size span outermost
86
+ // (BP-5787, backward-compat-first).
87
+ TextStyle.extend({ priority: 99 }).configure({
88
+ mergeNestedSpanStyles: true,
89
+ }),
90
+ TextAlign.configure({
91
+ types: ['heading', 'paragraph'],
92
+ }),
93
+ FontFamily.configure({
94
+ types: ['textStyle'],
95
+ }),
96
+ // FontSize + LineHeight are now shipped natively by @tiptap/extension-text-style.
97
+ FontSize.configure({ types: ['textStyle'] }),
98
+ LetterSpacing,
99
+ LineHeight.configure({ types: ['textStyle'] }),
100
+ ColorExtension.configure({
101
+ types: ['textStyle'],
102
+ }),
103
+ Highlight.configure({
104
+ multicolor: true,
105
+ }),
106
+ ];
107
+
108
+ // Live-editing-only plugins sit between Highlight and Link (original order).
109
+ if (editing) {
110
+ extensions.push(
111
+ Placeholder.configure({ placeholder: editing.placeholder }),
112
+ MaxLength.configure({ maxLength: editing.maxLength }),
113
+ );
114
+ }
115
+
116
+ extensions.push(
117
+ Link.configure({
118
+ openOnClick: false,
119
+ defaultProtocol: 'https',
120
+ autolink: true,
121
+ HTMLAttributes: (attrs) => {
122
+ const href = attrs.href;
123
+ const isExternal =
124
+ href && (href.startsWith('http://') || href.startsWith('https://'));
125
+
126
+ return {
127
+ ...(includeLinkRel && { rel: 'noopener noreferrer' }),
128
+ target: isExternal ? '_blank' : null,
129
+ };
130
+ },
131
+ isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || isHrefPageId(url),
132
+ }),
133
+ AutoLink,
134
+ );
135
+
136
+ return extensions;
137
+ };