@designcrowd/library.brand-page 6.0.25-json-prereq-2 → 6.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/{CreatedOnBrandCrowd-de-de-MkdpvvLq.js → CreatedOnBrandCrowd-de-de-CS3ct0wa.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-0K8Va8jv.js → CreatedOnBrandCrowd-en-us-CmBcnDu6.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-BXBXK85u.js → CreatedOnBrandCrowd-es-es-BdTuJtqV.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-wbrMlisb.js → CreatedOnBrandCrowd-fr-ca-Cx_H9X6l.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-wbrMlisb.js → CreatedOnBrandCrowd-fr-fr-Cx_H9X6l.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-BHBKnYHd.js → CreatedOnBrandCrowd-pt-br-BZZ9hjUi.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-BHBKnYHd.js → CreatedOnBrandCrowd-pt-pt-BZZ9hjUi.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-BeG5A3Bb.js → CreatedOnDcom-de-de-SbONDQN1.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-DVCNhdLS.js → CreatedOnDcom-en-us-Beno1_gw.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-Ba8bq852.js → CreatedOnDcom-es-es-BBE_zdRu.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-DuNhf0PH.js → CreatedOnDcom-fr-ca-C6pgDB5x.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-CfofYZSL.js → CreatedOnDcom-fr-fr-FD1eqtg3.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-BMCBFLhc.js → CreatedOnDcom-pt-br-BiSx3OwM.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-CQS6Ft_9.js → CreatedOnDcom-pt-pt-Dn4DmKGA.js} +1 -1
- package/lib/src/brand-page/components/contact-form/ContactForm.mixin.js +2 -5
- package/lib/src/brand-page/models/index.d.ts +26 -34
- package/lib/src/brand-page/utils/migration.util.js +0 -11
- package/lib/src/brand-page/utils/text.util.d.ts +1 -1
- package/lib/src/brand-page/utils/text.util.js +0 -32
- package/lib/src/brand-page/utils/vcard.util.js +4 -4
- package/lib/src/brand-page/utils/view-model.util.d.ts +0 -1
- package/lib/src/brand-page/utils/view-model.util.js +20 -68
- package/lib/src/brand-page/view-models.d.ts +3 -3
- package/lib/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.d.ts +1 -0
- package/lib/src/brand-page-maker/composables/rich-text-editor/bubbleMenu.d.ts +9 -2
- package/lib/src/brand-page-maker/composables/rich-text-editor/bubbleMenu.js +10 -6
- package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontSize.d.ts +20 -0
- package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontSize.js +44 -0
- package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/LineHeight.d.ts +27 -0
- package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/LineHeight.js +44 -0
- package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.d.ts +1 -1
- package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +103 -24
- package/lib/src/brand-page-maker/utils/index.d.ts +0 -2
- package/lib/src/brand-page-maker/utils/index.js +0 -2
- package/lib/src/brand-page-maker/utils/rich-text-editor.util.d.ts +5 -28
- package/lib/src/brand-page-maker/utils/rich-text-editor.util.js +5 -48
- package/lib/src/brand-page-maker/validators/field.validator.js +2 -10
- package/lib/src/css/library.brand-page-brandCrowd.min.css +39 -39
- package/lib/src/css/library.brand-page-brandPage.min.css +39 -39
- package/lib/src/css/library.brand-page-designCom.min.css +39 -39
- package/lib/src/css/library.brand-page-preview.min.css +39 -39
- package/lib/src/index.d.ts +0 -1
- package/lib/src/index.mjs +138 -143
- package/lib/src/{lib-QPQAbiCG.js → lib-BVHFYSLL.js} +60149 -63629
- package/lib/src/lib.js +0 -2
- package/lib/src/metadata-builder/ai-metadata-executor.d.ts +1 -0
- package/lib/src/metadata-builder/ai-metadata-executor.js +19 -0
- package/lib/src/metadata-builder/builders/section-builders/about-section-builder.js +3 -2
- package/lib/src/metadata-builder/builders/section-builders/banner-section-builder.js +3 -2
- package/lib/src/metadata-builder/builders/section-builders/booking-section-builder.js +3 -2
- package/lib/src/metadata-builder/builders/section-builders/contact-form-builder.js +4 -3
- package/lib/src/metadata-builder/builders/section-builders/images-section-builder.js +3 -2
- package/lib/src/metadata-builder/builders/section-builders/mailing-list-builder.js +2 -1
- package/lib/src/metadata-builder/builders/section-builders/mailing-list-section-builder.js +2 -1
- package/lib/src/metadata-builder/builders/section-builders/profile-section-builder.js +4 -3
- package/lib/src/metadata-builder/builders/section-builders/shapes-section-builder.js +3 -2
- package/lib/src/metadata-builder/builders/section-builders/testimonial-section-builder.js +2 -1
- package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +11 -15
- package/lib/src/metadata-builder/builders/update-content-text-builder.d.ts +17 -0
- package/lib/src/metadata-builder/builders/update-content-text-builder.js +103 -0
- package/lib/src/metadata-builder/builders/update-content-text-style-builder.d.ts +16 -0
- package/lib/src/metadata-builder/builders/update-content-text-style-builder.js +216 -0
- package/lib/src/metadata-builder/helpers/rte.helpers.d.ts +13 -11
- package/lib/src/metadata-builder/helpers/rte.helpers.js +73 -14
- package/lib/src/metadata-builder/metadata-executor.js +4 -0
- package/lib/src/metadata-builder/models.d.ts +2 -0
- package/lib/src/metadata-builder/models.js +2 -0
- package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +19 -0
- package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +4 -0
- package/lib/src/metadata-builder/sanitizers/rte-schema-check.d.ts +8 -7
- package/lib/src/metadata-builder/sanitizers/rte-schema-check.js +52 -21
- package/lib/src/shared/content-settings/components/GenericTextInput.mixin.js +1 -14
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.js +3 -12
- package/lib/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.mixin.d.ts +2 -3
- package/lib/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.mixin.js +3 -10
- package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.d.ts +1 -2
- package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.js +1 -4
- package/lib/src/utils/minify-brand-page-metadata.js +1 -4
- package/package.json +15 -17
- package/src/brand-page/components/contact-form/ContactForm.mixin.ts +2 -5
- package/src/brand-page/components/text-wrapper/TextWrapper.vue +6 -17
- package/src/brand-page/models/index.ts +26 -35
- package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +3 -3
- package/src/brand-page/utils/migration.util.ts +0 -12
- package/src/brand-page/utils/style.util.test.ts +0 -51
- package/src/brand-page/utils/text.util.ts +4 -43
- package/src/brand-page/utils/vcard.util.ts +4 -6
- package/src/brand-page/utils/view-model.util.ts +26 -94
- package/src/brand-page/view-models.ts +2 -3
- package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +3 -4
- package/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.ts +1 -0
- package/src/brand-page-maker/composables/rich-text-editor/bubbleMenu.ts +10 -6
- package/src/brand-page-maker/composables/rich-text-editor/extensions/FontSize.ts +73 -0
- package/src/brand-page-maker/composables/rich-text-editor/extensions/LineHeight.ts +85 -0
- package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +111 -27
- package/src/brand-page-maker/utils/index.ts +0 -2
- package/src/brand-page-maker/utils/rich-text-editor.util.test.ts +3 -37
- package/src/brand-page-maker/utils/rich-text-editor.util.ts +11 -66
- package/src/brand-page-maker/validators/field.validator.ts +2 -10
- package/src/lib.ts +0 -9
- package/src/metadata-builder/ai-metadata-executor.ts +26 -0
- package/src/metadata-builder/builders/section-builders/about-section-builder.ts +3 -2
- package/src/metadata-builder/builders/section-builders/banner-section-builder.ts +3 -2
- package/src/metadata-builder/builders/section-builders/booking-section-builder.ts +5 -2
- package/src/metadata-builder/builders/section-builders/contact-form-builder.ts +4 -3
- package/src/metadata-builder/builders/section-builders/images-section-builder.ts +3 -2
- package/src/metadata-builder/builders/section-builders/mailing-list-builder.ts +2 -1
- package/src/metadata-builder/builders/section-builders/mailing-list-section-builder.ts +2 -1
- package/src/metadata-builder/builders/section-builders/profile-section-builder.ts +6 -3
- package/src/metadata-builder/builders/section-builders/shapes-section-builder.ts +3 -2
- package/src/metadata-builder/builders/section-builders/testimonial-section-builder.ts +2 -1
- package/src/metadata-builder/builders/section-builders/text-section-builder.ts +13 -19
- package/src/metadata-builder/builders/update-content-text-builder.ts +148 -0
- package/src/metadata-builder/builders/update-content-text-style-builder.ts +352 -0
- package/src/metadata-builder/helpers/rte.helpers.ts +101 -15
- package/src/metadata-builder/metadata-executor.ts +5 -0
- package/src/metadata-builder/models.ts +3 -0
- package/src/metadata-builder/parsers/ai-toolcall-parser.ts +27 -0
- package/src/metadata-builder/sanitizers/rte-schema-check.ts +53 -24
- package/src/shared/content-settings/components/GenericTextInput.mixin.ts +1 -17
- package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.ts +3 -16
- package/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.mixin.ts +4 -13
- package/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.vue +4 -6
- package/src/shared/content-settings/components/text-image-field/TextImageField.vue +3 -6
- package/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.ts +1 -6
- package/src/shared/content-settings/components/text-items-field/TextItemsField.vue +1 -1
- package/src/utils/minify-brand-page-metadata.ts +1 -4
- package/lib/src/brand-page/utils/migration/migration-20.d.ts +0 -2
- package/lib/src/brand-page/utils/migration/migration-20.js +0 -111
- package/lib/src/brand-page-maker/__fixtures__/complex-rte.fixture.d.ts +0 -22
- package/lib/src/brand-page-maker/__fixtures__/complex-rte.fixture.js +0 -22
- package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.d.ts +0 -14
- package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.js +0 -39
- package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.d.ts +0 -28
- package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.js +0 -90
- package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +0 -41
- package/lib/src/brand-page-maker/utils/rte-render.util.js +0 -33
- package/lib/src/brand-page-maker/utils/rte-sanitize.util.d.ts +0 -6
- package/lib/src/brand-page-maker/utils/rte-sanitize.util.js +0 -158
- package/src/brand-page/utils/migration/migration-20.server.test.ts +0 -121
- package/src/brand-page/utils/migration/migration-20.test.ts +0 -72
- package/src/brand-page/utils/migration/migration-20.ts +0 -152
- package/src/brand-page/utils/text.util.test.ts +0 -47
- package/src/brand-page/utils/view-model.pageid-rte.test.ts +0 -104
- package/src/brand-page-maker/__fixtures__/complex-rte.fixture.ts +0 -23
- package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.test.ts +0 -52
- package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.ts +0 -42
- package/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.ts +0 -137
- package/src/brand-page-maker/utils/__snapshots__/rte-render-parity.test.ts.snap +0 -202
- package/src/brand-page-maker/utils/rte-features.test.ts +0 -351
- package/src/brand-page-maker/utils/rte-render-parity.test.ts +0 -157
- package/src/brand-page-maker/utils/rte-render.util.ts +0 -68
- package/src/brand-page-maker/utils/rte-sanitize-json.test.ts +0 -158
- package/src/brand-page-maker/utils/rte-sanitize.util.ts +0 -195
- package/src/brand-page-maker/validators/field.validator.test.ts +0 -55
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import StarterKit from '@tiptap/starter-kit';
|
|
2
|
-
import Underline from '@tiptap/extension-underline';
|
|
3
|
-
import { TextStyle, FontSize, LineHeight } from '@tiptap/extension-text-style';
|
|
4
|
-
import TextAlign from '@tiptap/extension-text-align';
|
|
5
|
-
import ColorExtension from '@tiptap/extension-color';
|
|
6
|
-
import Highlight from '@tiptap/extension-highlight';
|
|
7
|
-
import Placeholder from '@tiptap/extension-placeholder';
|
|
8
|
-
import Link from '@tiptap/extension-link';
|
|
9
|
-
import { FontFamily } from './extensions/FontFamily';
|
|
10
|
-
import { LetterSpacing } from './extensions/LetterSpacing';
|
|
11
|
-
import { MaxLength } from './extensions/MaxLength';
|
|
12
|
-
import { AutoLink } from './extensions/AutoLink';
|
|
13
|
-
import { isHrefPageId } from './hyperlink';
|
|
14
|
-
export const getRteExtensions = (options = {}) => {
|
|
15
|
-
const { restrictStarterKit = false, includeLinkRel = false, editing, } = options;
|
|
16
|
-
const extensions = [
|
|
17
|
-
// v3 StarterKit bundles Link + Underline; we register both separately (with
|
|
18
|
-
// custom Link config), so disable the bundled copies to avoid duplicate
|
|
19
|
-
// extension errors.
|
|
20
|
-
// trailingNode: false — v3 StarterKit adds a TrailingNode plugin (not in v2)
|
|
21
|
-
// that appends an empty paragraph after any non-paragraph trailing block.
|
|
22
|
-
// Disabled to keep editor/stored-content behaviour identical to v2 (BP-5787
|
|
23
|
-
// is backward-compat-first); revisit as a standalone UX change if wanted.
|
|
24
|
-
restrictStarterKit
|
|
25
|
-
? StarterKit.configure({
|
|
26
|
-
link: false,
|
|
27
|
-
underline: false,
|
|
28
|
-
trailingNode: false,
|
|
29
|
-
bulletList: false,
|
|
30
|
-
orderedList: false,
|
|
31
|
-
listItem: false,
|
|
32
|
-
horizontalRule: false,
|
|
33
|
-
codeBlock: false,
|
|
34
|
-
code: false,
|
|
35
|
-
})
|
|
36
|
-
: StarterKit.configure({
|
|
37
|
-
link: false,
|
|
38
|
-
underline: false,
|
|
39
|
-
trailingNode: false,
|
|
40
|
-
}),
|
|
41
|
-
Underline,
|
|
42
|
-
// priority: 99 — render the textStyle <span> (which carries font-size) as the
|
|
43
|
-
// OUTERMOST inline mark, so decoration marks (<u>/<s>) nest INSIDE it and
|
|
44
|
-
// inherit the applied font-size. v3's extension-text-style defaults to
|
|
45
|
-
// priority 101, which flips this: <u>/<s> end up outside the font-size span
|
|
46
|
-
// and inherit only the base paragraph size. Since text-decoration thickness
|
|
47
|
-
// AND vertical position are computed from the decorating element's own font,
|
|
48
|
-
// that produced thin, mis-centred underline/strike on large text — a
|
|
49
|
-
// regression vs v2's serializer, which emitted the font-size span outermost
|
|
50
|
-
// (BP-5787, backward-compat-first).
|
|
51
|
-
TextStyle.extend({ priority: 99 }).configure({
|
|
52
|
-
mergeNestedSpanStyles: true,
|
|
53
|
-
}),
|
|
54
|
-
TextAlign.configure({
|
|
55
|
-
types: ['heading', 'paragraph'],
|
|
56
|
-
}),
|
|
57
|
-
FontFamily.configure({
|
|
58
|
-
types: ['textStyle'],
|
|
59
|
-
}),
|
|
60
|
-
// FontSize + LineHeight are now shipped natively by @tiptap/extension-text-style.
|
|
61
|
-
FontSize.configure({ types: ['textStyle'] }),
|
|
62
|
-
LetterSpacing,
|
|
63
|
-
LineHeight.configure({ types: ['textStyle'] }),
|
|
64
|
-
ColorExtension.configure({
|
|
65
|
-
types: ['textStyle'],
|
|
66
|
-
}),
|
|
67
|
-
Highlight.configure({
|
|
68
|
-
multicolor: true,
|
|
69
|
-
}),
|
|
70
|
-
];
|
|
71
|
-
// Live-editing-only plugins sit between Highlight and Link (original order).
|
|
72
|
-
if (editing) {
|
|
73
|
-
extensions.push(Placeholder.configure({ placeholder: editing.placeholder }), MaxLength.configure({ maxLength: editing.maxLength }));
|
|
74
|
-
}
|
|
75
|
-
extensions.push(Link.configure({
|
|
76
|
-
openOnClick: false,
|
|
77
|
-
defaultProtocol: 'https',
|
|
78
|
-
autolink: true,
|
|
79
|
-
HTMLAttributes: (attrs) => {
|
|
80
|
-
const href = attrs.href;
|
|
81
|
-
const isExternal = href && (href.startsWith('http://') || href.startsWith('https://'));
|
|
82
|
-
return {
|
|
83
|
-
...(includeLinkRel && { rel: 'noopener noreferrer' }),
|
|
84
|
-
target: isExternal ? '_blank' : null,
|
|
85
|
-
};
|
|
86
|
-
},
|
|
87
|
-
isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || isHrefPageId(url),
|
|
88
|
-
}), AutoLink);
|
|
89
|
-
return extensions;
|
|
90
|
-
};
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { JSONContent } from '@tiptap/vue-3';
|
|
2
|
-
import { isTiptapDocument, getRteJsonPlainText } from './rich-text-editor.util';
|
|
3
|
-
export { isTiptapDocument, getRteJsonPlainText };
|
|
4
|
-
/**
|
|
5
|
-
* BP-5787 — the boundary plumbing for storing RTE as Tiptap JSON.
|
|
6
|
-
*
|
|
7
|
-
* An RTE field is either a legacy inline-HTML string (pre-migration, or a
|
|
8
|
-
* not-yet-migrated published site) or a Tiptap JSON document (post-migration).
|
|
9
|
-
* These helpers let every consumer treat the two uniformly:
|
|
10
|
-
*
|
|
11
|
-
* - isTiptapDocument — the canonical discriminator (object with type 'doc').
|
|
12
|
-
* - renderRteToHtml — render either form to final HTML for display.
|
|
13
|
-
* - toRteJson — normalise either form to JSON for storage.
|
|
14
|
-
*
|
|
15
|
-
* Both directions delegate to Tiptap's own schema-driven converters, fed by the
|
|
16
|
-
* single-source-of-truth extension set (getRteExtensions) — so the mapping lives
|
|
17
|
-
* in the extensions' `renderHTML`, not a hand-maintained copy that drifts when
|
|
18
|
-
* Tiptap changes:
|
|
19
|
-
*
|
|
20
|
-
* - renderRteToHtml uses @tiptap/static-renderer (renderToHTMLString). It is
|
|
21
|
-
* DOM-less, so it runs on the server (SSR/publish/preview), not just the
|
|
22
|
-
* browser, and needs no Editor instance.
|
|
23
|
-
* - toRteJson uses generateJSON, which PARSES HTML and so needs a DOM
|
|
24
|
-
* (window.DOMParser). In the browser and in jsdom tests that global exists;
|
|
25
|
-
* on the server the only caller is migration-20, which installs a jsdom
|
|
26
|
-
* window around its conversion pass (see migration/migration-20.ts).
|
|
27
|
-
*/
|
|
28
|
-
export type RichTextValue = string | JSONContent;
|
|
29
|
-
/**
|
|
30
|
-
* Render an RTE value to final HTML. Legacy HTML strings pass through untouched
|
|
31
|
-
* (they are already HTML); Tiptap JSON is serialised by @tiptap/static-renderer
|
|
32
|
-
* from the extension schema — no editor, no DOM — so it works on the server
|
|
33
|
-
* (SSR/publish), not just the browser.
|
|
34
|
-
*/
|
|
35
|
-
export declare const renderRteToHtml: (value: RichTextValue | null | undefined) => string;
|
|
36
|
-
/**
|
|
37
|
-
* Normalise an RTE value to a Tiptap JSON document. HTML strings are parsed via
|
|
38
|
-
* generateJSON (needs a DOM — browser/jsdom); documents pass through unchanged.
|
|
39
|
-
* Returns undefined for empty/blank input so callers can omit empty fields.
|
|
40
|
-
*/
|
|
41
|
-
export declare const toRteJson: (value: RichTextValue | null | undefined) => JSONContent | undefined;
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { generateJSON } from '@tiptap/core';
|
|
2
|
-
import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string';
|
|
3
|
-
import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
|
|
4
|
-
import { isTiptapDocument, getRteJsonPlainText } from './rich-text-editor.util';
|
|
5
|
-
export { isTiptapDocument, getRteJsonPlainText };
|
|
6
|
-
// One extension set shared by both directions (render + parse). Schema knowledge
|
|
7
|
-
// lives here, once — Tiptap/extension upgrades propagate to both automatically.
|
|
8
|
-
const RTE_EXTENSIONS = getRteExtensions({ includeLinkRel: true });
|
|
9
|
-
/**
|
|
10
|
-
* Render an RTE value to final HTML. Legacy HTML strings pass through untouched
|
|
11
|
-
* (they are already HTML); Tiptap JSON is serialised by @tiptap/static-renderer
|
|
12
|
-
* from the extension schema — no editor, no DOM — so it works on the server
|
|
13
|
-
* (SSR/publish), not just the browser.
|
|
14
|
-
*/
|
|
15
|
-
export const renderRteToHtml = (value) => {
|
|
16
|
-
if (value == null || value === '')
|
|
17
|
-
return '';
|
|
18
|
-
if (typeof value === 'string')
|
|
19
|
-
return value;
|
|
20
|
-
return renderToHTMLString({ content: value, extensions: RTE_EXTENSIONS });
|
|
21
|
-
};
|
|
22
|
-
/**
|
|
23
|
-
* Normalise an RTE value to a Tiptap JSON document. HTML strings are parsed via
|
|
24
|
-
* generateJSON (needs a DOM — browser/jsdom); documents pass through unchanged.
|
|
25
|
-
* Returns undefined for empty/blank input so callers can omit empty fields.
|
|
26
|
-
*/
|
|
27
|
-
export const toRteJson = (value) => {
|
|
28
|
-
if (value == null || value === '')
|
|
29
|
-
return undefined;
|
|
30
|
-
if (isTiptapDocument(value))
|
|
31
|
-
return value;
|
|
32
|
-
return generateJSON(value, RTE_EXTENSIONS);
|
|
33
|
-
};
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { validateAndSanitizeHref } from './rich-text-editor.util';
|
|
2
|
-
/**
|
|
3
|
-
* BP-5787 — native JSON sanitiser for RTE content.
|
|
4
|
-
*
|
|
5
|
-
* Operates directly on the Tiptap document tree: no editor instantiation, no
|
|
6
|
-
* HTML round-trip, no DOMPurify. This is the hot path (runs on every editor
|
|
7
|
-
* update and on the server sanitise gate), so it is a single, allocation-light
|
|
8
|
-
* recursive walk.
|
|
9
|
-
*
|
|
10
|
-
* Security model (mirrors the HTML allowlist it replaces):
|
|
11
|
-
* - only allow-listed node and mark types survive; everything else is dropped;
|
|
12
|
-
* - textStyle / highlight attrs are restricted to known keys whose values must
|
|
13
|
-
* be "safe CSS values" (no quotes/angle-brackets/extra declarations/url()/
|
|
14
|
-
* expression()), preventing CSS-injection when the value is later rendered
|
|
15
|
-
* into a `style="…"` attribute;
|
|
16
|
-
* - link hrefs go through the same validateAndSanitizeHref used by the HTML
|
|
17
|
-
* sanitiser; unsafe links lose their href (and the mark);
|
|
18
|
-
* - heading levels are clamped to 1–6.
|
|
19
|
-
*/
|
|
20
|
-
// ProseMirror node types the editor schema can legitimately produce.
|
|
21
|
-
const ALLOWED_NODE_TYPES = new Set([
|
|
22
|
-
'doc',
|
|
23
|
-
'paragraph',
|
|
24
|
-
'heading',
|
|
25
|
-
'text',
|
|
26
|
-
'hardBreak',
|
|
27
|
-
'bulletList',
|
|
28
|
-
'orderedList',
|
|
29
|
-
'listItem',
|
|
30
|
-
'blockquote',
|
|
31
|
-
'codeBlock',
|
|
32
|
-
'horizontalRule',
|
|
33
|
-
]);
|
|
34
|
-
// Inline mark types the editor produces (strong/em/u/s/code/span/mark/a).
|
|
35
|
-
const ALLOWED_MARK_TYPES = new Set([
|
|
36
|
-
'bold',
|
|
37
|
-
'italic',
|
|
38
|
-
'underline',
|
|
39
|
-
'strike',
|
|
40
|
-
'code',
|
|
41
|
-
'textStyle',
|
|
42
|
-
'highlight',
|
|
43
|
-
'link',
|
|
44
|
-
]);
|
|
45
|
-
// textStyle attrs map to the allow-listed CSS properties (font-weight →
|
|
46
|
-
// bold mark, text-decoration → underline/strike, text-align → node attr,
|
|
47
|
-
// background-color → highlight, so they are intentionally not here).
|
|
48
|
-
const ALLOWED_TEXT_STYLE_ATTRS = new Set([
|
|
49
|
-
'color',
|
|
50
|
-
'fontFamily',
|
|
51
|
-
'fontSize',
|
|
52
|
-
'letterSpacing',
|
|
53
|
-
'lineHeight',
|
|
54
|
-
]);
|
|
55
|
-
/**
|
|
56
|
-
* A safe CSS value is a short string free of characters that could inject an
|
|
57
|
-
* extra declaration or escape the attribute, and free of the url()/expression()
|
|
58
|
-
* vectors. Legitimate values — `rgb(255, 0, 0)`, `#FF0000`,
|
|
59
|
-
* `var(--logo-text-color)`, `Open Sans`, `1.5em` — all pass.
|
|
60
|
-
*/
|
|
61
|
-
const isSafeCssValue = (value) => typeof value === 'string' &&
|
|
62
|
-
value.length > 0 &&
|
|
63
|
-
value.length <= 200 &&
|
|
64
|
-
!/[<>"';]/.test(value) &&
|
|
65
|
-
!/url\s*\(/i.test(value) &&
|
|
66
|
-
!/expression\s*\(/i.test(value);
|
|
67
|
-
const sanitizeTextStyleAttrs = (attrs) => {
|
|
68
|
-
const clean = {};
|
|
69
|
-
for (const key of Object.keys(attrs)) {
|
|
70
|
-
if (!ALLOWED_TEXT_STYLE_ATTRS.has(key))
|
|
71
|
-
continue;
|
|
72
|
-
const value = attrs[key];
|
|
73
|
-
// Tiptap emits '' for unset textStyle slots — preserve them.
|
|
74
|
-
if (value === '' || value == null) {
|
|
75
|
-
clean[key] = '';
|
|
76
|
-
}
|
|
77
|
-
else if (isSafeCssValue(value)) {
|
|
78
|
-
clean[key] = value;
|
|
79
|
-
}
|
|
80
|
-
// else: drop the unsafe value
|
|
81
|
-
}
|
|
82
|
-
return Object.keys(clean).length ? clean : null;
|
|
83
|
-
};
|
|
84
|
-
const sanitizeMark = (mark) => {
|
|
85
|
-
const type = mark?.type;
|
|
86
|
-
if (!type || !ALLOWED_MARK_TYPES.has(type))
|
|
87
|
-
return null;
|
|
88
|
-
const attrs = mark.attrs;
|
|
89
|
-
if (type === 'link') {
|
|
90
|
-
const href = typeof attrs?.href === 'string'
|
|
91
|
-
? validateAndSanitizeHref(attrs.href)
|
|
92
|
-
: null;
|
|
93
|
-
if (!href)
|
|
94
|
-
return null; // drop links with unsafe/empty href
|
|
95
|
-
const cleanAttrs = { href };
|
|
96
|
-
if (attrs?.target != null)
|
|
97
|
-
cleanAttrs.target = attrs.target;
|
|
98
|
-
if (attrs?.rel != null)
|
|
99
|
-
cleanAttrs.rel = attrs.rel;
|
|
100
|
-
return { type, attrs: cleanAttrs };
|
|
101
|
-
}
|
|
102
|
-
if (type === 'textStyle') {
|
|
103
|
-
const clean = attrs ? sanitizeTextStyleAttrs(attrs) : null;
|
|
104
|
-
return clean ? { type, attrs: clean } : null;
|
|
105
|
-
}
|
|
106
|
-
if (type === 'highlight') {
|
|
107
|
-
return isSafeCssValue(attrs?.color)
|
|
108
|
-
? { type, attrs: { color: attrs.color } }
|
|
109
|
-
: { type };
|
|
110
|
-
}
|
|
111
|
-
// Plain marks (bold/italic/underline/strike/code) carry no attrs.
|
|
112
|
-
return { type };
|
|
113
|
-
};
|
|
114
|
-
const ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify']);
|
|
115
|
-
const sanitizeNode = (node) => {
|
|
116
|
-
const type = node?.type;
|
|
117
|
-
if (!type || !ALLOWED_NODE_TYPES.has(type))
|
|
118
|
-
return null;
|
|
119
|
-
const clean = { type };
|
|
120
|
-
const attrs = {};
|
|
121
|
-
if (type === 'heading') {
|
|
122
|
-
const level = Number(node.attrs?.level);
|
|
123
|
-
attrs.level = level >= 1 && level <= 6 ? level : 1;
|
|
124
|
-
}
|
|
125
|
-
if (type === 'heading' || type === 'paragraph') {
|
|
126
|
-
const align = node.attrs?.textAlign;
|
|
127
|
-
if (typeof align === 'string' && ALIGN_VALUES.has(align)) {
|
|
128
|
-
attrs.textAlign = align;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (Object.keys(attrs).length)
|
|
132
|
-
clean.attrs = attrs;
|
|
133
|
-
if (typeof node.text === 'string')
|
|
134
|
-
clean.text = node.text;
|
|
135
|
-
if (Array.isArray(node.marks)) {
|
|
136
|
-
const marks = node.marks
|
|
137
|
-
.map((m) => sanitizeMark(m))
|
|
138
|
-
.filter(Boolean);
|
|
139
|
-
if (marks && marks.length)
|
|
140
|
-
clean.marks = marks;
|
|
141
|
-
}
|
|
142
|
-
if (Array.isArray(node.content)) {
|
|
143
|
-
const content = node.content
|
|
144
|
-
.map(sanitizeNode)
|
|
145
|
-
.filter(Boolean);
|
|
146
|
-
if (content.length)
|
|
147
|
-
clean.content = content;
|
|
148
|
-
}
|
|
149
|
-
return clean;
|
|
150
|
-
};
|
|
151
|
-
/**
|
|
152
|
-
* Sanitise a Tiptap JSON document (returns a new clean doc). Returns a minimal
|
|
153
|
-
* empty doc if the input isn't a usable document.
|
|
154
|
-
*/
|
|
155
|
-
export const sanitizeRteJson = (doc) => {
|
|
156
|
-
const clean = doc && typeof doc === 'object' ? sanitizeNode(doc) : null;
|
|
157
|
-
return clean && clean.type === 'doc' ? clean : { type: 'doc', content: [] };
|
|
158
|
-
};
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BP-5787 — migration-20 must convert HTML RTE → Tiptap JSON on the SERVER, not
|
|
3
|
-
* just the browser. `generateJSON` (via toRteJson) needs `window.DOMParser`,
|
|
4
|
-
* which pure-node SSR lacks; migration-20 provides a jsdom window for its pass.
|
|
5
|
-
*
|
|
6
|
-
* These tests run under the default jsdom environment but DELETE the global
|
|
7
|
-
* `window`/`document` around each case, so `migration20` takes its DOM-less
|
|
8
|
-
* server branch for real (dynamic jsdom import via a runtime-assembled specifier
|
|
9
|
-
* + transient global install + restore). The sibling migration-20.test.ts
|
|
10
|
-
* guards the browser path.
|
|
11
|
-
*
|
|
12
|
-
* (We simulate DOM-less rather than switch this file to a node environment,
|
|
13
|
-
* because the shared test/vitest.setup.js assumes a jsdom `global.window`.)
|
|
14
|
-
*/
|
|
15
|
-
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
16
|
-
import { migration20 } from './migration-20';
|
|
17
|
-
import type { MigrationData } from '../migration.util';
|
|
18
|
-
import { isTiptapDocument, getRteJsonPlainText } from '@brand-page-maker/utils';
|
|
19
|
-
|
|
20
|
-
const createMigrationData = (brandPageData: any): MigrationData =>
|
|
21
|
-
({ brandPageModel: brandPageData }) as MigrationData;
|
|
22
|
-
|
|
23
|
-
const model = (contents: any[]) => ({
|
|
24
|
-
pages: [{ id: 'p1', title: 'Home', slug: 'home', contents }],
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const g = globalThis as any;
|
|
28
|
-
let savedWindow: unknown;
|
|
29
|
-
let savedDocument: unknown;
|
|
30
|
-
|
|
31
|
-
describe('migration20 (server / no DOM) — HTML RTE → Tiptap JSON', () => {
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
// Force migration-20's DOM-less server branch.
|
|
34
|
-
savedWindow = g.window;
|
|
35
|
-
savedDocument = g.document;
|
|
36
|
-
delete g.window;
|
|
37
|
-
delete g.document;
|
|
38
|
-
|
|
39
|
-
expect(typeof window).toBe('undefined');
|
|
40
|
-
expect(typeof document).toBe('undefined');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
g.window = savedWindow;
|
|
45
|
-
g.document = savedDocument;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('converts a top-level HTML RTE field with no global window', async () => {
|
|
49
|
-
const data = model([
|
|
50
|
-
{
|
|
51
|
-
id: '1',
|
|
52
|
-
type: 'banner-section',
|
|
53
|
-
title:
|
|
54
|
-
'<p><span style="color: var(--logo-text-color)">Hello</span></p>',
|
|
55
|
-
},
|
|
56
|
-
]);
|
|
57
|
-
|
|
58
|
-
const result = await migration20(createMigrationData(data));
|
|
59
|
-
const title = (result.brandPageModel.pages[0].contents[0] as any).title;
|
|
60
|
-
|
|
61
|
-
expect(isTiptapDocument(title)).toBe(true);
|
|
62
|
-
expect(getRteJsonPlainText(title)).toContain('Hello');
|
|
63
|
-
// theme token ref preserved through the server-side parse
|
|
64
|
-
expect(JSON.stringify(title)).toContain('var(--logo-text-color)');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('converts RTE inside item arrays server-side (marks preserved)', async () => {
|
|
68
|
-
const data = model([
|
|
69
|
-
{
|
|
70
|
-
id: '2',
|
|
71
|
-
type: 'text',
|
|
72
|
-
textItems: [{ id: 'ti1', text: '<p><strong>bold</strong> item</p>' }],
|
|
73
|
-
},
|
|
74
|
-
]);
|
|
75
|
-
|
|
76
|
-
const result = await migration20(createMigrationData(data));
|
|
77
|
-
const item = (result.brandPageModel.pages[0].contents[0] as any)
|
|
78
|
-
.textItems[0];
|
|
79
|
-
|
|
80
|
-
expect(isTiptapDocument(item.text)).toBe(true);
|
|
81
|
-
expect(getRteJsonPlainText(item.text)).toContain('bold');
|
|
82
|
-
// the <strong> mark survived the jsdom parse
|
|
83
|
-
expect(JSON.stringify(item.text)).toContain('bold');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('leaves plain strings and already-JSON content untouched (idempotent)', async () => {
|
|
87
|
-
const jsonDoc = {
|
|
88
|
-
type: 'doc',
|
|
89
|
-
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] }],
|
|
90
|
-
};
|
|
91
|
-
const data = model([
|
|
92
|
-
{
|
|
93
|
-
id: '3',
|
|
94
|
-
type: 'banner-section',
|
|
95
|
-
title: jsonDoc, // already JSON
|
|
96
|
-
url: 'https://example.com', // plain string, not RTE
|
|
97
|
-
titleStyle: { color: '#fff', fontSize: '1em' }, // style object, untouched
|
|
98
|
-
},
|
|
99
|
-
]);
|
|
100
|
-
|
|
101
|
-
const result = await migration20(createMigrationData(data));
|
|
102
|
-
const c = result.brandPageModel.pages[0].contents[0] as any;
|
|
103
|
-
|
|
104
|
-
expect(c.title).toEqual(jsonDoc);
|
|
105
|
-
expect(c.url).toBe('https://example.com');
|
|
106
|
-
expect(c.titleStyle).toEqual({ color: '#fff', fontSize: '1em' });
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('does not leak the jsdom window into globalThis after the pass', async () => {
|
|
110
|
-
const data = model([
|
|
111
|
-
{ id: '4', type: 'banner-section', title: '<p>Leak check</p>' },
|
|
112
|
-
]);
|
|
113
|
-
|
|
114
|
-
await migration20(createMigrationData(data));
|
|
115
|
-
|
|
116
|
-
// The transient jsdom globals installed during the pass must be restored
|
|
117
|
-
// to their pre-pass state (deleted here), not leaked.
|
|
118
|
-
expect(typeof window).toBe('undefined');
|
|
119
|
-
expect(typeof document).toBe('undefined');
|
|
120
|
-
});
|
|
121
|
-
});
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from 'vitest';
|
|
2
|
-
import { migration20 } from './migration-20';
|
|
3
|
-
import type { MigrationData } from '../migration.util';
|
|
4
|
-
import { isTiptapDocument, getRteJsonPlainText } from '@brand-page-maker/utils';
|
|
5
|
-
|
|
6
|
-
const createMigrationData = (brandPageData: any): MigrationData =>
|
|
7
|
-
({ brandPageModel: brandPageData }) as MigrationData;
|
|
8
|
-
|
|
9
|
-
const model = (contents: any[]) => ({
|
|
10
|
-
pages: [{ id: 'p1', title: 'Home', slug: 'home', contents }],
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe('migration20 — HTML RTE → Tiptap JSON', () => {
|
|
14
|
-
test('converts a top-level HTML RTE field to a JSON document', async () => {
|
|
15
|
-
const data = model([
|
|
16
|
-
{
|
|
17
|
-
id: '1',
|
|
18
|
-
type: 'banner-section',
|
|
19
|
-
title:
|
|
20
|
-
'<p><span style="color: var(--logo-text-color)">Hello</span></p>',
|
|
21
|
-
},
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
const result = await migration20(createMigrationData(data));
|
|
25
|
-
const title = (result.brandPageModel.pages[0].contents[0] as any).title;
|
|
26
|
-
|
|
27
|
-
expect(isTiptapDocument(title)).toBe(true);
|
|
28
|
-
expect(getRteJsonPlainText(title)).toContain('Hello');
|
|
29
|
-
// theme token ref preserved through migration
|
|
30
|
-
expect(JSON.stringify(title)).toContain('var(--logo-text-color)');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('converts RTE inside item arrays (text items / testimonials)', async () => {
|
|
34
|
-
const data = model([
|
|
35
|
-
{
|
|
36
|
-
id: '2',
|
|
37
|
-
type: 'text',
|
|
38
|
-
textItems: [{ id: 'ti1', text: '<p><strong>bold</strong> item</p>' }],
|
|
39
|
-
},
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
const result = await migration20(createMigrationData(data));
|
|
43
|
-
const item = (result.brandPageModel.pages[0].contents[0] as any)
|
|
44
|
-
.textItems[0];
|
|
45
|
-
|
|
46
|
-
expect(isTiptapDocument(item.text)).toBe(true);
|
|
47
|
-
expect(getRteJsonPlainText(item.text)).toContain('bold');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('leaves plain strings and already-JSON content untouched (idempotent)', async () => {
|
|
51
|
-
const jsonDoc = {
|
|
52
|
-
type: 'doc',
|
|
53
|
-
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] }],
|
|
54
|
-
};
|
|
55
|
-
const data = model([
|
|
56
|
-
{
|
|
57
|
-
id: '3',
|
|
58
|
-
type: 'banner-section',
|
|
59
|
-
title: jsonDoc, // already JSON
|
|
60
|
-
url: 'https://example.com', // plain string, not RTE
|
|
61
|
-
titleStyle: { color: '#fff', fontSize: '1em' }, // style object, untouched
|
|
62
|
-
},
|
|
63
|
-
]);
|
|
64
|
-
|
|
65
|
-
const result = await migration20(createMigrationData(data));
|
|
66
|
-
const c = result.brandPageModel.pages[0].contents[0] as any;
|
|
67
|
-
|
|
68
|
-
expect(c.title).toEqual(jsonDoc);
|
|
69
|
-
expect(c.url).toBe('https://example.com');
|
|
70
|
-
expect(c.titleStyle).toEqual({ color: '#fff', fontSize: '1em' });
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import type { BrandPageModel } from '../../models';
|
|
2
|
-
import type { MigrationData } from '../migration.util';
|
|
3
|
-
import { hasRTEContent, toRteJson } from '../../../brand-page-maker/utils';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* BP-5787 — migrate existing inline-HTML RTE content to Tiptap JSON.
|
|
7
|
-
*
|
|
8
|
-
* The editor emits, and the renderer reads, Tiptap JSON. Existing
|
|
9
|
-
* published/saved sites still hold RTE fields as HTML strings. This converts
|
|
10
|
-
* them on load, in EVERY environment:
|
|
11
|
-
* - maker (browser): the loaded model is JSON, so the next save persists JSON.
|
|
12
|
-
* - web-renderer (SSR / server API routes): converted on read using a jsdom
|
|
13
|
-
* window (see below), so old sites are normalised without a re-save and
|
|
14
|
-
* downstream code can treat RTE as JSON.
|
|
15
|
-
*
|
|
16
|
-
* This is a one-time, versioned migration (runAlways is NOT set): legacy sites
|
|
17
|
-
* are at model version < 20 and convert on first load in any environment; once
|
|
18
|
-
* `v` is stamped to 20 the conversion is skipped. This is only safe because the
|
|
19
|
-
* AI section builders now emit Tiptap JSON directly (they no longer write HTML),
|
|
20
|
-
* so there is no recurring post-load source of HTML to normalise.
|
|
21
|
-
*
|
|
22
|
-
* Scope: only HTML-RTE strings (hasRTEContent) are converted. Plain strings,
|
|
23
|
-
* numbers, and already-JSON docs are left untouched, so the migration is
|
|
24
|
-
* idempotent (re-running on JSON content is a no-op — non-string values are
|
|
25
|
-
* skipped). Nested RTE inside item arrays (textItems, testimonials, images…)
|
|
26
|
-
* is handled by recursing into arrays; style objects are intentionally not
|
|
27
|
-
* descended into (they never hold RTE content).
|
|
28
|
-
*/
|
|
29
|
-
const convertRteStringsToJson = (value: unknown): void => {
|
|
30
|
-
if (Array.isArray(value)) {
|
|
31
|
-
value.forEach((item) => convertRteStringsToJson(item));
|
|
32
|
-
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (!value || typeof value !== 'object') return;
|
|
37
|
-
|
|
38
|
-
const record = value as Record<string, unknown>;
|
|
39
|
-
|
|
40
|
-
Object.keys(record).forEach((key) => {
|
|
41
|
-
const field = record[key];
|
|
42
|
-
|
|
43
|
-
if (typeof field === 'string' && hasRTEContent(field)) {
|
|
44
|
-
const json = toRteJson(field);
|
|
45
|
-
|
|
46
|
-
if (json) record[key] = json;
|
|
47
|
-
} else if (Array.isArray(field)) {
|
|
48
|
-
convertRteStringsToJson(field);
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const convertModel = (brandPageModel: BrandPageModel): void => {
|
|
54
|
-
for (const page of brandPageModel.pages) {
|
|
55
|
-
for (const content of page.contents) {
|
|
56
|
-
convertRteStringsToJson(content);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* HTML→JSON parsing (Tiptap's generateJSON, via toRteJson) needs `window.DOMParser`.
|
|
63
|
-
* In pure-node SSR there is no global DOM, so we provide a jsdom-backed window.
|
|
64
|
-
*
|
|
65
|
-
* Memoised: JSDOM construction is relatively expensive, so one window is created
|
|
66
|
-
* per process and reused. The parser is stateless (each parseFromString builds a
|
|
67
|
-
* fresh document), so sharing the window across requests is safe.
|
|
68
|
-
*
|
|
69
|
-
* `jsdom` is dynamically imported via a runtime-assembled specifier (see
|
|
70
|
-
* getServerWindow), so no bundler can follow it into a browser bundle — this
|
|
71
|
-
* branch only runs when there is no global `window`.
|
|
72
|
-
*/
|
|
73
|
-
type ServerWindow = { DOMParser: unknown; document: unknown };
|
|
74
|
-
|
|
75
|
-
// Minimal shape of the jsdom module, declared locally rather than via an
|
|
76
|
-
// `import()` type, so the specifier "jsdom" never appears anywhere a bundler
|
|
77
|
-
// could follow it (value or type position). Only the members used below.
|
|
78
|
-
type JsdomModule = { JSDOM: new (html?: string) => { window: ServerWindow } };
|
|
79
|
-
|
|
80
|
-
let serverWindowPromise: Promise<ServerWindow> | null = null;
|
|
81
|
-
|
|
82
|
-
const getServerWindow = (): Promise<ServerWindow> => {
|
|
83
|
-
if (!serverWindowPromise) {
|
|
84
|
-
// The specifier is assembled at runtime rather than written as the string
|
|
85
|
-
// literal "jsdom", so no bundler (Rollup/Vite in the library build, and any
|
|
86
|
-
// downstream browser consumer — BrandCrowd.Nuxt, web-renderer, …) can
|
|
87
|
-
// statically follow it and try to bundle jsdom → cssstyle, whose generated
|
|
88
|
-
// code breaks Rollup's CommonJS parser. The library stays self-contained
|
|
89
|
-
// (it still loads its own jsdom at runtime on the server) while never
|
|
90
|
-
// leaking jsdom into a browser bundle. `@vite-ignore` silences the
|
|
91
|
-
// "cannot be analyzed" warning the non-literal specifier would otherwise
|
|
92
|
-
// trigger. At Node runtime this resolves the real jsdom from node_modules.
|
|
93
|
-
const jsdomModuleId = ['js', 'dom'].join('');
|
|
94
|
-
|
|
95
|
-
serverWindowPromise = (
|
|
96
|
-
import(/* @vite-ignore */ jsdomModuleId) as Promise<JsdomModule>
|
|
97
|
-
)
|
|
98
|
-
.then(({ JSDOM }) => new JSDOM('').window)
|
|
99
|
-
.catch((error) => {
|
|
100
|
-
// Don't cache the failure — let a later migration retry the import.
|
|
101
|
-
serverWindowPromise = null;
|
|
102
|
-
|
|
103
|
-
throw error;
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return serverWindowPromise;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
export const migration20 = async (
|
|
111
|
-
migrationResult: MigrationData,
|
|
112
|
-
): Promise<MigrationData> => {
|
|
113
|
-
const { brandPageModel } = migrationResult;
|
|
114
|
-
|
|
115
|
-
// Browser / jsdom test: a DOM is already present — convert directly.
|
|
116
|
-
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
117
|
-
convertModel(brandPageModel);
|
|
118
|
-
|
|
119
|
-
return migrationResult;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Pure-node SSR: install a memoised jsdom window as the global for the duration
|
|
123
|
-
// of the conversion pass, then restore. The pass is fully synchronous (no await
|
|
124
|
-
// between install and restore), so in Node's single thread no concurrent request
|
|
125
|
-
// can observe the temporary globals.
|
|
126
|
-
const serverWindow = await getServerWindow();
|
|
127
|
-
const globals = globalThis as unknown as {
|
|
128
|
-
window?: unknown;
|
|
129
|
-
document?: unknown;
|
|
130
|
-
};
|
|
131
|
-
// Track whether the globals existed so we restore exactly — deleting keys we
|
|
132
|
-
// added rather than leaving `window: undefined` own-properties behind.
|
|
133
|
-
const hadWindow = 'window' in globals;
|
|
134
|
-
const hadDocument = 'document' in globals;
|
|
135
|
-
const prevWindow = globals.window;
|
|
136
|
-
const prevDocument = globals.document;
|
|
137
|
-
|
|
138
|
-
globals.window = serverWindow;
|
|
139
|
-
globals.document = serverWindow.document;
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
convertModel(brandPageModel);
|
|
143
|
-
} finally {
|
|
144
|
-
if (hadWindow) globals.window = prevWindow;
|
|
145
|
-
else delete globals.window;
|
|
146
|
-
|
|
147
|
-
if (hadDocument) globals.document = prevDocument;
|
|
148
|
-
else delete globals.document;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return migrationResult;
|
|
152
|
-
};
|