@developer_tribe/react-builder 1.2.18 → 1.2.20
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/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +2 -1
- package/dist/build-components/patterns.generated.d.ts +23 -8
- package/dist/index.cjs.js +3 -3
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.web.cjs.js +4 -4
- package/dist/index.web.cjs.js.map +1 -1
- package/dist/index.web.esm.js +3 -3
- package/dist/index.web.esm.js.map +1 -1
- package/dist/pages/ProjectDebug.d.ts +9 -1
- package/dist/pages/ProjectMigrationPage.d.ts +3 -1
- package/dist/pages/ProjectValidationPage.d.ts +3 -2
- package/dist/styles.css +1 -1
- package/dist/utils/applyJsonTransform.d.ts +13 -0
- package/dist/utils/repairNodeKeys.d.ts +11 -0
- package/dist/utils/safeJsonStringify.d.ts +1 -0
- package/dist/utils/wrapNodeInMain.d.ts +2 -0
- package/package.json +1 -1
- package/src/RenderPage.tsx +17 -46
- package/src/assets/meta.json +1 -1
- package/src/assets/samples/carousel-sample.json +51 -51
- package/src/assets/samples/paywall-1.json +77 -77
- package/src/assets/samples/paywall-2.json +76 -76
- package/src/assets/samples/simple-1.json +13 -13
- package/src/assets/samples/simple-2.json +97 -97
- package/src/assets/samples/unmigrated-builder-1.1.1.json +25 -25
- package/src/assets/samples/unmigrated-builder1.json +1 -1
- package/src/assets/samples/unvalidated-builder1.json +15 -15
- package/src/assets/samples/unvalidated-crash1.json +4 -4
- package/src/assets/samples/vpn-onboard-1.json +100 -78
- package/src/assets/samples/vpn-onboard-2.json +97 -75
- package/src/assets/samples/vpn-onboard-3.json +103 -79
- package/src/assets/samples/vpn-onboard-4.json +103 -79
- package/src/assets/samples/vpn-onboard-5.json +139 -108
- package/src/assets/samples/vpn-onboard-6.json +100 -81
- package/src/build-components/CarouselDots/CarouselDots.tsx +112 -12
- package/src/build-components/OnboardDot/OnboardDot.tsx +74 -40
- package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +2 -1
- package/src/build-components/OnboardDot/pattern.json +28 -10
- package/src/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
- package/src/build-components/Text/Text.tsx +4 -9
- package/src/build-components/patterns.generated.ts +23 -8
- package/src/build-components/useNode.ts +20 -4
- package/src/components/AttributesEditorPanel.tsx +13 -1
- package/src/components/Builder.tsx +19 -5
- package/src/components/EditorHeader.tsx +16 -6
- package/src/components/JsonTextEditor.tsx +41 -0
- package/src/pages/DebugJsonPage.tsx +104 -4
- package/src/pages/ProjectDebug.tsx +66 -28
- package/src/pages/ProjectMigrationPage.tsx +15 -0
- package/src/pages/ProjectPage.tsx +160 -23
- package/src/pages/ProjectValidationPage.tsx +64 -1
- package/src/styles/layout/_project-validation.scss +29 -0
- package/src/styles/utilities/_carousel.scss +0 -32
- package/src/utils/__special_exceptions.ts +9 -3
- package/src/utils/analyseNodeByPatterns.ts +16 -6
- package/src/utils/applyJsonTransform.ts +19 -0
- package/src/utils/novaToJson.ts +7 -3
- package/src/utils/repairNodeKeys.ts +90 -0
- package/src/utils/safeJsonStringify.ts +18 -0
- package/src/utils/wrapNodeInMain.ts +67 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type
|
|
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;
|
|
@@ -78,38 +78,6 @@
|
|
|
78
78
|
height: 50px;
|
|
79
79
|
margin: 0 auto;
|
|
80
80
|
}
|
|
81
|
-
.embla__dot {
|
|
82
|
-
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
|
|
83
|
-
-webkit-appearance: none;
|
|
84
|
-
appearance: none;
|
|
85
|
-
background-color: transparent;
|
|
86
|
-
--embla-dot-color: var(--detail-medium-contrast);
|
|
87
|
-
touch-action: manipulation;
|
|
88
|
-
display: inline-flex;
|
|
89
|
-
text-decoration: none;
|
|
90
|
-
cursor: pointer;
|
|
91
|
-
border: 0;
|
|
92
|
-
padding: 0;
|
|
93
|
-
margin: 0;
|
|
94
|
-
/* Keep a reasonable hit-area, but allow bigger dots to grow it. */
|
|
95
|
-
width: max(2.6rem, calc(var(--embla-dot-size, 1.4rem) + 1.2rem));
|
|
96
|
-
height: max(2.6rem, calc(var(--embla-dot-size, 1.4rem) + 1.2rem));
|
|
97
|
-
display: flex;
|
|
98
|
-
align-items: center;
|
|
99
|
-
border-radius: 50%;
|
|
100
|
-
}
|
|
101
|
-
.embla__dot:after {
|
|
102
|
-
background-color: var(--embla-dot-color);
|
|
103
|
-
width: var(--embla-dot-size, 1.4rem);
|
|
104
|
-
height: var(--embla-dot-size, 1.4rem);
|
|
105
|
-
border-radius: 50%;
|
|
106
|
-
display: flex;
|
|
107
|
-
align-items: center;
|
|
108
|
-
content: '';
|
|
109
|
-
}
|
|
110
|
-
.embla__dot--selected {
|
|
111
|
-
--embla-dot-color: var(--text-body);
|
|
112
|
-
}
|
|
113
81
|
.carousel-provider {
|
|
114
82
|
height: 100%;
|
|
115
83
|
}
|
|
@@ -69,9 +69,15 @@ export function normalizeNodeForValidation(
|
|
|
69
69
|
|
|
70
70
|
const recordData = node as NodeData<NodeDefaultAttribute>;
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
@@ -387,14 +387,24 @@ function validateAttributesByPattern(
|
|
|
387
387
|
{}) as AttributeSchema;
|
|
388
388
|
const styleSchema = getStyleSubSchema(schema);
|
|
389
389
|
|
|
390
|
-
// Validate nested `attributes.style` as an object; validate any style keys that also exist in schema.
|
|
391
390
|
const maybeStyle = (attrs as Record<string, unknown>).style;
|
|
391
|
+
const maybeStyles = (attrs as Record<string, unknown>).styles;
|
|
392
|
+
|
|
393
|
+
// schemaVersion=2 requires `attributes.styles` (plural), not `attributes.style` (singular)
|
|
392
394
|
if (maybeStyle != null) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
+
return fail(
|
|
396
|
+
`Legacy attribute "style" detected. Use "styles" instead (schemaVersion=2). Found at ${joinPath(path, 'style')}`,
|
|
397
|
+
joinPath(path, 'style'),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Validate nested `attributes.styles` (canonical store for AttributesEditor).
|
|
402
|
+
if (maybeStyles != null) {
|
|
403
|
+
if (!isPlainObject(maybeStyles)) {
|
|
404
|
+
return fail(`styles must be an object`, joinPath(path, 'styles'));
|
|
395
405
|
}
|
|
396
406
|
for (const [styleKey, styleValue] of Object.entries(
|
|
397
|
-
|
|
407
|
+
maybeStyles as Record<string, unknown>,
|
|
398
408
|
)) {
|
|
399
409
|
const spec = (styleSchema?.[styleKey] ?? schema?.[styleKey]) as
|
|
400
410
|
| AttributeTypeSpec
|
|
@@ -404,14 +414,14 @@ function validateAttributesByPattern(
|
|
|
404
414
|
pattern.pattern.type,
|
|
405
415
|
styleValue,
|
|
406
416
|
spec,
|
|
407
|
-
joinPath(joinPath(path, '
|
|
417
|
+
joinPath(joinPath(path, 'styles'), styleKey),
|
|
408
418
|
);
|
|
409
419
|
if (!res.valid) return res;
|
|
410
420
|
}
|
|
411
421
|
}
|
|
412
422
|
|
|
413
423
|
for (const [attrName, attrValue] of Object.entries(attrs)) {
|
|
414
|
-
if (attrName === 'style') continue;
|
|
424
|
+
if (attrName === 'style' || attrName === 'styles') continue;
|
|
415
425
|
const attrSpec = schema?.[attrName] as AttributeTypeSpec | undefined;
|
|
416
426
|
if (!attrSpec) {
|
|
417
427
|
if (attrName === 'title' || attrName === 'description') {
|
|
@@ -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
|
+
}
|
package/src/utils/novaToJson.ts
CHANGED
|
@@ -474,7 +474,7 @@ function mapDotsFromGeneralComponents(generalComponents: any[]): Node | null {
|
|
|
474
474
|
};
|
|
475
475
|
|
|
476
476
|
const inactiveDotOpacity = parseNumber(dotAttrs?.inactive_dot_opacity);
|
|
477
|
-
const
|
|
477
|
+
const dotThickness = parseNumber(dotAttrs?.dot_thickness);
|
|
478
478
|
const dot_style =
|
|
479
479
|
typeof dotAttrs?.dot_style === 'string'
|
|
480
480
|
? (dotAttrs.dot_style as string)
|
|
@@ -487,16 +487,20 @@ function mapDotsFromGeneralComponents(generalComponents: any[]): Node | null {
|
|
|
487
487
|
typeof dotAttrs?.active_dot_color === 'string'
|
|
488
488
|
? (dotAttrs.active_dot_color as string)
|
|
489
489
|
: undefined;
|
|
490
|
+
const inactive_dot_color =
|
|
491
|
+
typeof dotAttrs?.inactive_dot_color === 'string'
|
|
492
|
+
? (dotAttrs.inactive_dot_color as string)
|
|
493
|
+
: undefined;
|
|
490
494
|
|
|
491
495
|
const attributes: Record<string, unknown> = {};
|
|
492
496
|
if (dotType) attributes.dotType = dotType;
|
|
493
497
|
if (inactiveDotOpacity !== undefined)
|
|
494
498
|
attributes.inactive_dot_opacity = inactiveDotOpacity;
|
|
495
|
-
if (
|
|
496
|
-
attributes.expanding_dot_width = expandingDotWidth;
|
|
499
|
+
if (dotThickness !== undefined) attributes.dot_thickness = dotThickness;
|
|
497
500
|
if (dot_style) attributes.dot_style = dot_style;
|
|
498
501
|
if (container_style) attributes.container_style = container_style;
|
|
499
502
|
if (active_dot_color) attributes.active_dot_color = active_dot_color;
|
|
503
|
+
if (inactive_dot_color) attributes.inactive_dot_color = inactive_dot_color;
|
|
500
504
|
return {
|
|
501
505
|
type: 'OnboardDot',
|
|
502
506
|
attributes: Object.keys(attributes).length ? attributes : undefined,
|
|
@@ -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
|
+
}
|