@ankhorage/studio 0.14.0 → 0.16.0

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 (117) hide show
  1. package/README.md +2 -2
  2. package/dist/canvasDragModel.d.ts +24 -0
  3. package/dist/canvasDragModel.d.ts.map +1 -1
  4. package/dist/canvasDragModel.js +58 -0
  5. package/dist/canvasDragModel.js.map +1 -1
  6. package/dist/canvasDropZones.d.ts +2 -2
  7. package/dist/canvasDropZones.d.ts.map +1 -1
  8. package/dist/canvasDropZones.js +20 -4
  9. package/dist/canvasDropZones.js.map +1 -1
  10. package/dist/contractGuards.d.ts.map +1 -1
  11. package/dist/contractGuards.js +4 -2
  12. package/dist/contractGuards.js.map +1 -1
  13. package/dist/core/StudioProvider.d.ts +3 -2
  14. package/dist/core/StudioProvider.d.ts.map +1 -1
  15. package/dist/core/StudioProvider.js +150 -17
  16. package/dist/core/StudioProvider.js.map +1 -1
  17. package/dist/host/layout/auth/resolveAuthLayoutPlan.js +2 -2
  18. package/dist/host/layout/auth/resolveAuthLayoutPlan.js.map +1 -1
  19. package/dist/host/layout/layoutGenerator.d.ts.map +1 -1
  20. package/dist/host/layout/layoutGenerator.js +5 -3
  21. package/dist/host/layout/layoutGenerator.js.map +1 -1
  22. package/dist/host/layout/templates/navigation.js +4 -10
  23. package/dist/host/layout/templates/navigation.js.map +1 -1
  24. package/dist/host/layout/templates/rootLayout.d.ts.map +1 -1
  25. package/dist/host/layout/templates/rootLayout.js +25 -7
  26. package/dist/host/layout/templates/rootLayout.js.map +1 -1
  27. package/dist/host/orchestrator/templates.d.ts.map +1 -1
  28. package/dist/host/orchestrator/templates.js +1 -11
  29. package/dist/host/orchestrator/templates.js.map +1 -1
  30. package/dist/host/templateRegistry/index.d.ts.map +1 -1
  31. package/dist/host/templateRegistry/index.js +2 -17
  32. package/dist/host/templateRegistry/index.js.map +1 -1
  33. package/dist/index.d.ts +9 -15
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +83 -59
  36. package/dist/index.js.map +1 -1
  37. package/dist/manifestNavigatorPreviewModel.js +1 -1
  38. package/dist/manifestNavigatorPreviewModel.js.map +1 -1
  39. package/dist/manifestState.d.ts +59 -3
  40. package/dist/manifestState.d.ts.map +1 -1
  41. package/dist/manifestState.js +364 -229
  42. package/dist/manifestState.js.map +1 -1
  43. package/dist/routeUtils.d.ts +1 -2
  44. package/dist/routeUtils.d.ts.map +1 -1
  45. package/dist/routeUtils.js +6 -44
  46. package/dist/routeUtils.js.map +1 -1
  47. package/dist/runtime/runtimeNodeMeasurement.d.ts +6 -3
  48. package/dist/runtime/runtimeNodeMeasurement.d.ts.map +1 -1
  49. package/dist/runtime/runtimeNodeMeasurement.js +26 -11
  50. package/dist/runtime/runtimeNodeMeasurement.js.map +1 -1
  51. package/dist/runtime/stationarySelection.d.ts +10 -0
  52. package/dist/runtime/stationarySelection.d.ts.map +1 -1
  53. package/dist/runtime/stationarySelection.js +69 -20
  54. package/dist/runtime/stationarySelection.js.map +1 -1
  55. package/dist/studioAdminRouteModel.d.ts +7 -1
  56. package/dist/studioAdminRouteModel.d.ts.map +1 -1
  57. package/dist/studioAdminRouteModel.js +54 -0
  58. package/dist/studioAdminRouteModel.js.map +1 -1
  59. package/dist/ui/StudioDeleteDialog.d.ts +9 -0
  60. package/dist/ui/StudioDeleteDialog.d.ts.map +1 -0
  61. package/dist/ui/StudioDeleteDialog.js +7 -0
  62. package/dist/ui/StudioDeleteDialog.js.map +1 -0
  63. package/dist/ui/StudioInsertDialog.d.ts +14 -0
  64. package/dist/ui/StudioInsertDialog.d.ts.map +1 -0
  65. package/dist/ui/StudioInsertDialog.js +28 -0
  66. package/dist/ui/StudioInsertDialog.js.map +1 -0
  67. package/dist/ui/admin/AnkhAdminPage.d.ts.map +1 -1
  68. package/dist/ui/admin/AnkhAdminPage.js +5 -1
  69. package/dist/ui/admin/AnkhAdminPage.js.map +1 -1
  70. package/dist/ui/admin/AnkhAdminShell.d.ts.map +1 -1
  71. package/dist/ui/admin/AnkhAdminShell.js +5 -2
  72. package/dist/ui/admin/AnkhAdminShell.js.map +1 -1
  73. package/dist/ui/admin/pages/ScreenDetailAdminPage.d.ts +6 -0
  74. package/dist/ui/admin/pages/ScreenDetailAdminPage.d.ts.map +1 -0
  75. package/dist/ui/admin/pages/ScreenDetailAdminPage.js +86 -0
  76. package/dist/ui/admin/pages/ScreenDetailAdminPage.js.map +1 -0
  77. package/dist/ui/admin/pages/ScreensAdminPage.d.ts +3 -0
  78. package/dist/ui/admin/pages/ScreensAdminPage.d.ts.map +1 -0
  79. package/dist/ui/admin/pages/ScreensAdminPage.js +99 -0
  80. package/dist/ui/admin/pages/ScreensAdminPage.js.map +1 -0
  81. package/dist/ui/admin/pages/screens/screensAdminActions.d.ts +28 -0
  82. package/dist/ui/admin/pages/screens/screensAdminActions.d.ts.map +1 -0
  83. package/dist/ui/admin/pages/screens/screensAdminActions.js +22 -0
  84. package/dist/ui/admin/pages/screens/screensAdminActions.js.map +1 -0
  85. package/dist/ui/canvas/StudioCanvasDndOverlay.d.ts +15 -0
  86. package/dist/ui/canvas/StudioCanvasDndOverlay.d.ts.map +1 -0
  87. package/dist/ui/canvas/StudioCanvasDndOverlay.js +128 -0
  88. package/dist/ui/canvas/StudioCanvasDndOverlay.js.map +1 -0
  89. package/dist/ui/canvas/canvasDndInteraction.d.ts +20 -0
  90. package/dist/ui/canvas/canvasDndInteraction.d.ts.map +1 -0
  91. package/dist/ui/canvas/canvasDndInteraction.js +32 -0
  92. package/dist/ui/canvas/canvasDndInteraction.js.map +1 -0
  93. package/dist/ui/studioAppBarModel.d.ts +4 -1
  94. package/dist/ui/studioAppBarModel.d.ts.map +1 -1
  95. package/dist/ui/studioAppBarModel.js +6 -0
  96. package/dist/ui/studioAppBarModel.js.map +1 -1
  97. package/dist/ui/useStudioAppBarAugmentation.d.ts +1 -0
  98. package/dist/ui/useStudioAppBarAugmentation.d.ts.map +1 -1
  99. package/dist/ui/useStudioAppBarAugmentation.js +86 -15
  100. package/dist/ui/useStudioAppBarAugmentation.js.map +1 -1
  101. package/dist/utils/treeUtils.d.ts +2 -2
  102. package/dist/utils/treeUtils.d.ts.map +1 -1
  103. package/dist/utils/treeUtils.js +2 -2
  104. package/dist/utils/treeUtils.js.map +1 -1
  105. package/package.json +3 -15
  106. package/dist/dnd/index.d.ts +0 -3
  107. package/dist/dnd/index.d.ts.map +0 -1
  108. package/dist/dnd/index.js +0 -3
  109. package/dist/dnd/index.js.map +0 -1
  110. package/dist/dnd/primitives.d.ts +0 -2
  111. package/dist/dnd/primitives.d.ts.map +0 -1
  112. package/dist/dnd/primitives.js +0 -2
  113. package/dist/dnd/primitives.js.map +0 -1
  114. package/dist/dnd/state.d.ts +0 -6
  115. package/dist/dnd/state.d.ts.map +0 -1
  116. package/dist/dnd/state.js +0 -5
  117. package/dist/dnd/state.js.map +0 -1
@@ -1,3 +1,5 @@
1
+ import { NAVIGATOR_TYPES } from '@ankhorage/contracts';
2
+ import { insertNodeAtPlacement, moveNodeToPlacement } from './index';
1
3
  export const DEFAULT_STUDIO_SCREEN_TEMPLATE = {
2
4
  id: 'tpl-screen-empty',
3
5
  type: 'Screen',
@@ -103,6 +105,215 @@ export function groupScreenRouteEntries(entries) {
103
105
  }
104
106
  return Array.from(groups.values());
105
107
  }
108
+ /**
109
+ * Verifies the Studio manifest invariant that route registry keys and stable
110
+ * ScreenSpec identities are the same unique value.
111
+ */
112
+ export function hasCanonicalStudioScreenRegistryIdentity(screens) {
113
+ const screenIds = new Set();
114
+ for (const [registryKey, screen] of Object.entries(screens)) {
115
+ if (registryKey !== screen.id || screenIds.has(screen.id))
116
+ return false;
117
+ screenIds.add(screen.id);
118
+ }
119
+ return true;
120
+ }
121
+ function resolveCanonicalStudioScreen(manifest, screenId) {
122
+ if (!hasCanonicalStudioScreenRegistryIdentity(manifest.screens))
123
+ return undefined;
124
+ return manifest.screens[screenId];
125
+ }
126
+ /**
127
+ * Derives the complete package-neutral screen/navigation authoring model from
128
+ * the manifest without flattening its navigator tree.
129
+ */
130
+ export function deriveStudioScreenNavigationModel(manifest) {
131
+ const primaryNavigatorPath = getPrimaryNavigatorPath(manifest.navigator.routes);
132
+ const primaryNavigator = findNavigatorAtPath(manifest.navigator, primaryNavigatorPath) ?? manifest.navigator;
133
+ const diagnostics = [];
134
+ const referencesByRegistryKey = new Map();
135
+ collectNavigationModelReferences({
136
+ navigator: manifest.navigator,
137
+ screens: manifest.screens,
138
+ primaryNavigatorPath,
139
+ parentPath: [],
140
+ routePathPrefix: [],
141
+ runtimePathPrefix: [],
142
+ diagnostics,
143
+ referencesByRegistryKey,
144
+ });
145
+ const screenIdCounts = new Map();
146
+ for (const screen of Object.values(manifest.screens)) {
147
+ screenIdCounts.set(screen.id, (screenIdCounts.get(screen.id) ?? 0) + 1);
148
+ }
149
+ const reportedDuplicateScreenIds = new Set();
150
+ const screens = Object.entries(manifest.screens).map(([registryKey, screen]) => {
151
+ const screenId = screen.id;
152
+ const routeReferences = referencesByRegistryKey.get(registryKey) ?? [];
153
+ if (registryKey !== screenId) {
154
+ diagnostics.push({
155
+ code: 'screen-registry-key-mismatch',
156
+ message: `Screen registry key "${registryKey}" does not match stable ScreenSpec.id "${screenId}".`,
157
+ parentPath: [],
158
+ screenId,
159
+ });
160
+ }
161
+ if ((screenIdCounts.get(screenId) ?? 0) > 1 && !reportedDuplicateScreenIds.has(screenId)) {
162
+ reportedDuplicateScreenIds.add(screenId);
163
+ diagnostics.push({
164
+ code: 'duplicate-screen-id',
165
+ message: `Stable ScreenSpec.id "${screenId}" is used by multiple screen registry entries.`,
166
+ parentPath: [],
167
+ screenId,
168
+ });
169
+ }
170
+ if (routeReferences.length > 1) {
171
+ diagnostics.push({
172
+ code: 'ambiguous-screen-route-reference',
173
+ message: `Screen "${screenId}" is referenced by ${routeReferences.length} routes.`,
174
+ parentPath: [],
175
+ screenId,
176
+ });
177
+ }
178
+ return { screenId, screen, routeReferences };
179
+ });
180
+ return {
181
+ primaryNavigatorPath,
182
+ primaryNavigator,
183
+ screens,
184
+ diagnostics,
185
+ };
186
+ }
187
+ /**
188
+ * Returns a concrete canonical app pathname only when a screen has one route
189
+ * reference and that route does not require runtime parameters.
190
+ */
191
+ export function resolveStudioScreenAppPath(model, screenId) {
192
+ const matchingEntries = model.screens.filter((candidate) => candidate.screenId === screenId);
193
+ if (matchingEntries.length !== 1)
194
+ return null;
195
+ const [entry] = matchingEntries;
196
+ if (!entry)
197
+ return null;
198
+ if (entry.routeReferences.length !== 1)
199
+ return null;
200
+ const [reference] = entry.routeReferences;
201
+ if (!reference)
202
+ return null;
203
+ const hasDynamicSegment = reference.pathnamePattern
204
+ .split('/')
205
+ .some((segment) => segment.startsWith(':') || /^\[.*\]$/u.test(segment));
206
+ if (hasDynamicSegment)
207
+ return null;
208
+ const matchingReferences = model.screens.flatMap((candidate) => candidate.routeReferences.filter((candidateReference) => candidateReference.pathnamePattern === reference.pathnamePattern));
209
+ return matchingReferences.length === 1 ? reference.pathnamePattern : null;
210
+ }
211
+ function collectNavigationModelReferences(args) {
212
+ const { navigator, screens, primaryNavigatorPath, parentPath, routePathPrefix, runtimePathPrefix, diagnostics, referencesByRegistryKey, } = args;
213
+ const siblingNameCounts = new Map();
214
+ for (const route of navigator.routes) {
215
+ siblingNameCounts.set(route.name, (siblingNameCounts.get(route.name) ?? 0) + 1);
216
+ }
217
+ if (navigator.routes.length === 0) {
218
+ diagnostics.push({
219
+ code: 'empty-navigator',
220
+ message: `Navigator at "${formatParentPath(parentPath)}" has no routes.`,
221
+ parentPath,
222
+ });
223
+ }
224
+ if (navigator.initialRouteName !== undefined &&
225
+ siblingNameCounts.get(navigator.initialRouteName) !== 1) {
226
+ diagnostics.push({
227
+ code: 'invalid-initial-route',
228
+ message: `Initial route "${navigator.initialRouteName}" is not one unambiguous direct route of "${formatParentPath(parentPath)}".`,
229
+ parentPath,
230
+ routeName: navigator.initialRouteName,
231
+ });
232
+ }
233
+ for (const [siblingIndex, route] of navigator.routes.entries()) {
234
+ const routePath = [...routePathPrefix, route.name];
235
+ const runtimePath = appendRuntimeRoutePath(runtimePathPrefix, route);
236
+ const isPrimaryNavigatorMember = pathsEqual(parentPath, primaryNavigatorPath);
237
+ if ((siblingNameCounts.get(route.name) ?? 0) > 1 &&
238
+ navigator.routes.findIndex((candidate) => candidate.name === route.name) === siblingIndex) {
239
+ diagnostics.push({
240
+ code: 'duplicate-sibling-route-name',
241
+ message: `Route name "${route.name}" is duplicated under "${formatParentPath(parentPath)}".`,
242
+ parentPath,
243
+ routeName: route.name,
244
+ screenId: route.screenId,
245
+ });
246
+ }
247
+ if (!route.screenId && !route.navigator) {
248
+ diagnostics.push({
249
+ code: 'missing-route-target',
250
+ message: `Route "${route.name}" has neither a screen nor a nested navigator.`,
251
+ parentPath,
252
+ routeName: route.name,
253
+ });
254
+ }
255
+ else if (route.screenId && route.navigator) {
256
+ diagnostics.push({
257
+ code: 'ambiguous-route-target',
258
+ message: `Route "${route.name}" targets both a screen and a nested navigator.`,
259
+ parentPath,
260
+ routeName: route.name,
261
+ screenId: route.screenId,
262
+ });
263
+ }
264
+ if (route.screenId) {
265
+ if (!screens[route.screenId]) {
266
+ diagnostics.push({
267
+ code: 'missing-screen-reference',
268
+ message: `Route "${route.name}" references missing screen "${route.screenId}".`,
269
+ parentPath,
270
+ routeName: route.name,
271
+ screenId: route.screenId,
272
+ });
273
+ }
274
+ else {
275
+ const reference = {
276
+ route,
277
+ parentPath,
278
+ routePath,
279
+ pathnamePattern: toCanonicalRoutePattern(runtimePath),
280
+ siblingIndex,
281
+ navigatorType: navigator.type,
282
+ isPrimaryNavigatorMember,
283
+ showInPrimaryNavigation: route.showInPrimaryNavigation !== false,
284
+ isPrimaryInitialRoute: isPrimaryNavigatorMember && navigator.initialRouteName === route.name,
285
+ };
286
+ const currentReferences = referencesByRegistryKey.get(route.screenId) ?? [];
287
+ currentReferences.push(reference);
288
+ referencesByRegistryKey.set(route.screenId, currentReferences);
289
+ }
290
+ }
291
+ if (route.navigator) {
292
+ collectNavigationModelReferences({
293
+ navigator: route.navigator,
294
+ screens,
295
+ primaryNavigatorPath,
296
+ parentPath: routePath,
297
+ routePathPrefix: routePath,
298
+ runtimePathPrefix: runtimePath,
299
+ diagnostics,
300
+ referencesByRegistryKey,
301
+ });
302
+ }
303
+ }
304
+ }
305
+ function appendRuntimeRoutePath(runtimePathPrefix, route) {
306
+ const explicitPath = route.path?.trim();
307
+ const segmentPath = explicitPath ?? route.name;
308
+ const segments = segmentPath.split('/').filter(Boolean);
309
+ return explicitPath?.startsWith('/') ? segments : [...runtimePathPrefix, ...segments];
310
+ }
311
+ function pathsEqual(first, second) {
312
+ return first.length === second.length && first.every((value, index) => value === second[index]);
313
+ }
314
+ function formatParentPath(parentPath) {
315
+ return parentPath.length === 0 ? '<root>' : parentPath.join('/');
316
+ }
106
317
  export function listScreenIdsInRouteOrder(routes) {
107
318
  return collectScreenRouteEntries(routes).map((entry) => entry.screenId);
108
319
  }
@@ -112,6 +323,8 @@ export function listScreenIdsInRouteOrder(routes) {
112
323
  * back to the first route that reaches an available screen.
113
324
  */
114
325
  export function resolveInitialScreenId(navigator, screens) {
326
+ if (screens && !hasCanonicalStudioScreenRegistryIdentity(screens))
327
+ return null;
115
328
  const initialRoute = navigator.initialRouteName
116
329
  ? navigator.routes.find((route) => route.name === navigator.initialRouteName)
117
330
  : undefined;
@@ -125,22 +338,22 @@ export function resolveInitialScreenId(navigator, screens) {
125
338
  return nestedScreenId;
126
339
  }
127
340
  if (route.screenId && (!screens || screens[route.screenId])) {
128
- return route.screenId;
341
+ return screens ? (screens[route.screenId]?.id ?? null) : route.screenId;
129
342
  }
130
343
  }
131
344
  return null;
132
345
  }
133
346
  export function resolveInitialActiveScreenId(manifest) {
134
- if (!manifest)
347
+ if (!manifest || !hasCanonicalStudioScreenRegistryIdentity(manifest.screens))
135
348
  return null;
136
349
  const firstRoutedScreenId = resolveInitialScreenId(manifest.navigator, manifest.screens);
137
- const [firstScreenId] = Object.keys(manifest.screens);
350
+ const [firstScreenId] = Object.values(manifest.screens).map((screen) => screen.id);
138
351
  return firstRoutedScreenId ?? firstScreenId ?? null;
139
352
  }
140
353
  export function resolveActiveRootNode(manifest, activeScreenId) {
141
354
  if (!manifest || !activeScreenId)
142
355
  return null;
143
- return manifest.screens[activeScreenId]?.root ?? null;
356
+ return resolveCanonicalStudioScreen(manifest, activeScreenId)?.root ?? null;
144
357
  }
145
358
  export function findNodeInManifest(root, id) {
146
359
  if (root.id === id)
@@ -158,9 +371,11 @@ export function resolveSafeSelectedNodeId(rootNode, selectedNodeId) {
158
371
  return findNodeInManifest(rootNode, selectedNodeId) ? selectedNodeId : null;
159
372
  }
160
373
  export function findScreenIdForNode(manifest, nodeId) {
161
- for (const [screenId, screen] of Object.entries(manifest.screens)) {
374
+ if (!hasCanonicalStudioScreenRegistryIdentity(manifest.screens))
375
+ return null;
376
+ for (const screen of Object.values(manifest.screens)) {
162
377
  if (findNodeInManifest(screen.root, nodeId)) {
163
- return screenId;
378
+ return screen.id;
164
379
  }
165
380
  }
166
381
  return null;
@@ -168,7 +383,7 @@ export function findScreenIdForNode(manifest, nodeId) {
168
383
  export function updateStudioManifestNode(manifest, activeScreenId, nodeId, newProps) {
169
384
  if (!activeScreenId)
170
385
  return manifest;
171
- const screen = manifest.screens[activeScreenId];
386
+ const screen = resolveCanonicalStudioScreen(manifest, activeScreenId);
172
387
  if (!screen)
173
388
  return manifest;
174
389
  const newRoot = updateNodeInManifestTree(screen.root, nodeId, newProps);
@@ -188,13 +403,17 @@ export function updateStudioManifestNode(manifest, activeScreenId, nodeId, newPr
188
403
  export function deleteStudioManifestNode(manifest, activeScreenId, nodeId) {
189
404
  if (!activeScreenId)
190
405
  return manifest;
191
- const screen = manifest.screens[activeScreenId];
406
+ const screen = resolveCanonicalStudioScreen(manifest, activeScreenId);
192
407
  if (!screen || screen.root.id === nodeId)
193
408
  return manifest;
409
+ const deletedNode = findNodeInManifest(screen.root, nodeId);
410
+ if (!deletedNode)
411
+ return manifest;
194
412
  const newRoot = removeNodeFromManifestTree(screen.root, nodeId);
195
413
  if (!newRoot || newRoot === screen.root)
196
414
  return manifest;
197
- const nextDataBindings = Object.fromEntries(Object.entries(manifest.dataBindings ?? {}).filter(([componentId]) => componentId !== nodeId));
415
+ const deletedNodeIds = collectNodeIds(deletedNode);
416
+ const nextDataBindings = Object.fromEntries(Object.entries(manifest.dataBindings ?? {}).filter(([componentId, binding]) => !deletedNodeIds.has(componentId) && !deletedNodeIds.has(binding.componentId)));
198
417
  return {
199
418
  ...manifest,
200
419
  dataBindings: nextDataBindings,
@@ -207,42 +426,20 @@ export function deleteStudioManifestNode(manifest, activeScreenId, nodeId) {
207
426
  },
208
427
  };
209
428
  }
210
- export function moveStudioManifestNode(manifest, activeScreenId, nodeId, direction) {
211
- if (!activeScreenId)
212
- return manifest;
213
- const screen = manifest.screens[activeScreenId];
214
- if (!screen)
215
- return manifest;
216
- const newRoot = moveNodeInManifestTree(screen.root, nodeId, direction);
217
- if (newRoot === screen.root)
218
- return manifest;
219
- return {
220
- ...manifest,
221
- screens: {
222
- ...manifest.screens,
223
- [activeScreenId]: {
224
- ...screen,
225
- root: newRoot,
226
- },
227
- },
228
- };
229
- }
230
429
  export function insertStudioManifestNodeAtPlacement(args) {
231
430
  const { manifest, activeScreenId, placement, newNode, componentMeta } = args;
232
431
  if (!activeScreenId)
233
432
  return null;
234
- const screen = manifest.screens[activeScreenId];
433
+ const screen = resolveCanonicalStudioScreen(manifest, activeScreenId);
235
434
  if (!screen)
236
435
  return null;
237
- if (!validateManifestNodePlacement(screen.root, placement, newNode.type, componentMeta))
238
- return null;
239
- const insertion = insertChildAtIndex({
240
- node: screen.root,
241
- parentId: placement.parentId,
242
- index: placement.index,
243
- newNode,
436
+ const insertion = insertNodeAtPlacement({
437
+ root: screen.root,
438
+ placement,
439
+ componentMeta,
440
+ makeNode: () => newNode,
244
441
  });
245
- if (!insertion.inserted)
442
+ if (!insertion)
246
443
  return null;
247
444
  return {
248
445
  manifest: {
@@ -251,7 +448,7 @@ export function insertStudioManifestNodeAtPlacement(args) {
251
448
  ...manifest.screens,
252
449
  [activeScreenId]: {
253
450
  ...screen,
254
- root: insertion.node,
451
+ root: insertion.root,
255
452
  },
256
453
  },
257
454
  },
@@ -262,32 +459,16 @@ export function moveStudioManifestNodeToPlacement(args) {
262
459
  const { manifest, activeScreenId, nodeId, placement, componentMeta } = args;
263
460
  if (!activeScreenId)
264
461
  return null;
265
- const screen = manifest.screens[activeScreenId];
462
+ const screen = resolveCanonicalStudioScreen(manifest, activeScreenId);
266
463
  if (!screen)
267
464
  return null;
268
- const source = findNodeWithParent(screen.root, nodeId);
269
- if (!source?.parent)
270
- return null;
271
- if (placement.parentId === nodeId)
272
- return null;
273
- if (isDescendantNode(source.node, placement.parentId))
274
- return null;
275
- const adjustedPlacement = adjustMovePlacement({ source, placement });
276
- if (!adjustedPlacement)
277
- return null;
278
- const removed = removeNodeForMove({ node: screen.root, nodeId });
279
- if (!removed.removedNode)
280
- return null;
281
- if (!validateManifestNodePlacement(removed.node, adjustedPlacement, removed.removedNode.type, componentMeta)) {
282
- return null;
283
- }
284
- const inserted = insertChildAtIndex({
285
- node: removed.node,
286
- parentId: adjustedPlacement.parentId,
287
- index: adjustedPlacement.index,
288
- newNode: removed.removedNode,
465
+ const movement = moveNodeToPlacement({
466
+ root: screen.root,
467
+ nodeId,
468
+ placement,
469
+ componentMeta,
289
470
  });
290
- if (!inserted.inserted)
471
+ if (!movement)
291
472
  return null;
292
473
  return {
293
474
  manifest: {
@@ -296,11 +477,11 @@ export function moveStudioManifestNodeToPlacement(args) {
296
477
  ...manifest.screens,
297
478
  [activeScreenId]: {
298
479
  ...screen,
299
- root: inserted.node,
480
+ root: movement.root,
300
481
  },
301
482
  },
302
483
  },
303
- movedNodeId: removed.removedNode.id,
484
+ movedNodeId: movement.movedNodeId,
304
485
  };
305
486
  }
306
487
  export function updateStudioManifestDataBindings(manifest, dataBindings) {
@@ -453,7 +634,10 @@ export function findRoutesAtParentPath(routes, parentPath) {
453
634
  const [segment, ...rest] = parentPath;
454
635
  if (!segment)
455
636
  return null;
456
- const route = routes.find((item) => item.name === segment);
637
+ const matches = routes.filter((item) => item.name === segment);
638
+ if (matches.length !== 1)
639
+ return null;
640
+ const [route] = matches;
457
641
  if (!route?.navigator?.routes)
458
642
  return null;
459
643
  return findRoutesAtParentPath(route.navigator.routes, rest);
@@ -462,8 +646,12 @@ export function insertRouteAtParentPath(routes, parentPath, newRoute) {
462
646
  if (parentPath.length === 0)
463
647
  return [...routes, newRoute];
464
648
  const [segment, ...rest] = parentPath;
649
+ const matches = routes.filter((route) => route.name === segment && route.navigator);
650
+ if (matches.length !== 1)
651
+ return routes;
652
+ const [targetRoute] = matches;
465
653
  return routes.map((route) => {
466
- if (route.name !== segment || !route.navigator?.routes)
654
+ if (route !== targetRoute || !route.navigator?.routes)
467
655
  return route;
468
656
  return {
469
657
  ...route,
@@ -480,7 +668,10 @@ export function findNavigatorAtPath(navigator, parentPath) {
480
668
  const [segment, ...rest] = parentPath;
481
669
  if (!segment)
482
670
  return null;
483
- const route = navigator.routes.find((item) => item.name === segment);
671
+ const matches = navigator.routes.filter((item) => item.name === segment);
672
+ if (matches.length !== 1)
673
+ return null;
674
+ const [route] = matches;
484
675
  if (!route?.navigator)
485
676
  return null;
486
677
  return findNavigatorAtPath(route.navigator, rest);
@@ -489,10 +680,14 @@ export function updateNavigatorAtPath(navigator, parentPath, updater) {
489
680
  if (parentPath.length === 0)
490
681
  return updater(navigator);
491
682
  const [segment, ...rest] = parentPath;
683
+ const matches = navigator.routes.filter((route) => route.name === segment && route.navigator);
684
+ if (matches.length !== 1)
685
+ return navigator;
686
+ const [targetRoute] = matches;
492
687
  return {
493
688
  ...navigator,
494
689
  routes: navigator.routes.map((route) => {
495
- if (route.name !== segment || !route.navigator)
690
+ if (route !== targetRoute || !route.navigator)
496
691
  return route;
497
692
  return {
498
693
  ...route,
@@ -502,6 +697,8 @@ export function updateNavigatorAtPath(navigator, parentPath, updater) {
502
697
  };
503
698
  }
504
699
  export function setStudioManifestNavigatorType(manifest, type) {
700
+ if (!NAVIGATOR_TYPES.includes(type))
701
+ return manifest;
505
702
  const primaryNavigatorPath = getPrimaryNavigatorPath(manifest.navigator.routes);
506
703
  const currentNavigator = findNavigatorAtPath(manifest.navigator, primaryNavigatorPath);
507
704
  if (!currentNavigator || currentNavigator.type === type)
@@ -522,8 +719,9 @@ export function setStudioManifestNavigatorInitialRoute(manifest, routeName) {
522
719
  const currentNavigator = findNavigatorAtPath(manifest.navigator, primaryNavigatorPath);
523
720
  if (!currentNavigator)
524
721
  return manifest;
525
- if (!currentNavigator.routes.some((route) => route.name === normalizedRoute))
722
+ if (currentNavigator.routes.filter((route) => route.name === normalizedRoute).length !== 1) {
526
723
  return manifest;
724
+ }
527
725
  if (currentNavigator.initialRouteName === normalizedRoute)
528
726
  return manifest;
529
727
  return {
@@ -534,26 +732,82 @@ export function setStudioManifestNavigatorInitialRoute(manifest, routeName) {
534
732
  })),
535
733
  };
536
734
  }
735
+ export function setStudioManifestRoutePrimaryNavigationVisibility(args) {
736
+ const { manifest, parentPath, routeName, showInPrimaryNavigation } = args;
737
+ const navigator = findNavigatorAtPath(manifest.navigator, parentPath);
738
+ if (!navigator)
739
+ return manifest;
740
+ const matches = navigator.routes.filter((route) => route.name === routeName);
741
+ if (matches.length !== 1)
742
+ return manifest;
743
+ const [matchedRoute] = matches;
744
+ if (!matchedRoute)
745
+ return manifest;
746
+ const alreadyCanonical = showInPrimaryNavigation
747
+ ? matchedRoute.showInPrimaryNavigation === undefined
748
+ : matchedRoute.showInPrimaryNavigation === false;
749
+ if (alreadyCanonical)
750
+ return manifest;
751
+ return {
752
+ ...manifest,
753
+ navigator: updateNavigatorAtPath(manifest.navigator, parentPath, (current) => ({
754
+ ...current,
755
+ routes: current.routes.map((route) => {
756
+ if (route !== matchedRoute)
757
+ return route;
758
+ if (!showInPrimaryNavigation) {
759
+ return { ...route, showInPrimaryNavigation: false };
760
+ }
761
+ const { showInPrimaryNavigation: _visibility, ...visibleRoute } = route;
762
+ return visibleRoute;
763
+ }),
764
+ })),
765
+ };
766
+ }
767
+ export function moveStudioManifestRoute(args) {
768
+ const { manifest, parentPath, routeName, toIndex } = args;
769
+ if (!Number.isInteger(toIndex))
770
+ return manifest;
771
+ const navigator = findNavigatorAtPath(manifest.navigator, parentPath);
772
+ if (!navigator || toIndex < 0 || toIndex >= navigator.routes.length)
773
+ return manifest;
774
+ const matchingIndexes = navigator.routes.flatMap((route, index) => route.name === routeName ? [index] : []);
775
+ if (matchingIndexes.length !== 1)
776
+ return manifest;
777
+ const [fromIndex] = matchingIndexes;
778
+ if (fromIndex === undefined || fromIndex === toIndex)
779
+ return manifest;
780
+ return {
781
+ ...manifest,
782
+ navigator: updateNavigatorAtPath(manifest.navigator, parentPath, (current) => {
783
+ const routes = [...current.routes];
784
+ const [route] = routes.splice(fromIndex, 1);
785
+ if (!route)
786
+ return current;
787
+ routes.splice(toIndex, 0, route);
788
+ return { ...current, routes };
789
+ }),
790
+ };
791
+ }
537
792
  export function addStudioManifestScreen(args) {
538
793
  const { manifest, activeScreenId, createId = generateManifestStateId } = args;
794
+ if (!hasCanonicalStudioScreenRegistryIdentity(manifest.screens)) {
795
+ return { manifest, activeScreenId };
796
+ }
539
797
  const trimmedName = args.name.trim();
540
798
  if (!trimmedName)
541
799
  return { manifest, activeScreenId };
542
800
  const baseRouteName = normalizeRouteName(trimmedName);
543
- const activeParentPath = activeScreenId
544
- ? findParentPathForScreenId(manifest.navigator.routes, activeScreenId)
545
- : null;
546
- const fallbackParentPath = getPrimaryNavigatorPath(manifest.navigator.routes);
547
- const activeParentRoutes = activeParentPath !== null
548
- ? findRoutesAtParentPath(manifest.navigator.routes, activeParentPath)
549
- : null;
550
- const parentPath = activeParentPath && activeParentRoutes ? activeParentPath : fallbackParentPath;
551
- const siblingRoutes = findRoutesAtParentPath(manifest.navigator.routes, parentPath) ?? [];
801
+ const parentPath = args.parentPath ?? getPrimaryNavigatorPath(manifest.navigator.routes);
802
+ const siblingRoutes = findRoutesAtParentPath(manifest.navigator.routes, parentPath);
803
+ if (!siblingRoutes)
804
+ return { manifest, activeScreenId };
805
+ const existingScreenIds = new Set(Object.values(manifest.screens).map((screen) => screen.id));
552
806
  let screenId = createId('Screen');
553
- while (manifest.screens[screenId]) {
807
+ while (manifest.screens[screenId] || existingScreenIds.has(screenId)) {
554
808
  screenId = createId('Screen');
555
809
  }
556
- const existingPatterns = new Set(collectScreenRouteEntries(manifest.navigator.routes).map((entry) => toCanonicalRoutePattern(entry.routePath)));
810
+ const existingPatterns = new Set(collectCanonicalRoutePatterns(manifest.navigator.routes));
557
811
  const routeName = makeUniqueRouteNameForParent(baseRouteName, siblingRoutes, parentPath, existingPatterns);
558
812
  const newScreen = {
559
813
  id: screenId,
@@ -581,46 +835,33 @@ export function addStudioManifestScreen(args) {
581
835
  };
582
836
  }
583
837
  export function deleteStudioManifestScreen(manifest, screenId, activeScreenId) {
838
+ if (!hasCanonicalStudioScreenRegistryIdentity(manifest.screens)) {
839
+ return { manifest, activeScreenId };
840
+ }
584
841
  if (Object.keys(manifest.screens).length <= 1)
585
842
  return { manifest, activeScreenId };
843
+ const deletedScreen = manifest.screens[screenId];
844
+ if (!deletedScreen)
845
+ return { manifest, activeScreenId };
586
846
  const { [screenId]: _deletedScreen, ...remainingScreens } = manifest.screens;
587
847
  const remainingScreenIds = Object.keys(remainingScreens);
588
- let safeRoutes = removeScreenIdFromRoutes(manifest.navigator.routes, screenId);
589
- if (safeRoutes.length === 0 && remainingScreenIds.length > 0) {
590
- const [fallbackScreenId] = remainingScreenIds;
591
- if (!fallbackScreenId)
592
- return { manifest, activeScreenId };
593
- const fallbackScreen = remainingScreens[fallbackScreenId];
594
- safeRoutes = [
595
- {
596
- name: makeUniqueSiblingRouteName('screen', []),
597
- label: fallbackScreen?.name ?? 'Screen',
598
- screenId: fallbackScreenId,
599
- },
600
- ];
601
- }
848
+ const safeRoutes = removeScreenIdFromRoutes(manifest.navigator.routes, screenId);
602
849
  const orderedScreenIds = listScreenIdsInRouteOrder(safeRoutes).filter((id) => !!remainingScreens[id]);
603
850
  const nextActiveScreenId = !activeScreenId || activeScreenId === screenId || !remainingScreens[activeScreenId]
604
851
  ? (orderedScreenIds[0] ?? remainingScreenIds[0] ?? null)
605
852
  : activeScreenId;
853
+ const deletedNodeIds = collectNodeIds(deletedScreen.root);
854
+ const dataBindings = Object.fromEntries(Object.entries(manifest.dataBindings ?? {}).filter(([componentId, binding]) => !deletedNodeIds.has(componentId) && !deletedNodeIds.has(binding.componentId)));
606
855
  return {
607
856
  activeScreenId: nextActiveScreenId,
608
857
  manifest: {
609
858
  ...manifest,
859
+ dataBindings,
610
860
  screens: remainingScreens,
611
- navigator: {
861
+ navigator: normalizeNavigatorAfterRouteUpdate({
612
862
  ...manifest.navigator,
613
863
  routes: safeRoutes,
614
- },
615
- },
616
- };
617
- }
618
- export function reorderStudioManifestScreens(manifest, newRoutes) {
619
- return {
620
- ...manifest,
621
- navigator: {
622
- ...manifest.navigator,
623
- routes: newRoutes,
864
+ }),
624
865
  },
625
866
  };
626
867
  }
@@ -686,6 +927,17 @@ function normalizeRouteName(value) {
686
927
  .replace(/[^a-z0-9]+/g, '-')
687
928
  .replace(/^-+|-+$/g, '') || 'screen');
688
929
  }
930
+ function collectCanonicalRoutePatterns(routes, runtimePathPrefix = []) {
931
+ const patterns = [];
932
+ for (const route of routes) {
933
+ const runtimePath = appendRuntimeRoutePath(runtimePathPrefix, route);
934
+ patterns.push(toCanonicalRoutePattern(runtimePath));
935
+ if (route.navigator) {
936
+ patterns.push(...collectCanonicalRoutePatterns(route.navigator.routes, runtimePath));
937
+ }
938
+ }
939
+ return patterns;
940
+ }
689
941
  function cloneNodeWithNewIds(node, createId) {
690
942
  const clonedNode = {
691
943
  ...node,
@@ -728,132 +980,15 @@ function removeNodeFromManifestTree(root, nodeId) {
728
980
  const hasChanged = nextChildren.some((child, index) => child !== root.children?.[index]);
729
981
  return hasChanged ? { ...root, children: nextChildren } : root;
730
982
  }
731
- function moveNodeInManifestTree(root, nodeId, direction) {
732
- if (root.id === nodeId || !root.children)
733
- return root;
734
- const index = root.children.findIndex((child) => child.id === nodeId);
735
- if (index !== -1) {
736
- const targetIndex = direction === 'up' ? index - 1 : index + 1;
737
- if (targetIndex < 0 || targetIndex >= root.children.length)
738
- return root;
739
- const currentNode = root.children[index];
740
- const targetNode = root.children[targetIndex];
741
- if (!currentNode || !targetNode)
742
- return root;
743
- const nextChildren = [...root.children];
744
- nextChildren[index] = targetNode;
745
- nextChildren[targetIndex] = currentNode;
746
- return { ...root, children: nextChildren };
747
- }
748
- const nextChildren = root.children.map((child) => moveNodeInManifestTree(child, nodeId, direction));
749
- const hasChanged = nextChildren.some((child, childIndex) => child !== root.children?.[childIndex]);
750
- return hasChanged ? { ...root, children: nextChildren } : root;
751
- }
752
- function findNodeWithParent(root, nodeId) {
753
- if (root.id === nodeId)
754
- return { node: root, parent: null, index: -1 };
983
+ function collectNodeIds(root) {
984
+ const ids = new Set();
755
985
  const visit = (node) => {
756
- const children = node.children ?? [];
757
- for (const [index, child] of children.entries()) {
758
- if (child.id === nodeId)
759
- return { node: child, parent: node, index };
760
- const nested = visit(child);
761
- if (nested)
762
- return nested;
763
- }
764
- return null;
986
+ ids.add(node.id);
987
+ for (const child of node.children ?? [])
988
+ visit(child);
765
989
  };
766
- return visit(root);
767
- }
768
- function validateManifestNodePlacement(root, placement, childType, componentMeta) {
769
- const parent = findNodeInManifest(root, placement.parentId);
770
- if (!parent)
771
- return false;
772
- const meta = componentMeta[parent.type];
773
- if (!meta?.allowedChildren.includes(childType))
774
- return false;
775
- const children = parent.children ?? [];
776
- if (placement.referenceId && !children.some((child) => child.id === placement.referenceId)) {
777
- return false;
778
- }
779
- return placement.index >= 0 && placement.index <= children.length;
780
- }
781
- function insertChildAtIndex(args) {
782
- const { node, parentId, index, newNode } = args;
783
- if (node.id === parentId) {
784
- const children = node.children ?? [];
785
- if (index < 0 || index > children.length)
786
- return { node, inserted: false };
787
- return {
788
- node: {
789
- ...node,
790
- children: [...children.slice(0, index), newNode, ...children.slice(index)],
791
- },
792
- inserted: true,
793
- };
794
- }
795
- if (!node.children?.length)
796
- return { node, inserted: false };
797
- const results = node.children.map((child) => insertChildAtIndex({ node: child, parentId, index, newNode }));
798
- const inserted = results.some((result) => result.inserted);
799
- return inserted
800
- ? { node: { ...node, children: results.map((result) => result.node) }, inserted }
801
- : { node, inserted };
802
- }
803
- function isDescendantNode(node, descendantId) {
804
- for (const child of node.children ?? []) {
805
- if (child.id === descendantId || isDescendantNode(child, descendantId))
806
- return true;
807
- }
808
- return false;
809
- }
810
- function removeNodeForMove(args) {
811
- const { node, nodeId } = args;
812
- const children = node.children ?? [];
813
- const directIndex = children.findIndex((child) => child.id === nodeId);
814
- if (directIndex !== -1) {
815
- const removedNode = children[directIndex];
816
- if (!removedNode)
817
- return { node, removedNode: null };
818
- return {
819
- node: {
820
- ...node,
821
- children: children.filter((child) => child.id !== nodeId),
822
- },
823
- removedNode,
824
- };
825
- }
826
- const nextChildren = [];
827
- let removedNode = null;
828
- for (const child of children) {
829
- if (removedNode) {
830
- nextChildren.push(child);
831
- continue;
832
- }
833
- const { node: nextChild, removedNode: nextRemovedNode } = removeNodeForMove({
834
- node: child,
835
- nodeId,
836
- });
837
- if (nextRemovedNode)
838
- removedNode = nextRemovedNode;
839
- nextChildren.push(nextChild);
840
- }
841
- if (!removedNode)
842
- return { node, removedNode: null };
843
- return { node: { ...node, children: nextChildren }, removedNode };
844
- }
845
- function adjustMovePlacement(args) {
846
- const { source, placement } = args;
847
- if (!source.parent)
848
- return null;
849
- if (placement.referenceId === source.node.id)
850
- return null;
851
- if (placement.parentId !== source.parent.id)
852
- return placement;
853
- const adjustedIndex = source.index < placement.index ? placement.index - 1 : placement.index;
854
- if (adjustedIndex === source.index)
855
- return null;
856
- return { ...placement, index: adjustedIndex };
990
+ visit(root);
991
+ return ids;
857
992
  }
858
993
  function normalizeNavigatorAfterRouteUpdate(navigator) {
859
994
  const nextRoutes = navigator.routes;