@lupinum/board-core 1.0.0-beta.4 → 1.0.0-beta.6

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 (26) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +46 -25
  3. package/dist/agent/AGENTS.md +53 -57
  4. package/dist/agent/pages/docs/build-features/connections.md +333 -2
  5. package/dist/agent/pages/docs/build-features/custom-node-renderers.md +366 -2
  6. package/dist/agent/pages/docs/build-features/read-only-and-command-guards.md +278 -2
  7. package/dist/agent/pages/docs/build-features/save-and-load.md +264 -2
  8. package/dist/agent/pages/docs/build-features/selection-and-keyboard.md +257 -2
  9. package/dist/agent/pages/docs/build-features/theming.md +285 -2
  10. package/dist/agent/pages/docs/evaluate/how-nuxt-board-works.md +451 -2
  11. package/dist/agent/pages/docs/project/contributing.md +4 -4
  12. package/dist/agent/pages/docs/project/support-and-security.md +1 -1
  13. package/dist/agent/pages/docs/reference/events-and-errors.md +279 -2
  14. package/dist/agent/pages/docs/reference/package-overview.md +2 -2
  15. package/dist/agent/pages/docs/solutions/mind-map.md +448 -2
  16. package/dist/agent/pages/docs/solutions/nuxt-application.md +214 -2
  17. package/dist/agent/pages/docs/solutions/planning-board.md +281 -2
  18. package/dist/agent/pages/docs/solutions/read-only-viewer.md +278 -2
  19. package/dist/agent/pages/docs/solutions/workflow-builder.md +435 -2
  20. package/dist/agent/pages/docs/start-building/customize-your-first-node.md +381 -2
  21. package/dist/agent/pages/docs/start-building/installation.md +6 -14
  22. package/dist/agent/pages/docs/understand-the-system/commands-and-transactions.md +456 -2
  23. package/dist/agent/pages/docs/understand-the-system/document-and-session-state.md +348 -2
  24. package/dist/agent/pages/docs/understand-the-system/persistence-and-json-canvas.md +445 -2
  25. package/package.json +3 -2
  26. package/dist/agent/manifest.json +0 -320
@@ -15,8 +15,372 @@ source: "ginko-content"
15
15
 
16
16
  Custom renderers make nodes look like your product, not the default text box. The board shell still handles positioning, selection, resize handles, and z-ordering — your renderer only draws content.
17
17
 
18
- > Component omitted: `renderer-board-demo`.
19
- > This page contains an interactive or site-specific block that has no agent markdown serializer yet.
18
+ ## Live example source
19
+
20
+ Use the installation guide to register @lupinum/nuxt-board. These are the actual documentation demo files. Keep their paths under app/. The module auto-imports Board components; install the optional packages named in imports. For the local content components, use components: [{ path: "~/components/content", pathPrefix: false }, "~/components"] in nuxt.config.ts, as this site does, or import them explicitly. createDemoDocument is a local helper, not a package export. DocsLab and the inspector components provide presentation only.
21
+
22
+ ### app/components/demos/RendererBoardDemo.vue
23
+
24
+ ````vue
25
+ <script setup lang="ts">
26
+ import { onMounted } from 'vue'
27
+ import { createBoardEngine } from '@lupinum/board-core'
28
+ import type { BoardRendererRegistry } from '@lupinum/vue-board'
29
+ import { createDemoDocument } from '../../utils/demoDocument'
30
+ import DocsInsightNode from './DocsInsightNode.vue'
31
+ import DocsMetricNode from './DocsMetricNode.vue'
32
+
33
+ const engine = createBoardEngine({
34
+ grid: { size: 24, majorEvery: 4, snap: true, pattern: 'cross' },
35
+ })
36
+
37
+ const renderers: BoardRendererRegistry = {
38
+ text: DocsInsightNode,
39
+ group: DocsMetricNode,
40
+ }
41
+ let insightCount = 0
42
+ let metricCount = 0
43
+
44
+ function seed() {
45
+ engine.loadDocument(
46
+ createDemoDocument({
47
+ camera: { x: 0, y: 0, z: 1 },
48
+ grid: engine.getGridSettings(),
49
+ selection: [],
50
+ nextZIndex: 4,
51
+ nodes: [
52
+ {
53
+ id: 'why',
54
+ type: 'text',
55
+ x: 80,
56
+ y: 80,
57
+ width: 250,
58
+ height: 160,
59
+ text: 'Why custom renderers\nSwap the default card view for a Vue component while keeping JSON Canvas node data explicit.',
60
+ zIndex: 1,
61
+ locked: false,
62
+ visible: true,
63
+ },
64
+ {
65
+ id: 'metric',
66
+ type: 'group',
67
+ x: 390,
68
+ y: 90,
69
+ width: 230,
70
+ height: 160,
71
+ label:
72
+ 'Adoption\n83%\n+12%\nThe renderer can look nothing like the default text card.',
73
+ zIndex: 2,
74
+ locked: false,
75
+ visible: true,
76
+ },
77
+ {
78
+ id: 'flow',
79
+ type: 'text',
80
+ x: 660,
81
+ y: 130,
82
+ width: 230,
83
+ height: 100,
84
+ text: 'Default JSON Canvas\nRenderers are keyed by real node type.',
85
+ zIndex: 3,
86
+ locked: false,
87
+ visible: true,
88
+ },
89
+ ],
90
+ }),
91
+ { mode: 'replace' },
92
+ )
93
+ }
94
+
95
+ function addInsight() {
96
+ insightCount++
97
+ engine.createNode({
98
+ type: 'text',
99
+ x: 160 + (insightCount % 3) * 120,
100
+ y: 80 + (insightCount % 2) * 130,
101
+ width: 250,
102
+ height: 160,
103
+ text: 'New insight\nUse a text node with a renderer registered for the JSON Canvas text type.',
104
+ })
105
+ }
106
+
107
+ function addMetric() {
108
+ metricCount++
109
+ engine.createNode({
110
+ type: 'group',
111
+ x: 180 + (metricCount % 3) * 140,
112
+ y: 90 + (metricCount % 2) * 120,
113
+ width: 230,
114
+ height: 160,
115
+ label:
116
+ 'Confidence\n91%\n+4%\nDifferent JSON Canvas node types can have their own layout.',
117
+ })
118
+ }
119
+
120
+ onMounted(async () => {
121
+ seed()
122
+ await engine.zoomToFit(72, false)
123
+ })
124
+ </script>
125
+
126
+ <template>
127
+ <div class="demo-frame">
128
+ <div class="demo-toolbar">
129
+ <button class="demo-danger" @click="seed">Reset</button>
130
+ <button class="demo-primary" @click="addInsight">Add insight</button>
131
+ <button @click="addMetric">Add metric</button>
132
+ <button @click="engine.zoomToFit(72, false)">Zoom to fit</button>
133
+ </div>
134
+
135
+ <BoardRoot :engine="engine" :renderers="renderers" style="height: 340px" />
136
+ </div>
137
+ </template>
138
+ ````
139
+
140
+ ### app/utils/demoDocument.ts
141
+
142
+ ````ts
143
+ import type {
144
+ BoardNode,
145
+ BoardNodeMetadata,
146
+ Camera,
147
+ GridSettings,
148
+ JsonCanvasDocument,
149
+ JsonCanvasNode,
150
+ NodeId,
151
+ } from '@lupinum/board-core'
152
+
153
+ type DemoNode = Omit<
154
+ Pick<
155
+ BoardNode,
156
+ | 'id'
157
+ | 'type'
158
+ | 'x'
159
+ | 'y'
160
+ | 'width'
161
+ | 'height'
162
+ | 'color'
163
+ | 'text'
164
+ | 'file'
165
+ | 'subpath'
166
+ | 'url'
167
+ | 'label'
168
+ | 'background'
169
+ | 'backgroundStyle'
170
+ | 'zIndex'
171
+ | 'locked'
172
+ | 'visible'
173
+ | 'parentId'
174
+ >,
175
+ 'id' | 'parentId'
176
+ > & {
177
+ id: NodeId | string
178
+ parentId?: NodeId | string
179
+ }
180
+
181
+ export interface DemoDocumentInput {
182
+ nodes: readonly DemoNode[]
183
+ camera?: Camera
184
+ grid?: GridSettings
185
+ selection?: readonly (NodeId | string)[]
186
+ nextZIndex?: number
187
+ }
188
+
189
+ function toJsonCanvasNode(node: DemoNode): JsonCanvasNode {
190
+ const base = {
191
+ id: node.id as NodeId,
192
+ type: node.type,
193
+ x: node.x,
194
+ y: node.y,
195
+ width: node.width,
196
+ height: node.height,
197
+ ...(node.color ? { color: node.color } : {}),
198
+ }
199
+
200
+ switch (node.type) {
201
+ case 'file':
202
+ return {
203
+ ...base,
204
+ type: 'file',
205
+ file: node.file ?? '',
206
+ ...(node.subpath ? { subpath: node.subpath } : {}),
207
+ }
208
+ case 'link':
209
+ return {
210
+ ...base,
211
+ type: 'link',
212
+ url: node.url ?? '',
213
+ }
214
+ case 'group':
215
+ return {
216
+ ...base,
217
+ type: 'group',
218
+ ...((node.label ?? node.text)
219
+ ? { label: node.label ?? node.text }
220
+ : {}),
221
+ ...(node.background ? { background: node.background } : {}),
222
+ ...(node.backgroundStyle
223
+ ? { backgroundStyle: node.backgroundStyle }
224
+ : {}),
225
+ }
226
+ case 'text':
227
+ default:
228
+ return {
229
+ ...base,
230
+ type: 'text',
231
+ text: node.text ?? '',
232
+ }
233
+ }
234
+ }
235
+
236
+ function toNodeMetadata(node: DemoNode): BoardNodeMetadata {
237
+ return {
238
+ zIndex: node.zIndex,
239
+ locked: node.locked,
240
+ visible: node.visible,
241
+ ...(node.parentId ? { parentId: node.parentId as NodeId } : {}),
242
+ }
243
+ }
244
+
245
+ export function createDemoDocument(
246
+ input: DemoDocumentInput,
247
+ ): JsonCanvasDocument {
248
+ return {
249
+ nodes: input.nodes.map(toJsonCanvasNode),
250
+ 'x-lupinum-board': {
251
+ ...(input.camera ? { camera: input.camera } : {}),
252
+ ...(input.grid ? { grid: input.grid } : {}),
253
+ ...(input.selection
254
+ ? { selection: input.selection.map((id) => id as NodeId) }
255
+ : {}),
256
+ nextZIndex:
257
+ input.nextZIndex ??
258
+ input.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1,
259
+ nodes: Object.fromEntries(
260
+ input.nodes.map((node) => [node.id, toNodeMetadata(node)]),
261
+ ),
262
+ },
263
+ }
264
+ }
265
+ ````
266
+
267
+ ### app/components/demos/DocsInsightNode.vue
268
+
269
+ ````vue
270
+ <script setup lang="ts">
271
+ import type { BoardNode } from '@lupinum/board-core'
272
+
273
+ defineProps<{
274
+ node: BoardNode
275
+ selected: boolean
276
+ }>()
277
+ </script>
278
+
279
+ <template>
280
+ <div
281
+ class="size-full rounded-[inherit] p-4 text-left"
282
+ :class="selected ? 'ring-2 ring-primary/60 ring-inset' : ''"
283
+ style="
284
+ background: linear-gradient(
285
+ 135deg,
286
+ color-mix(in srgb, var(--ui-primary) 16%, transparent),
287
+ color-mix(in srgb, var(--ui-primary) 3%, white) 55%,
288
+ color-mix(in srgb, var(--ui-primary) 10%, transparent)
289
+ );
290
+ box-shadow:
291
+ inset 0 1px 0 0 color-mix(in srgb, var(--ui-primary) 16%, transparent),
292
+ inset 0 0 0 1px color-mix(in srgb, var(--ui-primary) 10%, transparent);
293
+ "
294
+ >
295
+ <div
296
+ class="text-[10px] font-mono uppercase tracking-[0.18em]"
297
+ style="color: var(--ui-primary)"
298
+ >
299
+ Insight node
300
+ </div>
301
+ <div class="mt-2 text-sm font-semibold text-highlighted">
302
+ {{ node.text?.split('\n')[0] ?? 'Untitled' }}
303
+ </div>
304
+ <div class="mt-2 text-xs leading-5 text-muted">
305
+ {{
306
+ node.text?.split('\n').slice(1).join('\n') ||
307
+ 'Use custom renderers to swap the default card view for any Vue component.'
308
+ }}
309
+ </div>
310
+ </div>
311
+ </template>
312
+ ````
313
+
314
+ ### app/components/demos/DocsMetricNode.vue
315
+
316
+ ````vue
317
+ <script setup lang="ts">
318
+ import { computed } from 'vue'
319
+ import type { BoardNode } from '@lupinum/board-core'
320
+
321
+ const props = defineProps<{
322
+ node: BoardNode
323
+ selected: boolean
324
+ }>()
325
+
326
+ const data = computed(() => {
327
+ const [label = 'Metric', value = '-', delta = '', caption = ''] =
328
+ props.node.label?.split('\n') ?? []
329
+ return { label, value, delta, caption }
330
+ })
331
+ const deltaPositive = computed(() => {
332
+ const d = String(data.value.delta ?? '')
333
+ return d.startsWith('+')
334
+ })
335
+ </script>
336
+
337
+ <template>
338
+ <div
339
+ class="flex h-full flex-col justify-between rounded-md border p-4"
340
+ :class="[selected ? 'ring-2 ring-primary' : 'border-default']"
341
+ style="
342
+ background: linear-gradient(
343
+ 160deg,
344
+ color-mix(in srgb, var(--ui-primary) 8%, white),
345
+ white 50%,
346
+ color-mix(in srgb, var(--ui-primary) 5%, white)
347
+ );
348
+ box-shadow:
349
+ inset 0 1px 0 0 color-mix(in srgb, var(--ui-primary) 12%, transparent),
350
+ 0 12px 40px rgba(15, 23, 42, 0.07),
351
+ 0 1px 3px rgba(15, 23, 42, 0.04);
352
+ "
353
+ >
354
+ <div class="flex items-center justify-between gap-3">
355
+ <span
356
+ class="text-[0.65rem] font-semibold uppercase tracking-[0.28em] text-dimmed"
357
+ >{{ data.label }}</span
358
+ >
359
+ <span
360
+ class="rounded-full px-2.5 py-1 text-[0.7rem] font-semibold tracking-wide ring-1"
361
+ :class="
362
+ deltaPositive
363
+ ? 'bg-emerald-50 text-emerald-700 ring-emerald-200/60'
364
+ : 'bg-amber-50 text-amber-700 ring-amber-200/60'
365
+ "
366
+ >
367
+ {{ data.delta }}
368
+ </span>
369
+ </div>
370
+ <div>
371
+ <p
372
+ class="text-3xl font-bold tracking-tight"
373
+ style="color: var(--ui-primary)"
374
+ >
375
+ {{ data.value }}
376
+ </p>
377
+ <p class="mt-2 text-sm leading-6 text-muted">
378
+ {{ data.caption }}
379
+ </p>
380
+ </div>
381
+ </div>
382
+ </template>
383
+ ````
20
384
 
21
385
  ## Choose the data shape first
22
386
 
@@ -31,8 +31,284 @@ This is not a third-party plugin surface. The supported consumer API is the
31
31
  package API exposed by each first-party package, such as `engine.plugins.history` and
32
32
  `engine.plugins.connections`.
33
33
 
34
- > Component omitted: `read-only-toggle-demo`.
35
- > This page contains an interactive or site-specific block that has no agent markdown serializer yet.
34
+ ## Live example source
35
+
36
+ Use the installation guide to register @lupinum/nuxt-board. These are the actual documentation demo files. Keep their paths under app/. The module auto-imports Board components; install the optional packages named in imports. For the local content components, use components: [{ path: "~/components/content", pathPrefix: false }, "~/components"] in nuxt.config.ts, as this site does, or import them explicitly. createDemoDocument is a local helper, not a package export. DocsLab and the inspector components provide presentation only.
37
+
38
+ ### app/components/demos/ReadOnlyToggleDemo.vue
39
+
40
+ ````vue
41
+ <script setup lang="ts">
42
+ import { onMounted, ref, watch } from 'vue'
43
+ import { createBoardEngine } from '@lupinum/board-core'
44
+ import { createDemoDocument } from '../../utils/demoDocument'
45
+
46
+ const engine = createBoardEngine({
47
+ grid: { size: 20, majorEvery: 5, snap: true, pattern: 'dot' },
48
+ })
49
+
50
+ const isReadOnly = ref(true)
51
+ const blockedCount = ref(0)
52
+ let unsubscribeReadOnly: (() => void) | null = null
53
+
54
+ const blockedCommands = new Set([
55
+ 'beginNodeDrag',
56
+ 'beginResize',
57
+ 'beginTextEdit',
58
+ 'createNode',
59
+ 'updateNode',
60
+ 'deleteNode',
61
+ 'moveNode',
62
+ 'resizeNode',
63
+ 'deleteSelected',
64
+ 'duplicateNodes',
65
+ 'translateSelectedNodes',
66
+ 'sendToBack',
67
+ 'lockNode',
68
+ 'unlockNode',
69
+ 'commitTextEdit',
70
+ 'copySelected',
71
+ 'pasteClipboard',
72
+ ])
73
+
74
+ function seed() {
75
+ engine.loadDocument(
76
+ createDemoDocument({
77
+ camera: { x: 0, y: 0, z: 1 },
78
+ grid: engine.getGridSettings(),
79
+ nodes: [
80
+ {
81
+ id: 'ro-a',
82
+ type: 'text',
83
+ x: 80,
84
+ y: 90,
85
+ width: 220,
86
+ height: 100,
87
+ text: 'Node',
88
+ zIndex: 1,
89
+ locked: false,
90
+ visible: true,
91
+ },
92
+ {
93
+ id: 'ro-b',
94
+ type: 'text',
95
+ x: 380,
96
+ y: 190,
97
+ width: 220,
98
+ height: 100,
99
+ text: 'Node',
100
+ zIndex: 2,
101
+ locked: false,
102
+ visible: true,
103
+ },
104
+ ],
105
+ selection: [],
106
+ nextZIndex: 3,
107
+ }),
108
+ { mode: 'replace' },
109
+ )
110
+ }
111
+
112
+ function applyReadOnly() {
113
+ unsubscribeReadOnly?.()
114
+ unsubscribeReadOnly = null
115
+
116
+ if (!isReadOnly.value) {
117
+ return
118
+ }
119
+
120
+ unsubscribeReadOnly = engine.addCommandGuard(({ name }) => {
121
+ if (blockedCommands.has(name)) {
122
+ blockedCount.value += 1
123
+ return 'Board is read-only.'
124
+ }
125
+ return true
126
+ })
127
+ }
128
+
129
+ watch(isReadOnly, applyReadOnly)
130
+
131
+ onMounted(async () => {
132
+ seed()
133
+ applyReadOnly()
134
+ await engine.zoomToFit(60, false)
135
+ })
136
+ </script>
137
+
138
+ <template>
139
+ <div class="demo-frame">
140
+ <div class="demo-toolbar">
141
+ <button class="demo-danger" @click="seed">Reset</button>
142
+ <button class="demo-primary" @click="isReadOnly = !isReadOnly">
143
+ {{ isReadOnly ? 'Switch to edit mode' : 'Switch to read-only' }}
144
+ </button>
145
+ <span class="demo-toolbar-note">
146
+ {{
147
+ isReadOnly
148
+ ? 'Mutations blocked — try dragging or deleting.'
149
+ : 'Mutations allowed.'
150
+ }}
151
+ </span>
152
+ </div>
153
+
154
+ <div class="grid gap-0 lg:grid-cols-[minmax(0,1fr)_280px]">
155
+ <BoardRoot :engine="engine" style="height: 360px" />
156
+
157
+ <div
158
+ class="border-t border-default bg-elevated p-4 lg:border-t-0 lg:border-l"
159
+ >
160
+ <div
161
+ class="inline-flex rounded-full px-3 py-2 text-sm font-semibold"
162
+ :class="
163
+ isReadOnly
164
+ ? 'bg-amber-50 text-amber-700'
165
+ : 'bg-emerald-50 text-emerald-700'
166
+ "
167
+ >
168
+ {{ isReadOnly ? 'Read-only' : 'Editable' }}
169
+ </div>
170
+ <div class="mt-5 rounded-md border border-default bg-default p-4">
171
+ <p
172
+ class="text-xs font-semibold uppercase tracking-[0.28em] text-dimmed"
173
+ >
174
+ Blocked attempts
175
+ </p>
176
+ <p class="mt-2 text-2xl font-bold tracking-tight text-highlighted">
177
+ {{ blockedCount }}
178
+ </p>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </template>
184
+ ````
185
+
186
+ ### app/utils/demoDocument.ts
187
+
188
+ ````ts
189
+ import type {
190
+ BoardNode,
191
+ BoardNodeMetadata,
192
+ Camera,
193
+ GridSettings,
194
+ JsonCanvasDocument,
195
+ JsonCanvasNode,
196
+ NodeId,
197
+ } from '@lupinum/board-core'
198
+
199
+ type DemoNode = Omit<
200
+ Pick<
201
+ BoardNode,
202
+ | 'id'
203
+ | 'type'
204
+ | 'x'
205
+ | 'y'
206
+ | 'width'
207
+ | 'height'
208
+ | 'color'
209
+ | 'text'
210
+ | 'file'
211
+ | 'subpath'
212
+ | 'url'
213
+ | 'label'
214
+ | 'background'
215
+ | 'backgroundStyle'
216
+ | 'zIndex'
217
+ | 'locked'
218
+ | 'visible'
219
+ | 'parentId'
220
+ >,
221
+ 'id' | 'parentId'
222
+ > & {
223
+ id: NodeId | string
224
+ parentId?: NodeId | string
225
+ }
226
+
227
+ export interface DemoDocumentInput {
228
+ nodes: readonly DemoNode[]
229
+ camera?: Camera
230
+ grid?: GridSettings
231
+ selection?: readonly (NodeId | string)[]
232
+ nextZIndex?: number
233
+ }
234
+
235
+ function toJsonCanvasNode(node: DemoNode): JsonCanvasNode {
236
+ const base = {
237
+ id: node.id as NodeId,
238
+ type: node.type,
239
+ x: node.x,
240
+ y: node.y,
241
+ width: node.width,
242
+ height: node.height,
243
+ ...(node.color ? { color: node.color } : {}),
244
+ }
245
+
246
+ switch (node.type) {
247
+ case 'file':
248
+ return {
249
+ ...base,
250
+ type: 'file',
251
+ file: node.file ?? '',
252
+ ...(node.subpath ? { subpath: node.subpath } : {}),
253
+ }
254
+ case 'link':
255
+ return {
256
+ ...base,
257
+ type: 'link',
258
+ url: node.url ?? '',
259
+ }
260
+ case 'group':
261
+ return {
262
+ ...base,
263
+ type: 'group',
264
+ ...((node.label ?? node.text)
265
+ ? { label: node.label ?? node.text }
266
+ : {}),
267
+ ...(node.background ? { background: node.background } : {}),
268
+ ...(node.backgroundStyle
269
+ ? { backgroundStyle: node.backgroundStyle }
270
+ : {}),
271
+ }
272
+ case 'text':
273
+ default:
274
+ return {
275
+ ...base,
276
+ type: 'text',
277
+ text: node.text ?? '',
278
+ }
279
+ }
280
+ }
281
+
282
+ function toNodeMetadata(node: DemoNode): BoardNodeMetadata {
283
+ return {
284
+ zIndex: node.zIndex,
285
+ locked: node.locked,
286
+ visible: node.visible,
287
+ ...(node.parentId ? { parentId: node.parentId as NodeId } : {}),
288
+ }
289
+ }
290
+
291
+ export function createDemoDocument(
292
+ input: DemoDocumentInput,
293
+ ): JsonCanvasDocument {
294
+ return {
295
+ nodes: input.nodes.map(toJsonCanvasNode),
296
+ 'x-lupinum-board': {
297
+ ...(input.camera ? { camera: input.camera } : {}),
298
+ ...(input.grid ? { grid: input.grid } : {}),
299
+ ...(input.selection
300
+ ? { selection: input.selection.map((id) => id as NodeId) }
301
+ : {}),
302
+ nextZIndex:
303
+ input.nextZIndex ??
304
+ input.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1,
305
+ nodes: Object.fromEntries(
306
+ input.nodes.map((node) => [node.id, toNodeMetadata(node)]),
307
+ ),
308
+ },
309
+ }
310
+ }
311
+ ````
36
312
 
37
313
  ## Command guards
38
314