@developer_tribe/react-builder 1.2.18 → 1.2.19

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 (36) hide show
  1. package/dist/index.cjs.js +1 -1
  2. package/dist/index.cjs.js.map +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.web.cjs.js +6 -6
  6. package/dist/index.web.cjs.js.map +1 -1
  7. package/dist/index.web.esm.js +3 -3
  8. package/dist/index.web.esm.js.map +1 -1
  9. package/dist/pages/ProjectDebug.d.ts +9 -1
  10. package/dist/pages/ProjectMigrationPage.d.ts +3 -1
  11. package/dist/pages/ProjectValidationPage.d.ts +3 -2
  12. package/dist/styles.css +1 -1
  13. package/dist/utils/applyJsonTransform.d.ts +13 -0
  14. package/dist/utils/repairNodeKeys.d.ts +11 -0
  15. package/dist/utils/safeJsonStringify.d.ts +1 -0
  16. package/dist/utils/wrapNodeInMain.d.ts +2 -0
  17. package/package.json +1 -1
  18. package/src/RenderPage.tsx +17 -46
  19. package/src/assets/meta.json +1 -1
  20. package/src/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
  21. package/src/build-components/Text/Text.tsx +4 -9
  22. package/src/components/AttributesEditorPanel.tsx +13 -1
  23. package/src/components/Builder.tsx +19 -5
  24. package/src/components/EditorHeader.tsx +16 -6
  25. package/src/components/JsonTextEditor.tsx +41 -0
  26. package/src/pages/DebugJsonPage.tsx +24 -3
  27. package/src/pages/ProjectDebug.tsx +66 -28
  28. package/src/pages/ProjectMigrationPage.tsx +15 -0
  29. package/src/pages/ProjectPage.tsx +160 -23
  30. package/src/pages/ProjectValidationPage.tsx +64 -1
  31. package/src/styles/layout/_project-validation.scss +29 -0
  32. package/src/utils/__special_exceptions.ts +9 -3
  33. package/src/utils/applyJsonTransform.ts +19 -0
  34. package/src/utils/repairNodeKeys.ts +90 -0
  35. package/src/utils/safeJsonStringify.ts +18 -0
  36. package/src/utils/wrapNodeInMain.ts +67 -0
@@ -36,6 +36,8 @@ import {
36
36
  import type { Fonts } from '../types/Fonts';
37
37
  import { useProjectFonts } from '../hooks/useProjectFonts';
38
38
  import { resolveProjectForSave } from './projectPageUtils';
39
+ import { getDefaultProject } from '../utils/getDefaultProject';
40
+ import { CURRENT_PROJECT_VERSION } from '../migrations/migratePipe';
39
41
  export type ProjectPageProps = {
40
42
  project: Project;
41
43
  onSaveProject: (project: Project) => void;
@@ -75,10 +77,6 @@ export function ProjectPage({
75
77
  typography.fonts.find((f) => f?.isMain)?.name ??
76
78
  typography.fonts[0]?.name;
77
79
  useProjectFonts({ fonts: typography.fonts, appFont: resolvedAppFont });
78
- const resolvedName = name ?? project.name;
79
- const resolvedProjectColors = projectColors ?? project.projectColors;
80
- const isEmptyProjectData =
81
- isNodeNullOrUndefined(project.data) || isEmptyObject(project.data);
82
80
  // useRenderStore will be removed
83
81
  const {
84
82
  current,
@@ -103,10 +101,15 @@ export function ProjectPage({
103
101
  }));
104
102
  const resolvedAppConfig = appConfig ?? storeAppConfig ?? defaultAppConfig;
105
103
  const [overrideProject, setOverrideProject] = useState<Project | null>(null);
104
+ const activeProject = overrideProject ?? project;
105
+ const resolvedName = name ?? activeProject.name;
106
+ const resolvedProjectColors = projectColors ?? activeProject.projectColors;
107
+ const isEmptyProjectData =
108
+ isNodeNullOrUndefined(activeProject.data) ||
109
+ isEmptyObject(activeProject.data);
106
110
  const [editorData, setEditorData] = useState<Node>(() => {
107
- if (!isEmptyProjectData) return null;
108
- // Empty project should start in a usable state (no loader / no error).
109
- return analyseAndProccess({ type: 'Main', children: [] }) as Node;
111
+ // Empty project: keep data null-ish, show empty state.
112
+ return null;
110
113
  });
111
114
  const [validationError, setValidationError] = useState<string | null>(null);
112
115
  const [validationErrorStack, setValidationErrorStack] = useState<
@@ -221,7 +224,7 @@ export function ProjectPage({
221
224
  setMinLoadingDelayDone(false);
222
225
  const timer = setTimeout(() => setMinLoadingDelayDone(true), 1000);
223
226
  return () => clearTimeout(timer);
224
- }, [project.data]);
227
+ }, [activeProject.data]);
225
228
 
226
229
  useEffect(() => {
227
230
  try {
@@ -234,7 +237,7 @@ export function ProjectPage({
234
237
  setValidationError(null);
235
238
  setValidationErrorStack(null);
236
239
  // Version gate: if project is older than the current schema, show migration UI.
237
- const pipe = getMigrationPipe(project);
240
+ const pipe = getMigrationPipe(activeProject);
238
241
  if (!bypassValidation && pipe.required) {
239
242
  setMigrationGate(pipe);
240
243
  setEditorData(null);
@@ -245,13 +248,17 @@ export function ProjectPage({
245
248
  if (bypassValidation) {
246
249
  // Best-effort: let the user continue with the raw data even if invalid.
247
250
  // This may still crash the preview, but it unblocks users for debugging.
248
- setEditorData(project.data as unknown as Node);
249
- setCurrent(project.data as unknown as Node);
251
+ setEditorData(activeProject.data as unknown as Node);
252
+ setCurrent(activeProject.data as unknown as Node);
250
253
  return;
251
254
  }
252
- const inputNode: Node = isEmptyProjectData
253
- ? { type: 'Main', children: [] }
254
- : (project.data as Node);
255
+ if (isEmptyProjectData) {
256
+ setEditorData(null);
257
+ setCurrent(null);
258
+ return;
259
+ }
260
+
261
+ const inputNode: Node = activeProject.data as Node;
255
262
 
256
263
  const processed = analyseAndProccess(inputNode);
257
264
  if (!processed) return;
@@ -268,7 +275,7 @@ export function ProjectPage({
268
275
  setEditorData(null);
269
276
  setCurrent(null);
270
277
  }
271
- }, [project, project.data, bypassValidation, setCurrent]);
278
+ }, [activeProject, activeProject.data, bypassValidation, setCurrent]);
272
279
 
273
280
  const showLoading =
274
281
  !isEmptyProjectData && (editorData === null || !minLoadingDelayDone);
@@ -322,9 +329,10 @@ export function ProjectPage({
322
329
  {migrationGate ? (
323
330
  <ProjectMigrationPage
324
331
  name={resolvedName}
325
- rawData={project.data}
332
+ rawData={activeProject}
326
333
  projectVersion={migrationGate.projectVersion}
327
334
  requiredVersion={migrationGate.requiredVersion}
335
+ showFixVersionMeta={migrationGate.projectVersion === '0.0.0'}
328
336
  pendingMigrations={migrationGate.pending.map((m) => ({
329
337
  id: m.id,
330
338
  title: m.title,
@@ -338,15 +346,37 @@ export function ProjectPage({
338
346
  onContinueWithoutValidation={() => {
339
347
  setBypassValidation(true);
340
348
  setMigrationGate(null);
341
- setEditorData(project.data as unknown as Node);
342
- setCurrent(project.data as unknown as Node);
349
+ setEditorData(activeProject.data as unknown as Node);
350
+ setCurrent(activeProject.data as unknown as Node);
343
351
  setMinLoadingDelayDone(true);
344
352
  }}
345
353
  onMigrateNow={() => {
346
354
  try {
347
355
  setIsMigrating(true);
356
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
357
+ typeof v === 'object' && v !== null && !Array.isArray(v);
358
+ const isNodeLike = (v: unknown): v is { type: string } =>
359
+ isRecord(v) &&
360
+ typeof v.type === 'string' &&
361
+ v.type.trim().length > 0;
362
+
363
+ // If the incoming "project" is actually a raw Node (no version),
364
+ // wrap it into a valid Project shape before migrating/saving.
365
+ const projectForMigration: Project =
366
+ isNodeLike(activeProject) &&
367
+ !(
368
+ isRecord(activeProject) &&
369
+ typeof (activeProject as any).version === 'string'
370
+ )
371
+ ? getDefaultProject({
372
+ name: `imported-${Math.random().toString(36).slice(2, 8)}`,
373
+ version: CURRENT_PROJECT_VERSION,
374
+ data: activeProject as unknown as Node,
375
+ })
376
+ : activeProject;
377
+
348
378
  const { project: migratedProject } =
349
- runProjectMigrations(project);
379
+ runProjectMigrations(projectForMigration);
350
380
  onSaveProject(migratedProject);
351
381
  setOverrideProject(migratedProject);
352
382
  setBypassValidation(true);
@@ -358,11 +388,61 @@ export function ProjectPage({
358
388
  setIsMigrating(false);
359
389
  }
360
390
  }}
391
+ onFixVersionMeta={() => {
392
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
393
+ typeof v === 'object' && v !== null && !Array.isArray(v);
394
+ const isNodeLike = (v: unknown): v is { type: string } =>
395
+ isRecord(v) &&
396
+ typeof v.type === 'string' &&
397
+ v.type.trim().length > 0;
398
+ const isAllowedProjectType = (
399
+ v: unknown,
400
+ ): v is 'paywall' | 'onboard' | 'other' =>
401
+ v === 'paywall' || v === 'onboard' || v === 'other';
402
+
403
+ const fixedName =
404
+ typeof (activeProject as any)?.name === 'string' &&
405
+ String((activeProject as any).name).trim()
406
+ ? String((activeProject as any).name).trim()
407
+ : `imported-${Math.random().toString(36).slice(2, 8)}`;
408
+
409
+ const activeAny = (
410
+ isRecord(activeProject) ? activeProject : null
411
+ ) as Record<string, unknown> | null;
412
+ const nodeCandidate = (
413
+ activeAny && 'data' in activeAny
414
+ ? (activeAny as any).data
415
+ : isNodeLike(activeProject)
416
+ ? (activeProject as unknown as Node)
417
+ : null
418
+ ) as Node | null;
419
+
420
+ const fixedProject = getDefaultProject({
421
+ name: fixedName,
422
+ version: CURRENT_PROJECT_VERSION,
423
+ data: nodeCandidate,
424
+ appConfig: (activeAny as any)?.appConfig,
425
+ projectColors: (activeAny as any)?.projectColors,
426
+ type: isAllowedProjectType((activeAny as any)?.type)
427
+ ? ((activeAny as any).type as any)
428
+ : undefined,
429
+ });
430
+
431
+ // This action only fixes project metadata. It intentionally does NOT
432
+ // validate/normalize node data (it might still be invalid).
433
+ onSaveProject(fixedProject);
434
+ setOverrideProject(fixedProject);
435
+ setBypassValidation(false);
436
+ setMigrationGate(null);
437
+ setValidationError(null);
438
+ setValidationErrorStack(null);
439
+ toast.success('Fixed version meta');
440
+ }}
361
441
  />
362
442
  ) : validationError ? (
363
443
  <ProjectValidationPage
364
444
  name={resolvedName}
365
- rawData={project.data}
445
+ rawData={activeProject}
366
446
  validationError={validationError}
367
447
  validationErrorStack={validationErrorStack ?? undefined}
368
448
  products={products}
@@ -372,10 +452,67 @@ export function ProjectPage({
372
452
  setBypassValidation(true);
373
453
  setValidationError(null);
374
454
  setValidationErrorStack(null);
375
- setEditorData(project.data as unknown as Node);
376
- setCurrent(project.data as unknown as Node);
455
+ setEditorData(activeProject.data as unknown as Node);
456
+ setCurrent(activeProject.data as unknown as Node);
377
457
  setMinLoadingDelayDone(true);
378
458
  }}
459
+ onSaveEditedRawData={(nextRawData) => {
460
+ try {
461
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
462
+ typeof v === 'object' && v !== null && !Array.isArray(v);
463
+
464
+ // Accept both:
465
+ // - a raw Node (what the app uses internally)
466
+ // - a full Project-like wrapper { name, version, data: Node }
467
+ const parsed = nextRawData;
468
+ let nodeCandidate: unknown = parsed;
469
+ let nextName: string | undefined;
470
+ let nextVersion: string | undefined;
471
+
472
+ if (isRecord(parsed) && 'data' in parsed) {
473
+ nodeCandidate = (parsed as Record<string, unknown>).data;
474
+ const maybeName = (parsed as Record<string, unknown>).name;
475
+ const maybeVersion = (parsed as Record<string, unknown>)
476
+ .version;
477
+ if (typeof maybeName === 'string') nextName = maybeName;
478
+ if (typeof maybeVersion === 'string')
479
+ nextVersion = maybeVersion;
480
+ }
481
+
482
+ const processed = analyseAndProccess(nodeCandidate as Node);
483
+ if (!processed) throw new Error('Node is not valid');
484
+
485
+ const nextProject: Project = {
486
+ ...activeProject,
487
+ ...(nextName ? { name: nextName } : null),
488
+ ...(nextVersion ? { version: nextVersion } : null),
489
+ data: nodeCandidate as Project['data'],
490
+ };
491
+
492
+ onSaveProject(nextProject);
493
+ setOverrideProject(nextProject);
494
+ setBypassValidation(false);
495
+ setValidationError(null);
496
+ setValidationErrorStack(null);
497
+ setEditorData(processed);
498
+ setCurrent(processed);
499
+ setMinLoadingDelayDone(true);
500
+ toast.success('Saved');
501
+ } catch (e) {
502
+ logger.error(
503
+ 'ProjectPage',
504
+ 'save JSON from validation failed',
505
+ e,
506
+ );
507
+ setValidationError(
508
+ e instanceof Error ? e.message : 'Node is not valid',
509
+ );
510
+ setValidationErrorStack(
511
+ e instanceof Error ? (e.stack ?? null) : null,
512
+ );
513
+ toast.error('Save failed');
514
+ }
515
+ }}
379
516
  />
380
517
  ) : (
381
518
  <>
@@ -477,7 +614,7 @@ export function ProjectPage({
477
614
  </div>
478
615
  )}
479
616
  {/* NOTE: In React Native, `products` should come from an IAP wrapper (e.g. `react-native-iap`). */}
480
- {!showLoading && editorData && (
617
+ {!showLoading && (
481
618
  <RenderPage
482
619
  data={editorData}
483
620
  name={resolvedName}
@@ -1,7 +1,10 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode, useEffect, useState } from 'react';
2
2
  import type { Product } from '../paywall/types/paywall-types';
3
3
  import type { PaywallBenefits } from '../paywall/types/benefits';
4
4
  import { ProjectDebug } from './ProjectDebug';
5
+ import { safeJsonStringify } from '../utils/safeJsonStringify';
6
+ import type { Node } from '../types/Node';
7
+ import { wrapNodeInMain } from '../utils/wrapNodeInMain';
5
8
 
6
9
  export type ProjectValidationPageProps = {
7
10
  name: string;
@@ -13,6 +16,7 @@ export type ProjectValidationPageProps = {
13
16
  canvasBg?: string;
14
17
  belowName?: ReactNode;
15
18
  onContinueWithoutValidation?: () => void;
19
+ onSaveEditedRawData?: (nextRawData: unknown) => void;
16
20
  };
17
21
 
18
22
  export function ProjectValidationPage({
@@ -25,7 +29,40 @@ export function ProjectValidationPage({
25
29
  canvasBg,
26
30
  belowName,
27
31
  onContinueWithoutValidation,
32
+ onSaveEditedRawData,
28
33
  }: ProjectValidationPageProps) {
34
+ const [jsonText, setJsonText] = useState<string>(() =>
35
+ safeJsonStringify(rawData),
36
+ );
37
+ const [jsonParseError, setJsonParseError] = useState<string | null>(null);
38
+
39
+ const wrapInMain = () => {
40
+ try {
41
+ const parsed = JSON.parse(jsonText) as unknown;
42
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
43
+ typeof v === 'object' && v !== null && !Array.isArray(v);
44
+
45
+ const nodeCandidate: unknown =
46
+ isRecord(parsed) && 'data' in parsed ? (parsed as any).data : parsed;
47
+
48
+ const wrapped = wrapNodeInMain(nodeCandidate as Node);
49
+ const nextValue =
50
+ isRecord(parsed) && 'data' in parsed
51
+ ? { ...(parsed as any), data: wrapped }
52
+ : wrapped;
53
+
54
+ setJsonText(JSON.stringify(nextValue, null, 2));
55
+ setJsonParseError(null);
56
+ } catch (e) {
57
+ setJsonParseError(e instanceof Error ? e.message : String(e));
58
+ }
59
+ };
60
+
61
+ useEffect(() => {
62
+ setJsonText(safeJsonStringify(rawData));
63
+ setJsonParseError(null);
64
+ }, [rawData]);
65
+
29
66
  return (
30
67
  <ProjectDebug
31
68
  name={name}
@@ -35,6 +72,25 @@ export function ProjectValidationPage({
35
72
  products={products}
36
73
  benefits={benefits}
37
74
  canvasBg={canvasBg}
75
+ jsonEditor={{
76
+ value: jsonText,
77
+ onChange: (next) => {
78
+ setJsonText(next);
79
+ setJsonParseError(null);
80
+ },
81
+ error: jsonParseError,
82
+ onSave: () => {
83
+ try {
84
+ const parsed = JSON.parse(jsonText) as unknown;
85
+ setJsonParseError(null);
86
+ onSaveEditedRawData?.(parsed);
87
+ } catch (e) {
88
+ setJsonParseError(e instanceof Error ? e.message : String(e));
89
+ }
90
+ },
91
+ saveDisabled: !onSaveEditedRawData,
92
+ saveLabel: 'Save JSON',
93
+ }}
38
94
  belowName={
39
95
  <>
40
96
  <div className="rb-project-validation__actions">
@@ -45,6 +101,13 @@ export function ProjectValidationPage({
45
101
  >
46
102
  Continue without validation
47
103
  </button>
104
+ <button
105
+ type="button"
106
+ className="editor-button"
107
+ onClick={() => wrapInMain()}
108
+ >
109
+ Wrap in Main
110
+ </button>
48
111
  </div>
49
112
  {belowName}
50
113
  </>
@@ -163,6 +163,35 @@
163
163
  background: colors.$canvasColor;
164
164
  }
165
165
 
166
+ .rb-project-debug__code-editor {
167
+ @include card;
168
+ width: 100%;
169
+ margin: 0;
170
+ padding: sizes.$spaceCozy;
171
+ border: 1px solid colors.$borderColor;
172
+ font-family:
173
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
174
+ 'Courier New', monospace;
175
+ font-size: 12px;
176
+ line-height: 1.5;
177
+ white-space: pre;
178
+ overflow: auto;
179
+ resize: vertical;
180
+ min-height: 220px;
181
+ max-height: calc(100vh - 60px - 160px);
182
+ background: colors.$canvasColor;
183
+ color: colors.$textColor;
184
+ }
185
+
186
+ .rb-project-debug__json-error {
187
+ margin: sizes.$spaceTight 0 0 0;
188
+ font-size: 12px;
189
+ line-height: 1.35;
190
+ color: colors.$dangerColor;
191
+ font-weight: 600;
192
+ word-break: break-word;
193
+ }
194
+
166
195
  /* Legacy selectors kept for backward compatibility (no-op if unused) */
167
196
  .rb-project-validation__error {
168
197
  margin: 0 0 sizes.$spaceCozy 0;
@@ -69,9 +69,15 @@ export function normalizeNodeForValidation(
69
69
 
70
70
  const recordData = node as NodeData<NodeDefaultAttribute>;
71
71
 
72
- const attributes = isPlainObject(recordData.attributes)
73
- ? (normalizeUnknownValue(recordData.attributes) as Record<string, unknown>)
74
- : recordData.attributes;
72
+ let attributes: unknown = recordData.attributes;
73
+ // Common persisted mistake: attributes saved as an empty array (`[]`).
74
+ // Treat it as "no attributes" rather than failing validation.
75
+ if (Array.isArray(attributes) && attributes.length === 0) {
76
+ attributes = {};
77
+ }
78
+ attributes = isPlainObject(attributes)
79
+ ? (normalizeUnknownValue(attributes) as Record<string, unknown>)
80
+ : attributes;
75
81
 
76
82
  const children =
77
83
  recordData.children !== undefined
@@ -0,0 +1,19 @@
1
+ export type ApplyJsonTransformResult =
2
+ | { ok: true; nextText: string; nextValue: unknown }
3
+ | { ok: false; error: string };
4
+
5
+ export function applyJsonTransform(args: {
6
+ text: string;
7
+ transform: (value: unknown) => unknown;
8
+ indent?: number;
9
+ }): ApplyJsonTransformResult {
10
+ const { text, transform, indent = 2 } = args;
11
+ try {
12
+ const parsed = JSON.parse(text) as unknown;
13
+ const nextValue = transform(parsed);
14
+ const nextText = JSON.stringify(nextValue, null, indent);
15
+ return { ok: true, nextText, nextValue };
16
+ } catch (e) {
17
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
18
+ }
19
+ }
@@ -0,0 +1,90 @@
1
+ import type { Node, NodeData } from '../types/Node';
2
+ import { generateRandomKeyForNode } from './generateRandomKeyForNode';
3
+
4
+ function isNodeDataLike(value: unknown): value is NodeData {
5
+ return (
6
+ typeof value === 'object' &&
7
+ value !== null &&
8
+ !Array.isArray(value) &&
9
+ 'type' in (value as any) &&
10
+ 'children' in (value as any)
11
+ );
12
+ }
13
+
14
+ function normalizeKey(value: unknown): string | undefined {
15
+ if (typeof value !== 'string') return undefined;
16
+ const trimmed = value.trim();
17
+ return trimmed.length > 0 ? trimmed : undefined;
18
+ }
19
+
20
+ function collectNodeKeysInto(node: Node, keys: Set<string>) {
21
+ if (node === null || node === undefined) return;
22
+ if (typeof node === 'string') return;
23
+ if (Array.isArray(node)) {
24
+ node.forEach((child) => collectNodeKeysInto(child, keys));
25
+ return;
26
+ }
27
+ if (!isNodeDataLike(node)) return;
28
+
29
+ const key = normalizeKey(node.key);
30
+ if (key) keys.add(key);
31
+ collectNodeKeysInto(node.children as Node, keys);
32
+ }
33
+
34
+ export function collectNodeKeys(root: Node): Set<string> {
35
+ const keys = new Set<string>();
36
+ collectNodeKeysInto(root, keys);
37
+ return keys;
38
+ }
39
+
40
+ function generateUniqueKey(type: string, usedKeys: Set<string>): string {
41
+ let next = '';
42
+ do {
43
+ next = generateRandomKeyForNode(type || 'Node');
44
+ } while (usedKeys.has(next));
45
+ return next;
46
+ }
47
+
48
+ /**
49
+ * Ensures that all NodeData-like records in the tree have unique non-empty `key`s.
50
+ * - Missing keys are generated.
51
+ * - Duplicate keys (against `usedKeys`) are repaired by generating a new unique key.
52
+ * - Non NodeData-like objects are left untouched.
53
+ *
54
+ * `usedKeys` is mutated by design (to track uniqueness across recursion).
55
+ */
56
+ export function repairNodeKeys(
57
+ root: Node,
58
+ usedKeys: Set<string> = new Set(),
59
+ ): Node {
60
+ if (root === null || root === undefined) return root;
61
+ if (typeof root === 'string') return root;
62
+
63
+ if (Array.isArray(root)) {
64
+ let changed = false;
65
+ const next: Node[] = root.map((child): Node => {
66
+ const repaired: Node = repairNodeKeys(child, usedKeys);
67
+ if (repaired !== child) changed = true;
68
+ return repaired;
69
+ });
70
+ return changed ? next : root;
71
+ }
72
+
73
+ if (!isNodeDataLike(root)) return root;
74
+
75
+ const prev = root as NodeData;
76
+ const prevKey = normalizeKey(prev.key);
77
+ const needsNewKey = !prevKey || usedKeys.has(prevKey);
78
+ const nextKey = needsNewKey
79
+ ? generateUniqueKey(prev.type, usedKeys)
80
+ : prevKey;
81
+ usedKeys.add(nextKey);
82
+
83
+ const prevChildren = prev.children as Node;
84
+ const nextChildren = repairNodeKeys(prevChildren, usedKeys) as Node;
85
+
86
+ if (nextKey !== prevKey || nextChildren !== prevChildren) {
87
+ return { ...prev, key: nextKey, children: nextChildren } as Node;
88
+ }
89
+ return root;
90
+ }
@@ -0,0 +1,18 @@
1
+ export function safeJsonStringify(value: unknown): string {
2
+ try {
3
+ const seen = new WeakSet<object>();
4
+ return JSON.stringify(
5
+ value,
6
+ (_key, v) => {
7
+ if (typeof v === 'object' && v !== null) {
8
+ if (seen.has(v as object)) return '[Circular]';
9
+ seen.add(v as object);
10
+ }
11
+ return v;
12
+ },
13
+ 2,
14
+ );
15
+ } catch (e) {
16
+ return `<< Unable to stringify value: ${String(e)} >>`;
17
+ }
18
+ }
@@ -0,0 +1,67 @@
1
+ import type { Node, NodeData } from '../types/Node';
2
+
3
+ function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ function isNodeData(value: unknown): value is NodeData {
8
+ return (
9
+ isRecord(value) && typeof value.type === 'string' && 'children' in value
10
+ );
11
+ }
12
+
13
+ function isMainLike(node: NodeData): boolean {
14
+ const t = node.type;
15
+ // Only treat actual Main nodes as "already wrapped".
16
+ // Some payloads may incorrectly carry `isMain: true` on non-Main nodes.
17
+ return t === 'Main' || t === 'main';
18
+ }
19
+
20
+ function stripNestedIsMain(node: Node, isRoot: boolean): Node {
21
+ if (node == null || typeof node === 'string') return node;
22
+
23
+ if (Array.isArray(node)) {
24
+ let changed = false;
25
+ const next = node.map((c) => {
26
+ const out = stripNestedIsMain(c, false);
27
+ if (out !== c) changed = true;
28
+ return out;
29
+ });
30
+ return changed ? next : node;
31
+ }
32
+
33
+ if (!isNodeData(node)) return node;
34
+
35
+ const prevChildren = node.children as Node;
36
+ const nextChildren = stripNestedIsMain(prevChildren, false);
37
+ const shouldDemote = node.isMain === true && !isRoot;
38
+
39
+ if (!shouldDemote && nextChildren === prevChildren) return node;
40
+
41
+ return {
42
+ ...(node as any),
43
+ ...(shouldDemote ? { isMain: false } : null),
44
+ children: nextChildren,
45
+ } as NodeData;
46
+ }
47
+
48
+ export function wrapNodeInMain(node: Node): NodeData {
49
+ if (isNodeData(node) && isMainLike(node)) {
50
+ // Ensure no nested nodes keep `isMain: true`.
51
+ const cleaned = stripNestedIsMain(node, true) as NodeData;
52
+ // Keep root as main.
53
+ if (cleaned.isMain !== true) return { ...(cleaned as any), isMain: true };
54
+ return cleaned;
55
+ }
56
+
57
+ const cleanedChild = stripNestedIsMain(node, false);
58
+ const children: Node =
59
+ node === null || typeof node === 'undefined' ? [] : cleanedChild;
60
+
61
+ return {
62
+ type: 'Main',
63
+ isMain: true,
64
+ attributes: {},
65
+ children,
66
+ };
67
+ }