@designcrowd/library.brand-page 6.1.1-json-prereq-3 → 6.1.1
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-hj_ievMc.js → CreatedOnBrandCrowd-de-de-B5B5ThJ6.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-jAOSpfCw.js → CreatedOnBrandCrowd-en-us-a48CaORy.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-DZMVc-y3.js → CreatedOnBrandCrowd-es-es-xpuZ317-.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-DvB54H38.js → CreatedOnBrandCrowd-fr-ca-V-iUMArW.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-DvB54H38.js → CreatedOnBrandCrowd-fr-fr-V-iUMArW.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-bf-x1xhi.js → CreatedOnBrandCrowd-pt-br-DZ0NKSAy.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-bf-x1xhi.js → CreatedOnBrandCrowd-pt-pt-DZ0NKSAy.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-B-NY1B44.js → CreatedOnDcom-de-de-_950cgGp.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-Cd4C6KQf.js → CreatedOnDcom-en-us-DogKzH-g.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-DONlsQsm.js → CreatedOnDcom-es-es-CmGM3IPX.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-Dsv3FRpZ.js → CreatedOnDcom-fr-ca-BonEDkWZ.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-DOgyvY5C.js → CreatedOnDcom-fr-fr-DQHtKrd_.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-DX66MhJS.js → CreatedOnDcom-pt-br-CtYJSBjs.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-COe2G0bU.js → CreatedOnDcom-pt-pt-DoO9r4lA.js} +1 -1
- package/lib/src/brand-page/components/contact-form/ContactForm.mixin.js +7 -8
- package/lib/src/brand-page/components/mailing-list/MailingList.mixin.js +6 -4
- package/lib/src/brand-page/components/page-footer/PageFooter.mixin.d.ts +1 -0
- package/lib/src/brand-page/components/page-footer/PageFooter.mixin.js +4 -1
- package/lib/src/brand-page/components/payment/Payment.mixin.js +4 -3
- package/lib/src/brand-page/components/payment/__fixtures__/request-payment.d.ts +1 -0
- package/lib/src/brand-page/components/payment/__fixtures__/support-me.d.ts +1 -0
- package/lib/src/brand-page/components/payment/request-payment/RequestPayment.mixin.d.ts +1 -0
- package/lib/src/brand-page/components/payment/request-payment/RequestPayment.mixin.js +4 -1
- package/lib/src/brand-page/composables/consent.composable.d.ts +2 -0
- package/lib/src/brand-page/composables/consent.composable.js +9 -3
- package/lib/src/brand-page/models/brand-page-context.model.d.ts +7 -0
- package/lib/src/brand-page/models/index.d.ts +26 -34
- package/lib/src/brand-page/utils/context.util.d.ts +8 -0
- package/lib/src/brand-page/utils/context.util.js +8 -0
- package/lib/src/brand-page/utils/migration.util.js +0 -11
- package/lib/src/brand-page/utils/text.util.d.ts +1 -13
- package/lib/src/brand-page/utils/text.util.js +0 -64
- 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/components/brand-page-maker/BrandPageMaker.mixin.d.ts +1 -1
- package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.js +51 -18
- 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/stores/brandpage-container/brand-page-container-store.js +8 -0
- package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +9 -3
- package/lib/src/brand-page-maker/stores/ui/ui-store.d.ts +1 -1
- 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/clients/models.d.ts +2 -0
- package/lib/src/css/library.brand-page-brandCrowd.min.css +99 -102
- package/lib/src/css/library.brand-page-brandPage.min.css +99 -102
- package/lib/src/css/library.brand-page-designCom.min.css +99 -102
- package/lib/src/css/library.brand-page-preview.min.css +99 -102
- package/lib/src/index.d.ts +1 -1
- package/lib/src/index.mjs +219 -221
- package/lib/src/{lib-scDtk-Pi.js → lib-DtalJKI3.js} +72445 -75920
- package/lib/src/lib.js +1 -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.d.ts +1 -1
- package/lib/src/shared/content-settings/components/GenericTextInput.mixin.js +10 -35
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.js +3 -12
- package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +1 -1
- 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-field/TextField.mixin.d.ts +1 -1
- 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/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +1 -1
- package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.d.ts +1 -1
- package/lib/src/shared/logo-versions/LogoVersions.mixin.d.ts +1 -1
- package/lib/src/shared/my-designs/MyDesigns.mixin.d.ts +1 -1
- package/lib/src/shared/my-uploads/MyMedia.mixin.d.ts +1 -1
- package/lib/src/shared/my-uploads/MyUploads.mixin.d.ts +1 -1
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +1 -1
- package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +1 -1
- package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +1 -1
- package/lib/src/shared/template-settings/components/TemplatesSettings.mixin.d.ts +1 -1
- package/lib/src/shared/template-settings/store/template-settings-store.d.ts +2 -0
- package/lib/src/shared/template-settings/store/template-settings-store.js +72 -24
- package/lib/src/shared/unsplash-library/UnsplashLibrary.mixin.d.ts +1 -1
- 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 +9 -7
- package/src/brand-page/components/cookie-consent/CookieConsent.vue +1 -0
- package/src/brand-page/components/mailing-list/MailingList.mixin.ts +8 -3
- package/src/brand-page/components/navigation/navigation-carousel/NavigationCarousel.vue +5 -1
- package/src/brand-page/components/page-footer/PageFooter.mixin.ts +4 -1
- package/src/brand-page/components/page-footer/PageFooter.vue +1 -1
- package/src/brand-page/components/payment/Payment.mixin.ts +4 -3
- package/src/brand-page/components/payment/request-payment/RequestPayment.mixin.ts +8 -1
- package/src/brand-page/components/payment/request-payment/RequestPayment.vue +1 -1
- package/src/brand-page/components/save-contact/SaveContactWidget.vue +2 -1
- package/src/brand-page/components/shop/Shop.vue +6 -3
- package/src/brand-page/components/social-links/SocialLinks.vue +5 -2
- package/src/brand-page/components/text-wrapper/TextWrapper.vue +6 -17
- package/src/brand-page/composables/consent.composable.ts +18 -4
- package/src/brand-page/models/brand-page-context.model.ts +7 -0
- 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/context.util.test.ts +43 -0
- package/src/brand-page/utils/context.util.ts +10 -0
- 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 -83
- 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 +4 -5
- package/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.test.ts +135 -0
- package/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.ts +62 -21
- 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/stores/brandpage-container/brand-page-container-store.ts +9 -0
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +11 -3
- package/src/brand-page-maker/stores/ui/ui-store.ts +1 -1
- 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/clients/models.ts +3 -0
- package/src/lib.ts +1 -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 +11 -42
- 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/shared/template-settings/store/template-settings-store.ts +93 -27
- 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 -66
- 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 -117
- package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +0 -63
- package/lib/src/brand-page-maker/utils/rte-render.util.js +0 -95
- 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 -156
- package/src/brand-page/utils/migration/migration-20.server.test.ts +0 -125
- package/src/brand-page/utils/migration/migration-20.test.ts +0 -72
- package/src/brand-page/utils/migration/migration-20.ts +0 -87
- package/src/brand-page/utils/text.util.test.ts +0 -106
- 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 -162
- 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 -378
- package/src/brand-page-maker/utils/rte-render-parity.test.ts +0 -164
- package/src/brand-page-maker/utils/rte-render.util.ts +0 -165
- package/src/brand-page-maker/utils/rte-sanitize-json.test.ts +0 -172
- package/src/brand-page-maker/utils/rte-sanitize.util.ts +0 -192
- package/src/brand-page-maker/validators/field.validator.test.ts +0 -55
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
-
import type { Editor } from '@tiptap/vue-3';
|
|
3
|
-
import { useExternalRichTextEditor } from '../composables/rich-text-editor/useRichTextEditor';
|
|
4
|
-
import {
|
|
5
|
-
renderRteToHtml,
|
|
6
|
-
toRteJson,
|
|
7
|
-
getRteJsonPlainText,
|
|
8
|
-
} from './rte-render.util';
|
|
9
|
-
import {
|
|
10
|
-
COMPLEX_RTE_HTML,
|
|
11
|
-
THEME_TOKEN_REF,
|
|
12
|
-
} from '../__fixtures__/complex-rte.fixture';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* BP-5787 — golden-master render-parity baseline.
|
|
16
|
-
*
|
|
17
|
-
* Captured on the CURRENT (HTML) pipeline. These committed snapshots are the
|
|
18
|
-
* frozen reference for the HTML→JSON migration:
|
|
19
|
-
*
|
|
20
|
-
* - `editor-normalized-html` is what the editor renders the fixture to today.
|
|
21
|
-
* After the migration, `htmlFromJson(getJSON())` MUST equal this snapshot —
|
|
22
|
-
* i.e. the JSON pipeline reproduces identical rendered output (no drift).
|
|
23
|
-
*
|
|
24
|
-
* - `editor-json` freezes the RAW editor output (`getJSON()`), pre-sanitise. It
|
|
25
|
-
* is expected to change exactly once, intentionally, when storage flips to JSON
|
|
26
|
-
* (see "expected vs forbidden drift" in docs/plans/BP-5787-...html). NOTE: this
|
|
27
|
-
* is not the persisted form — the stored doc runs through `sanitizeRteJson`
|
|
28
|
-
* first, which (for links) keeps href only; target/rel are render-derived and
|
|
29
|
-
* stripped from storage. The persisted link shape is pinned in
|
|
30
|
-
* rte-sanitize-json.test.ts, not here.
|
|
31
|
-
*
|
|
32
|
-
* The headless editor uses the SAME extension set as the live editor
|
|
33
|
-
* (useExternalRichTextEditor), so it is a faithful stand-in for the renderer.
|
|
34
|
-
*/
|
|
35
|
-
describe('BP-5787 RTE render parity (golden master)', () => {
|
|
36
|
-
let editor: Editor;
|
|
37
|
-
|
|
38
|
-
const makeEditor = (): Editor => {
|
|
39
|
-
editor = useExternalRichTextEditor().externalEditor;
|
|
40
|
-
|
|
41
|
-
return editor;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
editor?.destroy();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('T1 — editor renders the complex fixture to the frozen HTML baseline', () => {
|
|
49
|
-
const ed = makeEditor();
|
|
50
|
-
|
|
51
|
-
ed.commands.setContent(COMPLEX_RTE_HTML);
|
|
52
|
-
|
|
53
|
-
// The golden master. The JSON pipeline must reproduce this exactly.
|
|
54
|
-
expect(ed.getHTML()).toMatchSnapshot('editor-normalized-html');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('T1 — round-trip is idempotent (html → json → html === json → html)', () => {
|
|
58
|
-
const ed = makeEditor();
|
|
59
|
-
|
|
60
|
-
ed.commands.setContent(COMPLEX_RTE_HTML);
|
|
61
|
-
const pass1 = ed.getHTML();
|
|
62
|
-
const json = ed.getJSON();
|
|
63
|
-
|
|
64
|
-
// Re-seed from the JSON the migration will store, render again.
|
|
65
|
-
ed.commands.setContent(json);
|
|
66
|
-
const pass2 = ed.getHTML();
|
|
67
|
-
|
|
68
|
-
expect(pass2).toEqual(pass1);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('T6 — freezes the RAW editor JSON (pre-sanitise; not the persisted form)', () => {
|
|
72
|
-
// getJSON() is the editor's raw output: link marks still carry the parsed
|
|
73
|
-
// target/rel/class/title. The persisted form runs through sanitizeRteJson
|
|
74
|
-
// (href-only links) — pinned in rte-sanitize-json.test.ts, not here.
|
|
75
|
-
const ed = makeEditor();
|
|
76
|
-
|
|
77
|
-
ed.commands.setContent(COMPLEX_RTE_HTML);
|
|
78
|
-
|
|
79
|
-
expect(ed.getJSON()).toMatchSnapshot('editor-json');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('T2 — theme token ref survives the round-trip (NOT flattened to rgb)', () => {
|
|
83
|
-
const ed = makeEditor();
|
|
84
|
-
|
|
85
|
-
ed.commands.setContent(COMPLEX_RTE_HTML);
|
|
86
|
-
|
|
87
|
-
// Today the editor preserves var(--…) in marks; the migration must keep it.
|
|
88
|
-
expect(ed.getHTML()).toContain(THEME_TOKEN_REF);
|
|
89
|
-
expect(JSON.stringify(ed.getJSON())).toContain(THEME_TOKEN_REF);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('T1 — render-from-JSON (DOM-less renderer) produces equivalent HTML', () => {
|
|
93
|
-
// renderRteToHtml serialises JSON → HTML via @tiptap/static-renderer from the
|
|
94
|
-
// extension schema (no editor/DOM, so it works on the server). Output is
|
|
95
|
-
// semantically identical to the editor's — the Playwright visual golden-master
|
|
96
|
-
// is the authority on pixel parity; here we freeze the renderer output and
|
|
97
|
-
// assert structure.
|
|
98
|
-
const viaJson = renderRteToHtml(toRteJson(COMPLEX_RTE_HTML));
|
|
99
|
-
|
|
100
|
-
expect(viaJson).toMatchSnapshot('serializer-html');
|
|
101
|
-
|
|
102
|
-
// marks/nodes preserved
|
|
103
|
-
expect(viaJson).toContain('<strong>bold</strong>');
|
|
104
|
-
expect(viaJson).toContain('<em>italic</em>');
|
|
105
|
-
expect(viaJson).toContain('<u>underline</u>');
|
|
106
|
-
expect(viaJson).toContain('<s>strike</s>');
|
|
107
|
-
expect(viaJson).toContain('font-family: Montserrat');
|
|
108
|
-
expect(viaJson).toContain('font-size: 1.5em');
|
|
109
|
-
// theme token ref preserved (the crux of BP-5787)
|
|
110
|
-
expect(viaJson).toContain('color: var(--logo-text-color)');
|
|
111
|
-
// links + alignment
|
|
112
|
-
expect(viaJson).toContain('href="/about"');
|
|
113
|
-
expect(viaJson).toContain('href="https://example.com"');
|
|
114
|
-
expect(viaJson).toContain('text-align: center');
|
|
115
|
-
expect(viaJson).toContain('text-align: right');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('T4 — legacy HTML strings render unchanged (back-compat passthrough)', () => {
|
|
119
|
-
expect(renderRteToHtml(COMPLEX_RTE_HTML)).toEqual(COMPLEX_RTE_HTML);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('T8 — getRteJsonPlainText extracts visible text only (for fraud/translate)', () => {
|
|
123
|
-
const json = toRteJson(COMPLEX_RTE_HTML)!;
|
|
124
|
-
const text = getRteJsonPlainText(json);
|
|
125
|
-
|
|
126
|
-
expect(text).toContain('Themed heading');
|
|
127
|
-
expect(text).toContain('bold');
|
|
128
|
-
expect(text).toContain('external link');
|
|
129
|
-
// no markup or token refs leak into the extracted text
|
|
130
|
-
expect(text).not.toContain('<');
|
|
131
|
-
expect(text).not.toContain('var(--');
|
|
132
|
-
expect(text).not.toContain('font-size');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('T1 — preserves every mark/node in the fixture', () => {
|
|
136
|
-
const ed = makeEditor();
|
|
137
|
-
|
|
138
|
-
ed.commands.setContent(COMPLEX_RTE_HTML);
|
|
139
|
-
const html = ed.getHTML();
|
|
140
|
-
|
|
141
|
-
// bold / italic / underline / strike
|
|
142
|
-
expect(html).toContain('<strong>');
|
|
143
|
-
expect(html).toContain('<em>');
|
|
144
|
-
expect(html).toContain('<u>');
|
|
145
|
-
expect(html).toContain('<s>');
|
|
146
|
-
// textStyle attrs
|
|
147
|
-
expect(html).toContain('font-family: Montserrat');
|
|
148
|
-
expect(html).toContain('font-size: 1.5em');
|
|
149
|
-
expect(html).toContain('letter-spacing: 0.05em');
|
|
150
|
-
expect(html).toContain('line-height: 1.4');
|
|
151
|
-
// highlight: data-color keeps the hex; the rendered style is normalised to rgb()
|
|
152
|
-
expect(html).toContain('data-color="#ffe08a"');
|
|
153
|
-
expect(html).toContain('background-color: rgb(255, 224, 138)');
|
|
154
|
-
// literal hex colour is normalised to rgb() by the editor...
|
|
155
|
-
expect(html).toContain('color: rgb(255, 0, 0)');
|
|
156
|
-
// ...but the THEME TOKEN ref is preserved verbatim (the crux of BP-5787)
|
|
157
|
-
expect(html).toContain('color: var(--logo-text-color)');
|
|
158
|
-
expect(html).toContain('text-align: center');
|
|
159
|
-
expect(html).toContain('text-align: right');
|
|
160
|
-
// links
|
|
161
|
-
expect(html).toContain('href="/about"');
|
|
162
|
-
expect(html).toContain('href="https://example.com"');
|
|
163
|
-
});
|
|
164
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { getSchema } from '@tiptap/core';
|
|
2
|
-
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
|
|
3
|
-
import type { JSONContent } from '@tiptap/vue-3';
|
|
4
|
-
import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string';
|
|
5
|
-
import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
|
|
6
|
-
import { isTiptapDocument, getRteJsonPlainText } from './rich-text-editor.util';
|
|
7
|
-
|
|
8
|
-
export { isTiptapDocument, getRteJsonPlainText };
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* BP-5787 — the boundary plumbing for storing RTE as Tiptap JSON.
|
|
12
|
-
*
|
|
13
|
-
* An RTE field is either a legacy inline-HTML string (pre-migration, or a
|
|
14
|
-
* not-yet-migrated published site) or a Tiptap JSON document (post-migration).
|
|
15
|
-
* These helpers let every consumer treat the two uniformly:
|
|
16
|
-
*
|
|
17
|
-
* - isTiptapDocument — the canonical discriminator (object with type 'doc').
|
|
18
|
-
* - renderRteToHtml — render either form to final HTML for display.
|
|
19
|
-
* - toRteJson — normalise either form to JSON for storage.
|
|
20
|
-
*
|
|
21
|
-
* The schema-driven core of both directions is Tiptap/ProseMirror, fed by the
|
|
22
|
-
* single-source-of-truth extension set (getRteExtensions) — so the mapping lives
|
|
23
|
-
* in the extensions' schema, not a hand-maintained copy that drifts:
|
|
24
|
-
*
|
|
25
|
-
* - renderRteToHtml uses @tiptap/static-renderer (renderToHTMLString). It is
|
|
26
|
-
* DOM-less, so it runs on the server (SSR/publish/preview), not just the
|
|
27
|
-
* browser, and needs no Editor instance.
|
|
28
|
-
* - toRteJson turns an HTML string into a DOM node with an INJECTED parser and
|
|
29
|
-
* feeds it to ProseMirror's schema-driven DOMParser. The HTML→DOM step needs
|
|
30
|
-
* a DOM parser; rather than read the global `window.DOMParser` (as Tiptap's
|
|
31
|
-
* `generateJSON` does — unusable on a pure-node server and fragile across SSR
|
|
32
|
-
* execution contexts), the parser is supplied by the caller: the native
|
|
33
|
-
* `DOMParser` in the browser, a jsdom `DOMParser` *instance* on the server
|
|
34
|
-
* (see resolveHtmlParser). No global is ever mutated, so the conversion is
|
|
35
|
-
* fully isomorphic regardless of how the SSR host bundles/executes this code.
|
|
36
|
-
*/
|
|
37
|
-
export type RichTextValue = string | JSONContent;
|
|
38
|
-
|
|
39
|
-
// One extension set shared by both directions (render + parse). Schema knowledge
|
|
40
|
-
// lives here, once — Tiptap/extension upgrades propagate to both automatically.
|
|
41
|
-
const RTE_EXTENSIONS = getRteExtensions();
|
|
42
|
-
|
|
43
|
-
// ProseMirror schema derived once from the shared extension set. This is the
|
|
44
|
-
// schema-driven heart of HTML→JSON (no drift); only the HTML→DOM step is injected.
|
|
45
|
-
const RTE_SCHEMA = getSchema(RTE_EXTENSIONS);
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A `DOMParser`-compatible object: turns an HTML string into a Document whose
|
|
49
|
-
* `body` is a DOM node ProseMirror can parse. The browser's native `DOMParser`
|
|
50
|
-
* and jsdom's `DOMParser` both satisfy this, so the DOM dependency is injected
|
|
51
|
-
* rather than read from a global.
|
|
52
|
-
*/
|
|
53
|
-
export interface HtmlParser {
|
|
54
|
-
parseFromString(html: string, type: 'text/html'): Document;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Render an RTE value to final HTML. Legacy HTML strings pass through untouched
|
|
59
|
-
* (they are already HTML); Tiptap JSON is serialised by @tiptap/static-renderer
|
|
60
|
-
* from the extension schema — no editor, no DOM — so it works on the server
|
|
61
|
-
* (SSR/publish), not just the browser.
|
|
62
|
-
*/
|
|
63
|
-
export const renderRteToHtml = (
|
|
64
|
-
value: RichTextValue | null | undefined,
|
|
65
|
-
): string => {
|
|
66
|
-
if (value == null || value === '') return '';
|
|
67
|
-
|
|
68
|
-
if (typeof value === 'string') return value;
|
|
69
|
-
|
|
70
|
-
return renderToHTMLString({ content: value, extensions: RTE_EXTENSIONS });
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// Tiptap's elementFromString strips whitespace-only text nodes (pretty-print
|
|
74
|
-
// indentation between tags) before parsing; replicate it so injected-parser
|
|
75
|
-
// output stays identical to the previous generateJSON-based conversion.
|
|
76
|
-
const stripIndentationTextNodes = (node: Node): Node => {
|
|
77
|
-
const children = node.childNodes;
|
|
78
|
-
|
|
79
|
-
for (let i = children.length - 1; i >= 0; i -= 1) {
|
|
80
|
-
const child = children[i];
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
child.nodeType === 3 &&
|
|
84
|
-
child.nodeValue &&
|
|
85
|
-
/^(\n\s\s|\n)$/.test(child.nodeValue)
|
|
86
|
-
) {
|
|
87
|
-
node.removeChild(child);
|
|
88
|
-
} else if (child.nodeType === 1) {
|
|
89
|
-
stripIndentationTextNodes(child);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return node;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const htmlToRteJson = (html: string, parser: HtmlParser): JSONContent => {
|
|
97
|
-
const body = parser.parseFromString(`<body>${html}</body>`, 'text/html').body;
|
|
98
|
-
const dom = stripIndentationTextNodes(body);
|
|
99
|
-
|
|
100
|
-
return ProseMirrorDOMParser.fromSchema(RTE_SCHEMA).parse(dom).toJSON();
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
let serverParserPromise: Promise<HtmlParser> | null = null;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Resolve an HtmlParser for the current runtime: the native `DOMParser` in the
|
|
107
|
-
* browser (and jsdom-based test envs), or a jsdom `DOMParser` INSTANCE on a
|
|
108
|
-
* pure-node server — created and returned directly, never assigned to a global,
|
|
109
|
-
* so it is immune to SSR execution-context differences. The jsdom specifier is
|
|
110
|
-
* assembled at runtime so no bundler follows it into a browser build. Memoised
|
|
111
|
-
* on the server (JSDOM construction is relatively expensive).
|
|
112
|
-
*/
|
|
113
|
-
export const resolveHtmlParser = async (): Promise<HtmlParser> => {
|
|
114
|
-
if (typeof DOMParser !== 'undefined') return new DOMParser();
|
|
115
|
-
|
|
116
|
-
if (!serverParserPromise) {
|
|
117
|
-
const jsdomModuleId = ['js', 'dom'].join('');
|
|
118
|
-
|
|
119
|
-
serverParserPromise = import(/* @vite-ignore */ jsdomModuleId)
|
|
120
|
-
.then((mod) => {
|
|
121
|
-
const { JSDOM } = mod as {
|
|
122
|
-
JSDOM: new (html?: string) => {
|
|
123
|
-
window: { DOMParser: new () => HtmlParser };
|
|
124
|
-
};
|
|
125
|
-
};
|
|
126
|
-
const { DOMParser: JsdomDOMParser } = new JSDOM('').window;
|
|
127
|
-
|
|
128
|
-
return new JsdomDOMParser();
|
|
129
|
-
})
|
|
130
|
-
.catch((error) => {
|
|
131
|
-
// Don't cache the failure — let a later call retry the import.
|
|
132
|
-
serverParserPromise = null;
|
|
133
|
-
|
|
134
|
-
throw error;
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return serverParserPromise;
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const getDefaultParser = (): HtmlParser => {
|
|
142
|
-
if (typeof DOMParser !== 'undefined') return new DOMParser();
|
|
143
|
-
|
|
144
|
-
throw new Error(
|
|
145
|
-
'toRteJson: no global DOMParser in this environment. On the server, pass an ' +
|
|
146
|
-
'explicit parser as the second argument (see resolveHtmlParser).',
|
|
147
|
-
);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Normalise an RTE value to a Tiptap JSON document. HTML strings are parsed with
|
|
152
|
-
* `parser` (defaults to the browser's native DOMParser); JSON documents pass
|
|
153
|
-
* through unchanged. Returns undefined for empty/blank input so callers can omit
|
|
154
|
-
* empty fields.
|
|
155
|
-
*/
|
|
156
|
-
export const toRteJson = (
|
|
157
|
-
value: RichTextValue | null | undefined,
|
|
158
|
-
parser?: HtmlParser,
|
|
159
|
-
): JSONContent | undefined => {
|
|
160
|
-
if (value == null || value === '') return undefined;
|
|
161
|
-
|
|
162
|
-
if (isTiptapDocument(value)) return value;
|
|
163
|
-
|
|
164
|
-
return htmlToRteJson(value, parser ?? getDefaultParser());
|
|
165
|
-
};
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { sanitizeRteJson } from './rte-sanitize.util';
|
|
3
|
-
import { toRteJson } from './rte-render.util';
|
|
4
|
-
import { COMPLEX_RTE_HTML } from '../__fixtures__/complex-rte.fixture';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* BP-5787 — native JSON sanitiser (T5). Operates on the Tiptap tree directly
|
|
8
|
-
* (no editor, no HTML, no DOMPurify). Mirrors the HTML allowlist's guarantees.
|
|
9
|
-
*/
|
|
10
|
-
const doc = (content: unknown[]): unknown => ({ type: 'doc', content });
|
|
11
|
-
const para = (content: unknown[]) => ({ type: 'paragraph', content });
|
|
12
|
-
const text = (t: string, marks?: unknown[]) => ({
|
|
13
|
-
type: 'text',
|
|
14
|
-
text: t,
|
|
15
|
-
marks,
|
|
16
|
-
});
|
|
17
|
-
const json = (v: unknown) => JSON.stringify(v);
|
|
18
|
-
|
|
19
|
-
describe('sanitizeRteJson (native)', () => {
|
|
20
|
-
it('drops unknown node types but keeps their valid text siblings', () => {
|
|
21
|
-
const out = sanitizeRteJson(
|
|
22
|
-
doc([
|
|
23
|
-
para([text('keep me')]),
|
|
24
|
-
{ type: 'image', attrs: { src: 'x', onerror: 'alert(1)' } },
|
|
25
|
-
{ type: 'script', content: [text('alert(1)')] },
|
|
26
|
-
]),
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
expect(json(out)).toContain('keep me');
|
|
30
|
-
expect(json(out)).not.toContain('image');
|
|
31
|
-
expect(json(out)).not.toContain('script');
|
|
32
|
-
expect(json(out)).not.toContain('alert(1)');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('drops unknown mark types, keeps allow-listed ones', () => {
|
|
36
|
-
const out = sanitizeRteJson(
|
|
37
|
-
doc([
|
|
38
|
-
para([
|
|
39
|
-
text('bold', [{ type: 'bold' }]),
|
|
40
|
-
text('evil', [{ type: 'onclick', attrs: { js: 'alert(1)' } }]),
|
|
41
|
-
]),
|
|
42
|
-
]),
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
expect(json(out)).toContain('"type":"bold"');
|
|
46
|
-
expect(json(out)).not.toContain('onclick');
|
|
47
|
-
expect(json(out)).not.toContain('alert(1)');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('strips javascript: / data: hrefs from link marks (drops the mark)', () => {
|
|
51
|
-
const out = sanitizeRteJson(
|
|
52
|
-
doc([
|
|
53
|
-
para([
|
|
54
|
-
// eslint-disable-next-line no-script-url
|
|
55
|
-
text('click', [
|
|
56
|
-
{ type: 'link', attrs: { href: 'javascript:alert(1)' } },
|
|
57
|
-
]),
|
|
58
|
-
]),
|
|
59
|
-
]),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
expect(json(out)).toContain('click'); // text kept
|
|
63
|
-
expect(json(out)).not.toContain('javascript:');
|
|
64
|
-
expect(json(out)).not.toContain('"type":"link"');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('keeps safe links but stores href only (target/rel are render-derived)', () => {
|
|
68
|
-
// The persisted link shape is a one-way-door contract published sites depend
|
|
69
|
-
// on. The sanitiser validates the href and drops everything else: target/rel
|
|
70
|
-
// are NOT stored — they are derived from the href at render time in the Link
|
|
71
|
-
// mark's renderHTML (see getRteExtensions.ts). Incoming target/rel (e.g. from
|
|
72
|
-
// a legacy site) must be stripped here so the stored form stays canonical.
|
|
73
|
-
const out = sanitizeRteJson(
|
|
74
|
-
doc([
|
|
75
|
-
para([
|
|
76
|
-
text('site', [
|
|
77
|
-
{
|
|
78
|
-
type: 'link',
|
|
79
|
-
attrs: {
|
|
80
|
-
href: 'https://example.com',
|
|
81
|
-
target: '_blank',
|
|
82
|
-
rel: 'noopener noreferrer nofollow',
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
]),
|
|
86
|
-
]),
|
|
87
|
-
]),
|
|
88
|
-
) as { content: [{ content: [{ marks: unknown[] }] }] };
|
|
89
|
-
|
|
90
|
-
const linkMark = out.content[0].content[0].marks[0];
|
|
91
|
-
|
|
92
|
-
expect(linkMark).toEqual({
|
|
93
|
-
type: 'link',
|
|
94
|
-
attrs: { href: 'https://example.com' },
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('keeps safe textStyle attrs incl. theme token, drops injection values', () => {
|
|
99
|
-
const out = sanitizeRteJson(
|
|
100
|
-
doc([
|
|
101
|
-
para([
|
|
102
|
-
text('ok', [
|
|
103
|
-
{
|
|
104
|
-
type: 'textStyle',
|
|
105
|
-
attrs: {
|
|
106
|
-
color: 'var(--logo-text-color)',
|
|
107
|
-
fontFamily: 'Open Sans',
|
|
108
|
-
fontSize: '1.5em',
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
]),
|
|
112
|
-
text('bad', [
|
|
113
|
-
{
|
|
114
|
-
type: 'textStyle',
|
|
115
|
-
attrs: {
|
|
116
|
-
color: 'red; position: fixed; top: 0',
|
|
117
|
-
fontSize: '1em" onload="x',
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
]),
|
|
121
|
-
]),
|
|
122
|
-
]),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const s = json(out);
|
|
126
|
-
|
|
127
|
-
expect(s).toContain('var(--logo-text-color)');
|
|
128
|
-
expect(s).toContain('Open Sans');
|
|
129
|
-
expect(s).toContain('1.5em');
|
|
130
|
-
// injection values dropped
|
|
131
|
-
expect(s).not.toContain('position: fixed');
|
|
132
|
-
expect(s).not.toContain('onload');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('clamps heading level and keeps valid textAlign', () => {
|
|
136
|
-
const out: any = sanitizeRteJson(
|
|
137
|
-
doc([
|
|
138
|
-
{
|
|
139
|
-
type: 'heading',
|
|
140
|
-
attrs: { level: 99, textAlign: 'center' },
|
|
141
|
-
content: [text('H')],
|
|
142
|
-
},
|
|
143
|
-
{
|
|
144
|
-
type: 'paragraph',
|
|
145
|
-
attrs: { textAlign: 'evil' },
|
|
146
|
-
content: [text('P')],
|
|
147
|
-
},
|
|
148
|
-
]),
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
expect(out.content[0].attrs.level).toBe(1);
|
|
152
|
-
expect(out.content[0].attrs.textAlign).toBe('center');
|
|
153
|
-
expect(out.content[1].attrs?.textAlign).toBeUndefined();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('passes the clean complex fixture through unchanged (idempotent)', () => {
|
|
157
|
-
const clean = toRteJson(COMPLEX_RTE_HTML);
|
|
158
|
-
const once = sanitizeRteJson(clean);
|
|
159
|
-
const twice = sanitizeRteJson(once);
|
|
160
|
-
|
|
161
|
-
expect(twice).toEqual(once);
|
|
162
|
-
// legitimate content preserved
|
|
163
|
-
expect(json(once)).toContain('var(--logo-text-color)');
|
|
164
|
-
expect(json(once)).toContain('Montserrat');
|
|
165
|
-
expect(json(once)).toContain('https://example.com');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('returns an empty doc for non-document input', () => {
|
|
169
|
-
expect(sanitizeRteJson(null)).toEqual({ type: 'doc', content: [] });
|
|
170
|
-
expect(sanitizeRteJson('<p>x</p>')).toEqual({ type: 'doc', content: [] });
|
|
171
|
-
});
|
|
172
|
-
});
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import type { JSONContent } from '@tiptap/vue-3';
|
|
2
|
-
import { validateAndSanitizeHref } from './rich-text-editor.util';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* BP-5787 — native JSON sanitiser for RTE content.
|
|
6
|
-
*
|
|
7
|
-
* Operates directly on the Tiptap document tree: no editor instantiation, no
|
|
8
|
-
* HTML round-trip, no DOMPurify. This is the hot path (runs on every editor
|
|
9
|
-
* update and on the server sanitise gate), so it is a single, allocation-light
|
|
10
|
-
* recursive walk.
|
|
11
|
-
*
|
|
12
|
-
* Security model (mirrors the HTML allowlist it replaces):
|
|
13
|
-
* - only allow-listed node and mark types survive; everything else is dropped;
|
|
14
|
-
* - textStyle / highlight attrs are restricted to known keys whose values must
|
|
15
|
-
* be "safe CSS values" (no quotes/angle-brackets/extra declarations/url()/
|
|
16
|
-
* expression()), preventing CSS-injection when the value is later rendered
|
|
17
|
-
* into a `style="…"` attribute;
|
|
18
|
-
* - link hrefs go through the same validateAndSanitizeHref used by the HTML
|
|
19
|
-
* sanitiser; unsafe links lose their href (and the mark);
|
|
20
|
-
* - heading levels are clamped to 1–6.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
// ProseMirror node types the editor schema can legitimately produce.
|
|
24
|
-
const ALLOWED_NODE_TYPES = new Set([
|
|
25
|
-
'doc',
|
|
26
|
-
'paragraph',
|
|
27
|
-
'heading',
|
|
28
|
-
'text',
|
|
29
|
-
'hardBreak',
|
|
30
|
-
'bulletList',
|
|
31
|
-
'orderedList',
|
|
32
|
-
'listItem',
|
|
33
|
-
'blockquote',
|
|
34
|
-
'codeBlock',
|
|
35
|
-
'horizontalRule',
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
// Inline mark types the editor produces (strong/em/u/s/code/span/mark/a).
|
|
39
|
-
const ALLOWED_MARK_TYPES = new Set([
|
|
40
|
-
'bold',
|
|
41
|
-
'italic',
|
|
42
|
-
'underline',
|
|
43
|
-
'strike',
|
|
44
|
-
'code',
|
|
45
|
-
'textStyle',
|
|
46
|
-
'highlight',
|
|
47
|
-
'link',
|
|
48
|
-
]);
|
|
49
|
-
|
|
50
|
-
// textStyle attrs map to the allow-listed CSS properties (font-weight →
|
|
51
|
-
// bold mark, text-decoration → underline/strike, text-align → node attr,
|
|
52
|
-
// background-color → highlight, so they are intentionally not here).
|
|
53
|
-
const ALLOWED_TEXT_STYLE_ATTRS = new Set([
|
|
54
|
-
'color',
|
|
55
|
-
'fontFamily',
|
|
56
|
-
'fontSize',
|
|
57
|
-
'letterSpacing',
|
|
58
|
-
'lineHeight',
|
|
59
|
-
]);
|
|
60
|
-
|
|
61
|
-
type Mark = { type?: string; attrs?: Record<string, unknown> };
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* A safe CSS value is a short string free of characters that could inject an
|
|
65
|
-
* extra declaration or escape the attribute, and free of the url()/expression()
|
|
66
|
-
* vectors. Legitimate values — `rgb(255, 0, 0)`, `#FF0000`,
|
|
67
|
-
* `var(--logo-text-color)`, `Open Sans`, `1.5em` — all pass.
|
|
68
|
-
*/
|
|
69
|
-
const isSafeCssValue = (value: unknown): value is string =>
|
|
70
|
-
typeof value === 'string' &&
|
|
71
|
-
value.length > 0 &&
|
|
72
|
-
value.length <= 200 &&
|
|
73
|
-
!/[<>"';]/.test(value) &&
|
|
74
|
-
!/url\s*\(/i.test(value) &&
|
|
75
|
-
!/expression\s*\(/i.test(value);
|
|
76
|
-
|
|
77
|
-
const sanitizeTextStyleAttrs = (
|
|
78
|
-
attrs: Record<string, unknown>,
|
|
79
|
-
): Record<string, unknown> | null => {
|
|
80
|
-
const clean: Record<string, unknown> = {};
|
|
81
|
-
|
|
82
|
-
for (const key of Object.keys(attrs)) {
|
|
83
|
-
if (!ALLOWED_TEXT_STYLE_ATTRS.has(key)) continue;
|
|
84
|
-
|
|
85
|
-
const value = attrs[key];
|
|
86
|
-
|
|
87
|
-
// Tiptap emits '' for unset textStyle slots — preserve them.
|
|
88
|
-
if (value === '' || value == null) {
|
|
89
|
-
clean[key] = '';
|
|
90
|
-
} else if (isSafeCssValue(value)) {
|
|
91
|
-
clean[key] = value;
|
|
92
|
-
}
|
|
93
|
-
// else: drop the unsafe value
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return Object.keys(clean).length ? clean : null;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const sanitizeMark = (mark: Mark): Mark | null => {
|
|
100
|
-
const type = mark?.type;
|
|
101
|
-
|
|
102
|
-
if (!type || !ALLOWED_MARK_TYPES.has(type)) return null;
|
|
103
|
-
|
|
104
|
-
const attrs = mark.attrs;
|
|
105
|
-
|
|
106
|
-
if (type === 'link') {
|
|
107
|
-
const href =
|
|
108
|
-
typeof attrs?.href === 'string'
|
|
109
|
-
? validateAndSanitizeHref(attrs.href)
|
|
110
|
-
: null;
|
|
111
|
-
|
|
112
|
-
if (!href) return null; // drop links with unsafe/empty href
|
|
113
|
-
|
|
114
|
-
// href only — the sanitiser validates, it does not enrich. target/rel are
|
|
115
|
-
// a render-time policy derived from the href in the Link mark's renderHTML
|
|
116
|
-
// (see getRteExtensions.ts), so they are neither stored nor re-added here.
|
|
117
|
-
return { type, attrs: { href } };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (type === 'textStyle') {
|
|
121
|
-
const clean = attrs ? sanitizeTextStyleAttrs(attrs) : null;
|
|
122
|
-
|
|
123
|
-
return clean ? { type, attrs: clean } : null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (type === 'highlight') {
|
|
127
|
-
return isSafeCssValue(attrs?.color)
|
|
128
|
-
? { type, attrs: { color: attrs!.color } }
|
|
129
|
-
: { type };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Plain marks (bold/italic/underline/strike/code) carry no attrs.
|
|
133
|
-
return { type };
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify']);
|
|
137
|
-
|
|
138
|
-
const sanitizeNode = (node: JSONContent): JSONContent | null => {
|
|
139
|
-
const type = node?.type;
|
|
140
|
-
|
|
141
|
-
if (!type || !ALLOWED_NODE_TYPES.has(type)) return null;
|
|
142
|
-
|
|
143
|
-
const clean: JSONContent = { type };
|
|
144
|
-
const attrs: Record<string, unknown> = {};
|
|
145
|
-
|
|
146
|
-
if (type === 'heading') {
|
|
147
|
-
const level = Number((node.attrs as { level?: unknown })?.level);
|
|
148
|
-
|
|
149
|
-
attrs.level = level >= 1 && level <= 6 ? level : 1;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (type === 'heading' || type === 'paragraph') {
|
|
153
|
-
const align = (node.attrs as { textAlign?: unknown })?.textAlign;
|
|
154
|
-
|
|
155
|
-
if (typeof align === 'string' && ALIGN_VALUES.has(align)) {
|
|
156
|
-
attrs.textAlign = align;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (Object.keys(attrs).length) clean.attrs = attrs;
|
|
161
|
-
|
|
162
|
-
if (typeof node.text === 'string') clean.text = node.text;
|
|
163
|
-
|
|
164
|
-
if (Array.isArray(node.marks)) {
|
|
165
|
-
const marks = node.marks
|
|
166
|
-
.map((m) => sanitizeMark(m as Mark))
|
|
167
|
-
.filter(Boolean) as JSONContent['marks'];
|
|
168
|
-
|
|
169
|
-
if (marks && marks.length) clean.marks = marks;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (Array.isArray(node.content)) {
|
|
173
|
-
const content = node.content
|
|
174
|
-
.map(sanitizeNode)
|
|
175
|
-
.filter(Boolean) as JSONContent[];
|
|
176
|
-
|
|
177
|
-
if (content.length) clean.content = content;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return clean;
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Sanitise a Tiptap JSON document (returns a new clean doc). Returns a minimal
|
|
185
|
-
* empty doc if the input isn't a usable document.
|
|
186
|
-
*/
|
|
187
|
-
export const sanitizeRteJson = (doc: unknown): JSONContent => {
|
|
188
|
-
const clean =
|
|
189
|
-
doc && typeof doc === 'object' ? sanitizeNode(doc as JSONContent) : null;
|
|
190
|
-
|
|
191
|
-
return clean && clean.type === 'doc' ? clean : { type: 'doc', content: [] };
|
|
192
|
-
};
|