@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.
- package/dist/index.cjs.js +1 -1
- 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 +6 -6
- 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/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
- package/src/build-components/Text/Text.tsx +4 -9
- 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 +24 -3
- 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/utils/__special_exceptions.ts +9 -3
- package/src/utils/applyJsonTransform.ts +19 -0
- package/src/utils/repairNodeKeys.ts +90 -0
- package/src/utils/safeJsonStringify.ts +18 -0
- 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
|
-
|
|
108
|
-
|
|
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
|
-
}, [
|
|
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(
|
|
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(
|
|
249
|
-
setCurrent(
|
|
251
|
+
setEditorData(activeProject.data as unknown as Node);
|
|
252
|
+
setCurrent(activeProject.data as unknown as Node);
|
|
250
253
|
return;
|
|
251
254
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
}, [
|
|
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={
|
|
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(
|
|
342
|
-
setCurrent(
|
|
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(
|
|
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={
|
|
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(
|
|
376
|
-
setCurrent(
|
|
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 &&
|
|
617
|
+
{!showLoading && (
|
|
481
618
|
<RenderPage
|
|
482
619
|
data={editorData}
|
|
483
620
|
name={resolvedName}
|
|
@@ -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;
|
|
@@ -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
|
|
@@ -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
|
+
}
|