@dfosco/storyboard-core 3.6.0 → 3.7.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 (50) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +12274 -11387
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -1
  6. package/src/CanvasZoomControl.svelte +8 -8
  7. package/src/CommentsMenuButton.svelte +7 -21
  8. package/src/CoreUIBar.svelte +19 -3
  9. package/src/CreateMenuButton.svelte +8 -12
  10. package/src/InspectorPanel.svelte +12 -15
  11. package/src/SidePanel.svelte +14 -14
  12. package/src/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
  13. package/src/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
  14. package/src/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
  15. package/src/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
  16. package/src/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
  17. package/src/comments/ui/AuthModal.svelte +45 -12
  18. package/src/comments/ui/authModal.js +6 -1
  19. package/src/comments/ui/comment-layout.css +15 -15
  20. package/src/comments/ui/commentWindow.js +6 -1
  21. package/src/comments/ui/comments.css +57 -57
  22. package/src/comments/ui/commentsDrawer.js +2 -0
  23. package/src/comments/ui/composer.js +7 -2
  24. package/src/comments/ui/mount.js +252 -33
  25. package/src/comments/ui/mount.test.js +138 -0
  26. package/src/core-ui-colors.css +28 -28
  27. package/src/inspector/mouseMode.js +2 -2
  28. package/src/lib/components/ui/button/button.svelte +9 -9
  29. package/src/lib/components/ui/panel/panel-content.svelte +2 -2
  30. package/src/lib/components/ui/select/select-trigger.svelte +1 -1
  31. package/src/lib/components/ui/toggle/toggle.svelte +1 -1
  32. package/src/lib/components/ui/toggle-group/toggle-group.svelte +2 -2
  33. package/src/lib/components/ui/trigger-button/trigger-button.svelte +13 -13
  34. package/src/modes.css +21 -21
  35. package/src/mountStoryboardCore.js +4 -4
  36. package/src/sidepanel.css +11 -11
  37. package/src/styles/tailwind.css +89 -1
  38. package/src/svelte-plugin-ui/components/ModeSwitch.svelte +3 -3
  39. package/src/svelte-plugin-ui/components/Viewfinder.svelte +31 -11
  40. package/src/svelte-plugin-ui/styles/base.css +41 -41
  41. package/src/workshop/features/createFlow/CreateFlowForm.svelte +187 -25
  42. package/src/workshop/features/createFlow/server.js +437 -40
  43. package/src/workshop/features/createPage/CreatePageForm.svelte +249 -0
  44. package/src/workshop/features/createPage/index.js +11 -0
  45. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +77 -24
  46. package/src/workshop/features/createPrototype/server.js +14 -16
  47. package/src/workshop/features/registry-server.js +1 -0
  48. package/src/workshop/features/registry.js +2 -0
  49. package/src/workshop/features/templateIndex.js +155 -0
  50. package/toolbar.config.json +2 -1
@@ -0,0 +1,249 @@
1
+ <!--
2
+ CreatePageForm — workshop form for creating a new page inside a prototype.
3
+ -->
4
+
5
+ <script lang="ts">
6
+ import { onMount } from 'svelte'
7
+ import { Button } from '../../../lib/components/ui/button/index.js'
8
+ import { Input } from '../../../lib/components/ui/input/index.js'
9
+ import { Label } from '../../../lib/components/ui/label/index.js'
10
+ import * as Panel from '../../../lib/components/ui/panel/index.js'
11
+ import * as DropdownMenu from '../../../lib/components/ui/dropdown-menu/index.js'
12
+ import * as Alert from '../../../lib/components/ui/alert/index.js'
13
+
14
+ interface Props { onClose?: () => void }
15
+ let { onClose }: Props = $props()
16
+
17
+ interface PrototypeEntry { name: string; folder?: string }
18
+ interface PartialEntry {
19
+ id: string
20
+ name: string
21
+ kind: 'template' | 'recipe'
22
+ scope: 'global' | 'prototype'
23
+ prototype?: string
24
+ folder?: string
25
+ }
26
+
27
+ let selectedPrototype = $state('')
28
+ let pagePath = $state('')
29
+ let template = $state('')
30
+ let prototypes: PrototypeEntry[] = $state([])
31
+ let partials: PartialEntry[] = $state([])
32
+ let loading = $state(true)
33
+ let submitting = $state(false)
34
+ let error: string | null = $state(null)
35
+ let success: string | null = $state(null)
36
+ let templateMenuOpen = $state(false)
37
+
38
+ const selectedProtoEntry = $derived(
39
+ selectedPrototype ? prototypes.find((p) => p.name === selectedPrototype) : null
40
+ )
41
+ const pagePrefix = $derived(selectedPrototype ? `/${selectedPrototype}/` : '/prototype-name/')
42
+ const pageSuffix = $derived(
43
+ pagePath.startsWith(pagePrefix) ? pagePath.slice(pagePrefix.length) : pagePath
44
+ )
45
+ const canSubmit = $derived(!!selectedPrototype && !!pageSuffix.trim() && !submitting)
46
+ const templateChoices = $derived(
47
+ selectedPrototype
48
+ ? partials.filter((partial) => {
49
+ if (partial.scope === 'global') return true
50
+ return partial.prototype === selectedPrototype && (partial.folder || '') === (selectedProtoEntry?.folder || '')
51
+ })
52
+ : partials.filter((partial) => partial.scope === 'global')
53
+ )
54
+ const globalTemplateChoices = $derived(templateChoices.filter((partial) => partial.scope === 'global'))
55
+ const localTemplateChoices = $derived(
56
+ templateChoices.filter((partial) => partial.scope === 'prototype')
57
+ )
58
+ const localTemplateHeading = $derived(
59
+ selectedPrototype || ''
60
+ )
61
+ const globalTemplates = $derived(globalTemplateChoices.filter((partial) => partial.kind === 'template'))
62
+ const globalRecipes = $derived(globalTemplateChoices.filter((partial) => partial.kind === 'recipe'))
63
+ const localTemplates = $derived(localTemplateChoices.filter((partial) => partial.kind === 'template'))
64
+ const localRecipes = $derived(localTemplateChoices.filter((partial) => partial.kind === 'recipe'))
65
+ const templateLabel = $derived(
66
+ template ? templateChoices.find((choice) => choice.id === template)?.name ?? template : 'Blank page'
67
+ )
68
+
69
+ function getApiUrl() {
70
+ const basePath = document.querySelector('base')?.getAttribute('href') || '/'
71
+ return basePath.replace(/\/$/, '') + '/_storyboard/workshop/pages'
72
+ }
73
+
74
+ onMount(async () => {
75
+ try {
76
+ const res = await fetch(getApiUrl())
77
+ if (res.ok) {
78
+ const data = await res.json()
79
+ prototypes = data.prototypes || []
80
+ partials = data.partials || []
81
+ }
82
+ } catch { /* defaults */ } finally { loading = false }
83
+ })
84
+
85
+ $effect(() => {
86
+ if (!selectedPrototype) {
87
+ pagePath = ''
88
+ return
89
+ }
90
+ if (!pagePath || !pagePath.startsWith(pagePrefix)) {
91
+ pagePath = `${pagePrefix}new-page`
92
+ }
93
+ })
94
+
95
+ $effect(() => {
96
+ if (!template) return
97
+ const stillAvailable = templateChoices.some((choice) => choice.id === template)
98
+ if (!stillAvailable) {
99
+ template = ''
100
+ }
101
+ })
102
+
103
+ async function submit() {
104
+ if (!canSubmit) return
105
+ submitting = true; error = null; success = null
106
+ try {
107
+ const res = await fetch(getApiUrl(), {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({
111
+ prototype: selectedPrototype,
112
+ folder: selectedProtoEntry?.folder || undefined,
113
+ path: pagePath.trim(),
114
+ template: template || undefined,
115
+ }),
116
+ })
117
+ const data = await res.json()
118
+ if (!res.ok) { error = data.error || 'Failed to create page'; return }
119
+ success = `Created ${data.path}`
120
+ } catch (err: any) {
121
+ error = err.message || 'Network error'
122
+ } finally {
123
+ submitting = false
124
+ }
125
+ }
126
+
127
+ function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' && canSubmit) submit() }
128
+ </script>
129
+
130
+ <Panel.Header>
131
+ <Panel.Title>Create page</Panel.Title>
132
+ <Panel.Close />
133
+ </Panel.Header>
134
+
135
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
136
+ <div class="p-4 pt-2 space-y-5" onkeydown={handleKeydown}>
137
+ <div class="space-y-1">
138
+ <Label for="sb-page-prototype">Prototype</Label>
139
+ <select
140
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
141
+ id="sb-page-prototype"
142
+ bind:value={selectedPrototype}
143
+ disabled={loading}
144
+ >
145
+ <option value="">Select prototype</option>
146
+ {#each prototypes as p}
147
+ <option value={p.name}>{p.folder ? `${p.folder} / ${p.name}` : p.name}</option>
148
+ {/each}
149
+ </select>
150
+ </div>
151
+
152
+ <div class="space-y-1">
153
+ <Label for="sb-page-path">Page path</Label>
154
+ <div class="flex items-center gap-2">
155
+ <span class="text-xs text-muted-foreground font-mono whitespace-nowrap">{pagePrefix}</span>
156
+ <Input
157
+ id="sb-page-path"
158
+ placeholder="new-page"
159
+ value={pageSuffix}
160
+ oninput={(e: Event) => {
161
+ const suffix = (e.target as HTMLInputElement).value.replace(/^\/+/, '')
162
+ pagePath = `${pagePrefix}${suffix}`
163
+ }}
164
+ disabled={!selectedPrototype}
165
+ />
166
+ </div>
167
+ </div>
168
+
169
+ <div class="space-y-1">
170
+ <Label>Template / recipe</Label>
171
+ <DropdownMenu.Root bind:open={templateMenuOpen}>
172
+ <DropdownMenu.Trigger>
173
+ {#snippet child({ props })}
174
+ <button
175
+ {...props}
176
+ class="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
177
+ disabled={!selectedPrototype}
178
+ >
179
+ <span class={template ? 'text-foreground' : 'text-muted-foreground'}>{templateLabel}</span>
180
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m6 9 6 6 6-6"/></svg>
181
+ </button>
182
+ {/snippet}
183
+ </DropdownMenu.Trigger>
184
+ <DropdownMenu.Content side="left" align="start" sideOffset={8} class="min-w-[220px]">
185
+ {#if template}
186
+ <DropdownMenu.Item onclick={() => { template = ''; templateMenuOpen = false }}>
187
+ <span class="text-muted-foreground">Blank page</span>
188
+ </DropdownMenu.Item>
189
+ <DropdownMenu.Separator />
190
+ {/if}
191
+
192
+ {#if globalTemplates.length > 0}
193
+ <DropdownMenu.Group>
194
+ <DropdownMenu.GroupHeading>Templates</DropdownMenu.GroupHeading>
195
+ {#each globalTemplates as item (item.id)}
196
+ <DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
197
+ {item.name}
198
+ </DropdownMenu.Item>
199
+ {/each}
200
+ </DropdownMenu.Group>
201
+ {/if}
202
+
203
+ {#if localTemplates.length > 0}
204
+ <DropdownMenu.Separator />
205
+ <DropdownMenu.Group>
206
+ <DropdownMenu.GroupHeading>{localTemplateHeading} / Templates</DropdownMenu.GroupHeading>
207
+ {#each localTemplates as item (item.id)}
208
+ <DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
209
+ {item.name}
210
+ </DropdownMenu.Item>
211
+ {/each}
212
+ </DropdownMenu.Group>
213
+ {/if}
214
+
215
+ {#if globalRecipes.length > 0}
216
+ <DropdownMenu.Separator />
217
+ <DropdownMenu.Group>
218
+ <DropdownMenu.GroupHeading>Recipes</DropdownMenu.GroupHeading>
219
+ {#each globalRecipes as item (item.id)}
220
+ <DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
221
+ {item.name}
222
+ </DropdownMenu.Item>
223
+ {/each}
224
+ </DropdownMenu.Group>
225
+ {/if}
226
+
227
+ {#if localRecipes.length > 0}
228
+ <DropdownMenu.Separator />
229
+ <DropdownMenu.Group>
230
+ <DropdownMenu.GroupHeading>{localTemplateHeading} / Recipes</DropdownMenu.GroupHeading>
231
+ {#each localRecipes as item (item.id)}
232
+ <DropdownMenu.Item onclick={() => { template = item.id; templateMenuOpen = false }}>
233
+ {item.name}
234
+ </DropdownMenu.Item>
235
+ {/each}
236
+ </DropdownMenu.Group>
237
+ {/if}
238
+ </DropdownMenu.Content>
239
+ </DropdownMenu.Root>
240
+ </div>
241
+
242
+ {#if error}<Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>{/if}
243
+ {#if success}<Alert.Root><Alert.Description class="text-success">{success}</Alert.Description></Alert.Root>{/if}
244
+ </div>
245
+
246
+ <Panel.Footer>
247
+ <Button variant="outline" onclick={onClose}>Cancel</Button>
248
+ <Button onclick={submit} disabled={!canSubmit}>{submitting ? 'Creating…' : 'Create page'}</Button>
249
+ </Panel.Footer>
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Create Page feature — workshop form for creating pages inside a prototype.
3
+ */
4
+
5
+ import CreatePageForm from './CreatePageForm.svelte'
6
+
7
+ export const name = 'createPage'
8
+ export const label = 'Create page'
9
+ export const icon = '📄'
10
+ export const overlayId = 'createPage'
11
+ export const overlay = CreatePageForm
@@ -27,7 +27,14 @@
27
27
  let isExternal = $state(false)
28
28
  let externalUrl = $state('')
29
29
 
30
- interface Partial { directory: string; name: string; globals?: string[] }
30
+ interface Partial {
31
+ id: string
32
+ name: string
33
+ kind: 'template' | 'recipe'
34
+ scope: 'global' | 'prototype'
35
+ prototype?: string
36
+ folder?: string
37
+ }
31
38
 
32
39
  let folders: string[] = $state([])
33
40
  let partials: Partial[] = $state([])
@@ -61,9 +68,23 @@
61
68
  })
62
69
  const canSubmit = $derived(!!kebabName && !nameError && !submitting && (!isExternal || (!!externalUrl.trim() && !urlError)))
63
70
 
64
- const templateLabel = $derived(partial ? partials.find(p => p.name === partial)?.name ?? partial : 'No template')
65
- const templates = $derived(partials.filter(p => p.directory === 'template' || p.directory === 'templates'))
66
- const recipes = $derived(partials.filter(p => p.directory === 'recipe' || p.directory === 'recipes'))
71
+ const selectedFolderLabel = $derived(folder ? `${folder}.folder` : '')
72
+ const scopedPartials = $derived(
73
+ partials.filter((p) => {
74
+ if (p.scope === 'global') return true
75
+ if (!folder) return false
76
+ return p.folder === folder
77
+ })
78
+ )
79
+ const templateLabel = $derived(
80
+ partial ? scopedPartials.find((p) => p.id === partial)?.name ?? partial : 'No template'
81
+ )
82
+ const templates = $derived(scopedPartials.filter((p) => p.kind === 'template'))
83
+ const recipes = $derived(scopedPartials.filter((p) => p.kind === 'recipe'))
84
+ const globalTemplates = $derived(templates.filter((p) => p.scope === 'global'))
85
+ const globalRecipes = $derived(recipes.filter((p) => p.scope === 'global'))
86
+ const localTemplates = $derived(templates.filter((p) => p.scope === 'prototype'))
87
+ const localRecipes = $derived(recipes.filter((p) => p.scope === 'prototype'))
67
88
  let templateMenuOpen = $state(false)
68
89
 
69
90
  function getApiUrl() {
@@ -82,6 +103,14 @@
82
103
  } catch { /* defaults */ } finally { loading = false }
83
104
  })
84
105
 
106
+ $effect(() => {
107
+ if (!partial) return
108
+ const stillAvailable = scopedPartials.some((p) => p.id === partial)
109
+ if (!stillAvailable) {
110
+ partial = ''
111
+ }
112
+ })
113
+
85
114
  function handleTitleInput(e: Event) { title = (e.target as HTMLInputElement).value; titleTouched = true }
86
115
  function handleTitleBlur() { if (!title.trim()) titleTouched = false }
87
116
 
@@ -182,27 +211,51 @@
182
211
  {#if templates.length > 0}
183
212
  <DropdownMenu.Group>
184
213
  <DropdownMenu.GroupHeading>Templates</DropdownMenu.GroupHeading>
185
- {#each templates as t (t.name)}
186
- <DropdownMenu.Item onclick={() => { partial = t.name; templateMenuOpen = false }}>
187
- {t.name}
188
- </DropdownMenu.Item>
189
- {/each}
190
- </DropdownMenu.Group>
191
- {/if}
214
+ {#each globalTemplates as t (t.id)}
215
+ <DropdownMenu.Item onclick={() => { partial = t.id; templateMenuOpen = false }}>
216
+ {t.name}
217
+ </DropdownMenu.Item>
218
+ {/each}
219
+ </DropdownMenu.Group>
220
+ {/if}
192
221
 
193
- {#if recipes.length > 0}
194
- <DropdownMenu.Separator />
195
- <DropdownMenu.Group>
196
- <DropdownMenu.GroupHeading>Recipes</DropdownMenu.GroupHeading>
197
- {#each recipes as r (r.name)}
198
- <DropdownMenu.Item onclick={() => { partial = r.name; templateMenuOpen = false }}>
199
- {r.name}
200
- </DropdownMenu.Item>
201
- {/each}
202
- </DropdownMenu.Group>
203
- {/if}
204
- </DropdownMenu.Content>
205
- </DropdownMenu.Root>
222
+ {#if localTemplates.length > 0}
223
+ <DropdownMenu.Separator />
224
+ <DropdownMenu.Group>
225
+ <DropdownMenu.GroupHeading>{selectedFolderLabel || 'Prototype local'} / Templates</DropdownMenu.GroupHeading>
226
+ {#each localTemplates as t (t.id)}
227
+ <DropdownMenu.Item onclick={() => { partial = t.id; templateMenuOpen = false }}>
228
+ {t.name}
229
+ </DropdownMenu.Item>
230
+ {/each}
231
+ </DropdownMenu.Group>
232
+ {/if}
233
+
234
+ {#if globalRecipes.length > 0}
235
+ <DropdownMenu.Separator />
236
+ <DropdownMenu.Group>
237
+ <DropdownMenu.GroupHeading>Recipes</DropdownMenu.GroupHeading>
238
+ {#each globalRecipes as r (r.id)}
239
+ <DropdownMenu.Item onclick={() => { partial = r.id; templateMenuOpen = false }}>
240
+ {r.name}
241
+ </DropdownMenu.Item>
242
+ {/each}
243
+ </DropdownMenu.Group>
244
+ {/if}
245
+
246
+ {#if localRecipes.length > 0}
247
+ <DropdownMenu.Separator />
248
+ <DropdownMenu.Group>
249
+ <DropdownMenu.GroupHeading>{selectedFolderLabel || 'Prototype local'} / Recipes</DropdownMenu.GroupHeading>
250
+ {#each localRecipes as r (r.id)}
251
+ <DropdownMenu.Item onclick={() => { partial = r.id; templateMenuOpen = false }}>
252
+ {r.name}
253
+ </DropdownMenu.Item>
254
+ {/each}
255
+ </DropdownMenu.Group>
256
+ {/if}
257
+ </DropdownMenu.Content>
258
+ </DropdownMenu.Root>
206
259
  </div>
207
260
  {/if}
208
261
  </div>
@@ -17,17 +17,13 @@
17
17
 
18
18
  import fs from 'node:fs'
19
19
  import path from 'node:path'
20
+ import {
21
+ buildTemplateRecipeIndex,
22
+ resolveTemplateRecipeEntry,
23
+ } from '../templateIndex.js'
20
24
 
21
25
  const FLOW_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
22
26
 
23
- /**
24
- * Check whether a partial entry is a template (vs recipe).
25
- * Accepts both singular and plural forms: "template" or "templates".
26
- */
27
- function isTemplate(partialEntry) {
28
- return partialEntry.directory === 'template' || partialEntry.directory === 'templates'
29
- }
30
-
31
27
  // ---------------------------------------------------------------------------
32
28
  // Helpers
33
29
  // ---------------------------------------------------------------------------
@@ -120,9 +116,9 @@ function generateBlankIndexJsx(componentName, title) {
120
116
  * @param {string} title - Human-readable title
121
117
  */
122
118
  function generateIndexJsx({ partialEntry, componentFile, componentName, title }) {
123
- const importPath = `@/${partialEntry.directory}/${partialEntry.name}/${componentFile}`
119
+ const importPath = `@/${partialEntry.baseDir}/${partialEntry.name}/${componentFile}`
124
120
 
125
- if (isTemplate(partialEntry)) {
121
+ if (partialEntry.kind === 'template') {
126
122
  return `import ${componentFile} from '${importPath}'
127
123
 
128
124
  export default function ${componentName}() {
@@ -182,12 +178,14 @@ function generatePrototypeJson({ title, author, description, partialEntry, url }
182
178
  */
183
179
  export function createPrototypesHandler(ctx) {
184
180
  const { root, sendJson, workshopConfig = {} } = ctx
185
- const partials = workshopConfig.partials || []
181
+ const getTemplateRecipes = () => buildTemplateRecipeIndex(root, workshopConfig.partials)
186
182
 
187
183
  return async (req, res, { body, path: routePath, method }) => {
184
+ const templateRecipes = getTemplateRecipes()
185
+
188
186
  if (routePath === '/prototypes' && method === 'GET') {
189
187
  const folders = listFolders(root)
190
- sendJson(res, 200, { folders, partials })
188
+ sendJson(res, 200, { folders, partials: templateRecipes })
191
189
  return
192
190
  }
193
191
 
@@ -282,11 +280,11 @@ export function createPrototypesHandler(ctx) {
282
280
 
283
281
  // Look up recipe in config (optional — blank prototype if none)
284
282
  const partialEntry = partialName
285
- ? partials.find((r) => r.name === partialName)
283
+ ? resolveTemplateRecipeEntry(templateRecipes, partialName)
286
284
  : null
287
285
 
288
286
  if (partialName && !partialEntry) {
289
- const validNames = partials.map((r) => r.name).join(', ')
287
+ const validNames = templateRecipes.map((r) => r.id).join(', ')
290
288
  sendJson(res, 400, { error: `Unknown recipe "${partialName}". Available: ${validNames}` })
291
289
  return
292
290
  }
@@ -304,10 +302,10 @@ export function createPrototypesHandler(ctx) {
304
302
  if (!partialEntry) {
305
303
  content = generateBlankIndexJsx(componentName, title)
306
304
  } else {
307
- const partialDir = path.join(root, 'src', partialEntry.directory, partialEntry.name)
305
+ const partialDir = path.join(root, 'src', partialEntry.baseDir, partialEntry.name)
308
306
  const componentFile = findComponentFile(partialDir)
309
307
  if (!componentFile) {
310
- sendJson(res, 400, { error: `No .jsx or .tsx file found in src/${partialEntry.directory}/${partialEntry.name}/` })
308
+ sendJson(res, 400, { error: `No .jsx or .tsx file found in src/${partialEntry.baseDir}/${partialEntry.name}/` })
311
309
  return
312
310
  }
313
311
 
@@ -18,4 +18,5 @@ import { createFlowsHandler } from './createFlow/server.js'
18
18
  export const serverFeatures = {
19
19
  createPrototype: { serverSetup: createPrototypesHandler },
20
20
  createFlow: { serverSetup: createFlowsHandler },
21
+ createPage: { serverSetup: createFlowsHandler },
21
22
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import * as createPrototype from './createPrototype/index.js'
14
14
  import * as createFlow from './createFlow/index.js'
15
+ import * as createPage from './createPage/index.js'
15
16
  import * as createCanvas from './createCanvas/index.js'
16
17
 
17
18
  /**
@@ -20,5 +21,6 @@ import * as createCanvas from './createCanvas/index.js'
20
21
  export const features = {
21
22
  createPrototype,
22
23
  createFlow,
24
+ createPage,
23
25
  createCanvas,
24
26
  }
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { parse as parseJsonc } from 'jsonc-parser'
4
+
5
+ const TEMPLATE_DIR_NAMES = new Set(['template', 'templates'])
6
+ const RECIPE_DIR_NAMES = new Set(['recipe', 'recipes'])
7
+ const PARTIAL_DIR_NAMES = new Set([...TEMPLATE_DIR_NAMES, ...RECIPE_DIR_NAMES])
8
+
9
+ function normalizeSlashes(value) {
10
+ return value.replaceAll('\\', '/')
11
+ }
12
+
13
+ function isExternalPrototype(prototypePath) {
14
+ try {
15
+ const raw = fs.readFileSync(prototypePath, 'utf-8')
16
+ const parsed = parseJsonc(raw)
17
+ return typeof parsed?.url === 'string' && parsed.url.trim().length > 0
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ function isPartialDirName(directory) {
24
+ return PARTIAL_DIR_NAMES.has(directory)
25
+ }
26
+
27
+ function toPartialKind(directory) {
28
+ if (TEMPLATE_DIR_NAMES.has(directory)) return 'template'
29
+ if (RECIPE_DIR_NAMES.has(directory)) return 'recipe'
30
+ return null
31
+ }
32
+
33
+ function listPrototypeDirs(root) {
34
+ const prototypesDir = path.join(root, 'src', 'prototypes')
35
+ if (!fs.existsSync(prototypesDir)) return []
36
+
37
+ const results = []
38
+
39
+ function scanDir(dir, folder) {
40
+ if (!fs.existsSync(dir)) return
41
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
42
+ if (!entry.isDirectory()) continue
43
+ if (entry.name.endsWith('.folder')) {
44
+ scanDir(path.join(dir, entry.name), entry.name.replace(/\.folder$/, ''))
45
+ continue
46
+ }
47
+
48
+ const protoDir = path.join(dir, entry.name)
49
+ const prototypeFile = fs.readdirSync(protoDir).find((f) => f.endsWith('.prototype.json'))
50
+ if (!prototypeFile) continue
51
+ if (isExternalPrototype(path.join(protoDir, prototypeFile))) continue
52
+
53
+ results.push({
54
+ name: entry.name,
55
+ folder,
56
+ dir: protoDir,
57
+ })
58
+ }
59
+ }
60
+
61
+ scanDir(prototypesDir)
62
+ return results
63
+ }
64
+
65
+ function makeId(prefix, directory, name, prototype, folder) {
66
+ if (prefix === 'global') {
67
+ return `global:${directory}/${name}`
68
+ }
69
+ const folderPart = folder ? `${folder}/` : ''
70
+ return `prototype:${folderPart}${prototype}:${directory}/${name}`
71
+ }
72
+
73
+ export function buildTemplateRecipeIndex(root, configPartials = []) {
74
+ const srcRoot = path.join(root, 'src')
75
+ const results = []
76
+ const seenIds = new Set()
77
+
78
+ for (const partial of configPartials) {
79
+ if (!partial || !isPartialDirName(partial.directory) || !partial.name) continue
80
+ const kind = toPartialKind(partial.directory)
81
+ if (!kind) continue
82
+ const id = makeId('global', partial.directory, partial.name)
83
+ if (seenIds.has(id)) continue
84
+ seenIds.add(id)
85
+ results.push({
86
+ id,
87
+ name: partial.name,
88
+ directory: partial.directory,
89
+ kind,
90
+ scope: 'global',
91
+ baseDir: partial.directory,
92
+ globals: Array.isArray(partial.globals) ? partial.globals : undefined,
93
+ })
94
+ }
95
+
96
+ const prototypes = listPrototypeDirs(root)
97
+ for (const proto of prototypes) {
98
+ for (const directory of PARTIAL_DIR_NAMES) {
99
+ const localPartialsDir = path.join(proto.dir, directory)
100
+ if (!fs.existsSync(localPartialsDir)) continue
101
+
102
+ for (const entry of fs.readdirSync(localPartialsDir, { withFileTypes: true })) {
103
+ if (!entry.isDirectory()) continue
104
+ const kind = toPartialKind(directory)
105
+ if (!kind) continue
106
+
107
+ const baseDir = normalizeSlashes(path.relative(srcRoot, localPartialsDir))
108
+ const id = makeId('prototype', directory, entry.name, proto.name, proto.folder)
109
+ if (seenIds.has(id)) continue
110
+ seenIds.add(id)
111
+ results.push({
112
+ id,
113
+ name: entry.name,
114
+ directory,
115
+ kind,
116
+ scope: 'prototype',
117
+ prototype: proto.name,
118
+ ...(proto.folder ? { folder: proto.folder } : {}),
119
+ baseDir,
120
+ })
121
+ }
122
+ }
123
+ }
124
+
125
+ return results.sort((a, b) => {
126
+ if (a.scope !== b.scope) return a.scope.localeCompare(b.scope)
127
+ if ((a.prototype || '') !== (b.prototype || '')) return (a.prototype || '').localeCompare(b.prototype || '')
128
+ if ((a.folder || '') !== (b.folder || '')) return (a.folder || '').localeCompare(b.folder || '')
129
+ if (a.kind !== b.kind) return a.kind.localeCompare(b.kind)
130
+ return a.name.localeCompare(b.name)
131
+ })
132
+ }
133
+
134
+ export function resolveTemplateRecipeEntry(entries, templateValue, { prototype, folder } = {}) {
135
+ if (!templateValue || typeof templateValue !== 'string') return null
136
+
137
+ const trimmed = templateValue.trim()
138
+ if (!trimmed) return null
139
+
140
+ const byId = entries.find((entry) => entry.id === trimmed)
141
+ if (byId) return byId
142
+
143
+ const exactScoped = entries.find((entry) =>
144
+ entry.scope === 'prototype' &&
145
+ entry.name === trimmed &&
146
+ entry.prototype === prototype &&
147
+ (entry.folder || '') === (folder || '')
148
+ )
149
+ if (exactScoped) return exactScoped
150
+
151
+ const globalMatch = entries.find((entry) => entry.scope === 'global' && entry.name === trimmed)
152
+ if (globalMatch) return globalMatch
153
+
154
+ return entries.find((entry) => entry.name === trimmed) || null
155
+ }
@@ -78,6 +78,7 @@
78
78
  { "type": "header", "label": "Create" },
79
79
  { "id": "workshop/create-prototype", "label": "New prototype", "type": "default", "modes": ["*"], "feature": "createPrototype" },
80
80
  { "id": "workshop/create-flow", "label": "New flow", "type": "default", "modes": ["*"], "feature": "createFlow" },
81
+ { "id": "workshop/create-page", "label": "New page", "type": "default", "modes": ["*"], "feature": "createPage" },
81
82
  { "id": "workshop/create-canvas", "label": "New canvas", "type": "default", "modes": ["*"], "feature": "createCanvas" },
82
83
  { "type": "footer", "label": "Only available in dev environment" }
83
84
  ]
@@ -101,7 +102,7 @@
101
102
  "modes": ["*"]
102
103
  },
103
104
  "viewfinder": {
104
- "label": "Index page",
105
+ "label": "Go to index page",
105
106
  "render": "link",
106
107
  "surface": "command-list",
107
108
  "modes": ["*"],