@designcrowd/library.brand-page 6.0.25-json-prereq-2 → 6.0.25-json-prereq-3

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 (23) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-MkdpvvLq.js → CreatedOnBrandCrowd-de-de-CEuDKci2.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-0K8Va8jv.js → CreatedOnBrandCrowd-en-us-_e-17FIJ.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BXBXK85u.js → CreatedOnBrandCrowd-es-es-BDBBH71W.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-wbrMlisb.js → CreatedOnBrandCrowd-fr-ca-BQQ4n0rM.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-wbrMlisb.js → CreatedOnBrandCrowd-fr-fr-BQQ4n0rM.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-BHBKnYHd.js → CreatedOnBrandCrowd-pt-br-CXEm8WP0.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-BHBKnYHd.js → CreatedOnBrandCrowd-pt-pt-CXEm8WP0.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BeG5A3Bb.js → CreatedOnDcom-de-de-Cd-wd_qJ.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-DVCNhdLS.js → CreatedOnDcom-en-us-CAX3HD_S.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-Ba8bq852.js → CreatedOnDcom-es-es-dw8fVEr5.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-DuNhf0PH.js → CreatedOnDcom-fr-ca-BdytTU6m.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CfofYZSL.js → CreatedOnDcom-fr-fr-CXDKOnJq.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-BMCBFLhc.js → CreatedOnDcom-pt-br-35D-ZF8c.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-CQS6Ft_9.js → CreatedOnDcom-pt-pt-wCqTuj1V.js} +1 -1
  15. package/lib/src/brand-page/utils/migration/migration-20.js +20 -65
  16. package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +33 -11
  17. package/lib/src/brand-page-maker/utils/rte-render.util.js +68 -6
  18. package/lib/src/index.mjs +1 -1
  19. package/lib/src/{lib-QPQAbiCG.js → lib-Ca6p-H2z.js} +7172 -7167
  20. package/package.json +1 -1
  21. package/src/brand-page/utils/migration/migration-20.server.test.ts +18 -14
  22. package/src/brand-page/utils/migration/migration-20.ts +28 -93
  23. package/src/brand-page-maker/utils/rte-render.util.ts +109 -12
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "6.0.25-json-prereq-2",
4
+ "version": "6.0.25-json-prereq-3",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -1,16 +1,13 @@
1
1
  /**
2
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.
3
+ * just the browser. The HTML→JSON parse needs a DOM parser, which pure-node SSR
4
+ * lacks. migration-20 resolves one via `resolveHtmlParser` — a jsdom `DOMParser`
5
+ * INSTANCE on the server (no global mutation), threaded explicitly into the pass.
5
6
  *
6
7
  * 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`.)
8
+ * `window`/`document`/`DOMParser` around each case, so `resolveHtmlParser` takes
9
+ * its real pure-node branch (dynamic jsdom import → parser instance). The sibling
10
+ * migration-20.test.ts guards the browser path (native global DOMParser).
14
11
  */
15
12
  import { describe, test, expect, beforeEach, afterEach } from 'vitest';
16
13
  import { migration20 } from './migration-20';
@@ -27,25 +24,31 @@ const model = (contents: any[]) => ({
27
24
  const g = globalThis as any;
28
25
  let savedWindow: unknown;
29
26
  let savedDocument: unknown;
27
+ let savedDOMParser: unknown;
30
28
 
31
29
  describe('migration20 (server / no DOM) — HTML RTE → Tiptap JSON', () => {
32
30
  beforeEach(() => {
33
- // Force migration-20's DOM-less server branch.
31
+ // Force the pure-node path: no global window/document AND no global
32
+ // DOMParser, so resolveHtmlParser must load jsdom itself.
34
33
  savedWindow = g.window;
35
34
  savedDocument = g.document;
35
+ savedDOMParser = g.DOMParser;
36
36
  delete g.window;
37
37
  delete g.document;
38
+ delete g.DOMParser;
38
39
 
39
40
  expect(typeof window).toBe('undefined');
40
41
  expect(typeof document).toBe('undefined');
42
+ expect(typeof DOMParser).toBe('undefined');
41
43
  });
42
44
 
43
45
  afterEach(() => {
44
46
  g.window = savedWindow;
45
47
  g.document = savedDocument;
48
+ g.DOMParser = savedDOMParser;
46
49
  });
47
50
 
48
- test('converts a top-level HTML RTE field with no global window', async () => {
51
+ test('converts a top-level HTML RTE field with no global DOM', async () => {
49
52
  const data = model([
50
53
  {
51
54
  id: '1',
@@ -106,16 +109,17 @@ describe('migration20 (server / no DOM) — HTML RTE → Tiptap JSON', () => {
106
109
  expect(c.titleStyle).toEqual({ color: '#fff', fontSize: '1em' });
107
110
  });
108
111
 
109
- test('does not leak the jsdom window into globalThis after the pass', async () => {
112
+ test('does not mutate globalThis (no window/document/DOMParser leak)', async () => {
110
113
  const data = model([
111
114
  { id: '4', type: 'banner-section', title: '<p>Leak check</p>' },
112
115
  ]);
113
116
 
114
117
  await migration20(createMigrationData(data));
115
118
 
116
- // The transient jsdom globals installed during the pass must be restored
117
- // to their pre-pass state (deleted here), not leaked.
119
+ // The pass resolves a parser INSTANCE and never assigns to globalThis, so the
120
+ // deleted globals must stay absent — no transient install, nothing to leak.
118
121
  expect(typeof window).toBe('undefined');
119
122
  expect(typeof document).toBe('undefined');
123
+ expect(typeof DOMParser).toBe('undefined');
120
124
  });
121
125
  });
@@ -1,6 +1,11 @@
1
1
  import type { BrandPageModel } from '../../models';
2
2
  import type { MigrationData } from '../migration.util';
3
- import { hasRTEContent, toRteJson } from '../../../brand-page-maker/utils';
3
+ import {
4
+ hasRTEContent,
5
+ toRteJson,
6
+ resolveHtmlParser,
7
+ type HtmlParser,
8
+ } from '../../../brand-page-maker/utils';
4
9
 
5
10
  /**
6
11
  * BP-5787 — migrate existing inline-HTML RTE content to Tiptap JSON.
@@ -9,9 +14,8 @@ import { hasRTEContent, toRteJson } from '../../../brand-page-maker/utils';
9
14
  * published/saved sites still hold RTE fields as HTML strings. This converts
10
15
  * them on load, in EVERY environment:
11
16
  * - 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.
17
+ * - web-renderer (SSR / server API routes): converted on read, so old sites
18
+ * are normalised without a re-save and downstream code can treat RTE as JSON.
15
19
  *
16
20
  * This is a one-time, versioned migration (runAlways is NOT set): legacy sites
17
21
  * are at model version < 20 and convert on first load in any environment; once
@@ -19,6 +23,12 @@ import { hasRTEContent, toRteJson } from '../../../brand-page-maker/utils';
19
23
  * AI section builders now emit Tiptap JSON directly (they no longer write HTML),
20
24
  * so there is no recurring post-load source of HTML to normalise.
21
25
  *
26
+ * HTML→JSON parsing needs a DOM parser. Rather than mutate a global `window`,
27
+ * the parser is resolved once (native `DOMParser` in the browser, a jsdom
28
+ * `DOMParser` instance on the server) and threaded explicitly through the
29
+ * conversion — see resolveHtmlParser / toRteJson. No global state is touched, so
30
+ * the pass is safe on any server runtime and under concurrent requests.
31
+ *
22
32
  * Scope: only HTML-RTE strings (hasRTEContent) are converted. Plain strings,
23
33
  * numbers, and already-JSON docs are left untouched, so the migration is
24
34
  * idempotent (re-running on JSON content is a no-op — non-string values are
@@ -26,9 +36,9 @@ import { hasRTEContent, toRteJson } from '../../../brand-page-maker/utils';
26
36
  * is handled by recursing into arrays; style objects are intentionally not
27
37
  * descended into (they never hold RTE content).
28
38
  */
29
- const convertRteStringsToJson = (value: unknown): void => {
39
+ const convertRteStringsToJson = (value: unknown, parser: HtmlParser): void => {
30
40
  if (Array.isArray(value)) {
31
- value.forEach((item) => convertRteStringsToJson(item));
41
+ value.forEach((item) => convertRteStringsToJson(item, parser));
32
42
 
33
43
  return;
34
44
  }
@@ -41,112 +51,37 @@ const convertRteStringsToJson = (value: unknown): void => {
41
51
  const field = record[key];
42
52
 
43
53
  if (typeof field === 'string' && hasRTEContent(field)) {
44
- const json = toRteJson(field);
54
+ const json = toRteJson(field, parser);
45
55
 
46
56
  if (json) record[key] = json;
47
57
  } else if (Array.isArray(field)) {
48
- convertRteStringsToJson(field);
58
+ convertRteStringsToJson(field, parser);
49
59
  }
50
60
  });
51
61
  };
52
62
 
53
- const convertModel = (brandPageModel: BrandPageModel): void => {
63
+ const convertModel = (
64
+ brandPageModel: BrandPageModel,
65
+ parser: HtmlParser,
66
+ ): void => {
54
67
  for (const page of brandPageModel.pages) {
55
68
  for (const content of page.contents) {
56
- convertRteStringsToJson(content);
69
+ convertRteStringsToJson(content, parser);
57
70
  }
58
71
  }
59
72
  };
60
73
 
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
74
  export const migration20 = async (
111
75
  migrationResult: MigrationData,
112
76
  ): Promise<MigrationData> => {
113
77
  const { brandPageModel } = migrationResult;
114
78
 
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;
79
+ // Resolve a DOM parser for this environment (native in the browser, a jsdom
80
+ // instance on the server — never touching globals), then convert every
81
+ // HTML-RTE field to Tiptap JSON with it.
82
+ const parser = await resolveHtmlParser();
140
83
 
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
- }
84
+ convertModel(brandPageModel, parser);
150
85
 
151
86
  return migrationResult;
152
87
  };
@@ -1,4 +1,5 @@
1
- import { generateJSON } from '@tiptap/core';
1
+ import { getSchema } from '@tiptap/core';
2
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
2
3
  import type { JSONContent } from '@tiptap/vue-3';
3
4
  import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string';
4
5
  import { getRteExtensions } from '../composables/rich-text-editor/getRteExtensions';
@@ -17,18 +18,21 @@ export { isTiptapDocument, getRteJsonPlainText };
17
18
  * - renderRteToHtml — render either form to final HTML for display.
18
19
  * - toRteJson — normalise either form to JSON for storage.
19
20
  *
20
- * Both directions delegate to Tiptap's own schema-driven converters, fed by the
21
+ * The schema-driven core of both directions is Tiptap/ProseMirror, fed by the
21
22
  * single-source-of-truth extension set (getRteExtensions) — so the mapping lives
22
- * in the extensions' `renderHTML`, not a hand-maintained copy that drifts when
23
- * Tiptap changes:
23
+ * in the extensions' schema, not a hand-maintained copy that drifts:
24
24
  *
25
25
  * - renderRteToHtml uses @tiptap/static-renderer (renderToHTMLString). It is
26
26
  * DOM-less, so it runs on the server (SSR/publish/preview), not just the
27
27
  * browser, and needs no Editor instance.
28
- * - toRteJson uses generateJSON, which PARSES HTML and so needs a DOM
29
- * (window.DOMParser). In the browser and in jsdom tests that global exists;
30
- * on the server the only caller is migration-20, which installs a jsdom
31
- * window around its conversion pass (see migration/migration-20.ts).
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.
32
36
  */
33
37
  export type RichTextValue = string | JSONContent;
34
38
 
@@ -36,6 +40,20 @@ export type RichTextValue = string | JSONContent;
36
40
  // lives here, once — Tiptap/extension upgrades propagate to both automatically.
37
41
  const RTE_EXTENSIONS = getRteExtensions({ includeLinkRel: true });
38
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
+
39
57
  /**
40
58
  * Render an RTE value to final HTML. Legacy HTML strings pass through untouched
41
59
  * (they are already HTML); Tiptap JSON is serialised by @tiptap/static-renderer
@@ -52,17 +70,96 @@ export const renderRteToHtml = (
52
70
  return renderToHTMLString({ content: value, extensions: RTE_EXTENSIONS });
53
71
  };
54
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
+
55
150
  /**
56
- * Normalise an RTE value to a Tiptap JSON document. HTML strings are parsed via
57
- * generateJSON (needs a DOM — browser/jsdom); documents pass through unchanged.
58
- * Returns undefined for empty/blank input so callers can omit empty fields.
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.
59
155
  */
60
156
  export const toRteJson = (
61
157
  value: RichTextValue | null | undefined,
158
+ parser?: HtmlParser,
62
159
  ): JSONContent | undefined => {
63
160
  if (value == null || value === '') return undefined;
64
161
 
65
162
  if (isTiptapDocument(value)) return value;
66
163
 
67
- return generateJSON(value, RTE_EXTENSIONS);
164
+ return htmlToRteJson(value, parser ?? getDefaultParser());
68
165
  };