@dfosco/storyboard-core 1.22.0 → 1.24.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.
- package/package.json +5 -2
- package/src/vite/server-plugin.js +176 -0
- package/src/workshop/features/createPage/client.js +73 -0
- package/src/workshop/features/createPage/index.js +59 -0
- package/src/workshop/features/createPage/server.js +148 -0
- package/src/workshop/features/createPage/templates/blank.html +9 -0
- package/src/workshop/features/registry.js +20 -0
- package/src/workshop/ui/mount.js +111 -0
- package/src/workshop/ui/workshop.css +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,11 +13,14 @@
|
|
|
13
13
|
],
|
|
14
14
|
"exports": {
|
|
15
15
|
".": "./src/index.js",
|
|
16
|
+
"./vite/server": "./src/vite/server-plugin.js",
|
|
16
17
|
"./comments": "./src/comments/index.js",
|
|
17
|
-
"./comments/ui/comments.css": "./src/comments/ui/comments.css"
|
|
18
|
+
"./comments/ui/comments.css": "./src/comments/ui/comments.css",
|
|
19
|
+
"./workshop/ui/mount.js": "./src/workshop/ui/mount.js"
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"alpinejs": "^3.15.8",
|
|
23
|
+
"jsonc-parser": "^3.3.1",
|
|
21
24
|
"tachyons": "^4.12.0"
|
|
22
25
|
}
|
|
23
26
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyboard Server Plugin — core dev-server infrastructure.
|
|
3
|
+
*
|
|
4
|
+
* Always-on Vite plugin that mounts a middleware backbone at `/_storyboard/`.
|
|
5
|
+
* Reads `storyboard.config.json` for workshop features and plugin config.
|
|
6
|
+
* Workshop API routes are wired directly; plugins register via the registry.
|
|
7
|
+
*
|
|
8
|
+
* Usage in vite.config.js:
|
|
9
|
+
* import storyboardServer from '@dfosco/storyboard-core/vite/server'
|
|
10
|
+
* storyboardServer() // reads storyboard.config.json, no args needed
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { parse as parseJsonc } from 'jsonc-parser'
|
|
16
|
+
import { features as workshopFeatures } from '../workshop/features/registry.js'
|
|
17
|
+
|
|
18
|
+
const API_PREFIX = '/_storyboard/'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse JSON request body from an IncomingMessage.
|
|
22
|
+
*/
|
|
23
|
+
function parseJsonBody(req) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let body = ''
|
|
26
|
+
req.on('data', (chunk) => { body += chunk })
|
|
27
|
+
req.on('end', () => {
|
|
28
|
+
if (!body) return resolve({})
|
|
29
|
+
try { resolve(JSON.parse(body)) }
|
|
30
|
+
catch { reject(new Error('Invalid JSON body')) }
|
|
31
|
+
})
|
|
32
|
+
req.on('error', reject)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send a JSON response.
|
|
38
|
+
*/
|
|
39
|
+
function sendJson(res, status, data) {
|
|
40
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
41
|
+
res.end(JSON.stringify(data))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read storyboard.config.json from the project root.
|
|
46
|
+
*/
|
|
47
|
+
function readConfig(root) {
|
|
48
|
+
const configPath = path.join(root, 'storyboard.config.json')
|
|
49
|
+
if (!fs.existsSync(configPath)) return {}
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
52
|
+
return parseJsonc(raw) || {}
|
|
53
|
+
} catch {
|
|
54
|
+
return {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if any workshop feature is enabled.
|
|
60
|
+
*/
|
|
61
|
+
function hasAnyWorkshopFeature(workshopConfig) {
|
|
62
|
+
if (!workshopConfig?.features) return false
|
|
63
|
+
return Object.values(workshopConfig.features).some(Boolean)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Core storyboard server Vite plugin.
|
|
68
|
+
*/
|
|
69
|
+
export default function storyboardServer() {
|
|
70
|
+
let root = ''
|
|
71
|
+
let base = '/'
|
|
72
|
+
let config = {}
|
|
73
|
+
|
|
74
|
+
// Route handler registry — plugins register here during setup
|
|
75
|
+
const routeHandlers = new Map()
|
|
76
|
+
const clientScripts = []
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: 'storyboard-server',
|
|
80
|
+
|
|
81
|
+
configResolved(viteConfig) {
|
|
82
|
+
root = viteConfig.root
|
|
83
|
+
base = viteConfig.base || '/'
|
|
84
|
+
config = readConfig(root)
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
configureServer(server) {
|
|
88
|
+
const workshopConfig = config.workshop || {}
|
|
89
|
+
|
|
90
|
+
// If workshop is explicitly disabled, skip everything
|
|
91
|
+
if (workshopConfig.enabled === false) return
|
|
92
|
+
|
|
93
|
+
const enabledFeatures = workshopConfig.features || {}
|
|
94
|
+
|
|
95
|
+
// Wire workshop API routes for each enabled feature
|
|
96
|
+
for (const [featureName, featureModule] of Object.entries(workshopFeatures)) {
|
|
97
|
+
if (enabledFeatures[featureName] === false) continue
|
|
98
|
+
if (featureModule.serverSetup) {
|
|
99
|
+
const templatesDir = path.resolve(
|
|
100
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
101
|
+
`../workshop/features/${featureName}/templates`
|
|
102
|
+
)
|
|
103
|
+
routeHandlers.set('workshop', featureModule.serverSetup({ root, sendJson }, templatesDir))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Inject workshop client UI when any feature is enabled
|
|
108
|
+
if (hasAnyWorkshopFeature(workshopConfig)) {
|
|
109
|
+
// Resolve the actual filesystem path for the mount script.
|
|
110
|
+
// Use /@fs/ prefix so Vite serves it through its module pipeline.
|
|
111
|
+
const mountPath = path.resolve(
|
|
112
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
113
|
+
'../workshop/ui/mount.js'
|
|
114
|
+
)
|
|
115
|
+
clientScripts.push('/@fs' + mountPath)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Plugin registry for external plugins (future use).
|
|
119
|
+
// Plugins call registerRoutes/registerClientScript in their setup().
|
|
120
|
+
// const pluginCtx = { server, root, config, registerRoutes, registerClientScript }
|
|
121
|
+
// Future: auto-discover and initialize plugins from pluginsConfig here
|
|
122
|
+
|
|
123
|
+
// Mount the /_storyboard/ middleware router
|
|
124
|
+
// Vite's dev server strips the base path from req.url for middleware,
|
|
125
|
+
// but the base-redirect plugin may redirect bare URLs first.
|
|
126
|
+
// We check both with and without base prefix.
|
|
127
|
+
server.middlewares.use(async (req, res, next) => {
|
|
128
|
+
if (!req.url) return next()
|
|
129
|
+
|
|
130
|
+
// Strip base path if present to normalize the URL
|
|
131
|
+
let url = req.url
|
|
132
|
+
const baseNoTrail = base.replace(/\/$/, '')
|
|
133
|
+
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
134
|
+
url = url.slice(baseNoTrail.length) || '/'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!url.startsWith(API_PREFIX)) return next()
|
|
138
|
+
|
|
139
|
+
// Parse: /_storyboard/{prefix}/{rest}
|
|
140
|
+
const pathAfterPrefix = url.slice(API_PREFIX.length)
|
|
141
|
+
const slashIndex = pathAfterPrefix.indexOf('/')
|
|
142
|
+
const prefix = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex)
|
|
143
|
+
const restPath = slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex)
|
|
144
|
+
|
|
145
|
+
const handler = routeHandlers.get(prefix)
|
|
146
|
+
if (!handler) {
|
|
147
|
+
sendJson(res, 404, { error: `No handler registered for prefix: ${prefix}` })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
let body = {}
|
|
153
|
+
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
|
|
154
|
+
body = await parseJsonBody(req)
|
|
155
|
+
}
|
|
156
|
+
await handler(req, res, { body, path: restPath, method: req.method })
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(`[storyboard-server] Error in ${prefix}:`, err)
|
|
159
|
+
sendJson(res, 500, { error: err.message || 'Internal server error' })
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
transformIndexHtml() {
|
|
165
|
+
if (clientScripts.length === 0) return []
|
|
166
|
+
|
|
167
|
+
return clientScripts.map((src) => ({
|
|
168
|
+
tag: 'script',
|
|
169
|
+
attrs: { type: 'module', src: base + src.replace(/^\//, '') },
|
|
170
|
+
injectTo: 'body',
|
|
171
|
+
}))
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { sendJson }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create Page form — Alpine.js component for the Workshop Dev Panel.
|
|
3
|
+
* Handles page name input, validation preview, and API submission.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register the createPageForm Alpine.js data component.
|
|
8
|
+
*/
|
|
9
|
+
export function registerCreatePageForm(Alpine) {
|
|
10
|
+
Alpine.data('createPageForm', () => ({
|
|
11
|
+
name: '',
|
|
12
|
+
createScene: true,
|
|
13
|
+
template: 'blank',
|
|
14
|
+
submitting: false,
|
|
15
|
+
error: null,
|
|
16
|
+
success: null,
|
|
17
|
+
|
|
18
|
+
get pascalName() {
|
|
19
|
+
return this.name
|
|
20
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
21
|
+
.split(/[\s_-]+/)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
24
|
+
.join('')
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
get routePreview() {
|
|
28
|
+
return this.pascalName ? `/${this.pascalName}` : ''
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async submit() {
|
|
32
|
+
if (!this.pascalName || this.submitting) return
|
|
33
|
+
this.submitting = true
|
|
34
|
+
this.error = null
|
|
35
|
+
this.success = null
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Build API URL with the app's base path
|
|
39
|
+
const basePath = document.querySelector('base')?.getAttribute('href') || '/'
|
|
40
|
+
const apiUrl = basePath.replace(/\/$/, '') + '/_storyboard/workshop/pages'
|
|
41
|
+
|
|
42
|
+
const res = await fetch(apiUrl, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
name: this.name,
|
|
47
|
+
template: this.template,
|
|
48
|
+
createScene: this.createScene,
|
|
49
|
+
}),
|
|
50
|
+
})
|
|
51
|
+
const data = await res.json()
|
|
52
|
+
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
this.error = data.error || 'Failed to create page'
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.success = `Created ${data.path}`
|
|
59
|
+
this.name = ''
|
|
60
|
+
|
|
61
|
+
// Navigate after HMR picks up the new file
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
const base = document.querySelector('base')?.href || '/'
|
|
64
|
+
window.location.href = base + data.route.slice(1)
|
|
65
|
+
}, 1500)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.error = err.message || 'Network error'
|
|
68
|
+
} finally {
|
|
69
|
+
this.submitting = false
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages feature — create new pages from the Workshop Dev Panel.
|
|
3
|
+
*
|
|
4
|
+
* Each workshop feature exports a standard interface:
|
|
5
|
+
* - name: Feature identifier (matches config key in workshop.features)
|
|
6
|
+
* - label: Display name for the menu item
|
|
7
|
+
* - icon: Emoji or HTML for the menu icon
|
|
8
|
+
* - overlayId: Unique ID for the overlay (used by x-if in mount.js)
|
|
9
|
+
* - serverSetup: Called by the server plugin to register API routes
|
|
10
|
+
* - clientSetup: Called by mount.js to register Alpine components
|
|
11
|
+
* - overlayHtml: Returns the overlay HTML string for the create form
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { createPagesHandler as serverSetup } from './server.js'
|
|
15
|
+
export { registerCreatePageForm as clientSetup } from './client.js'
|
|
16
|
+
|
|
17
|
+
export const name = 'createPage'
|
|
18
|
+
export const label = 'Create page'
|
|
19
|
+
export const icon = '📄'
|
|
20
|
+
export const overlayId = 'createPage'
|
|
21
|
+
|
|
22
|
+
export function overlayHtml() {
|
|
23
|
+
return `
|
|
24
|
+
<div class="sb-workshop-modal sb-bg ba sb-b-default br3 sb-shadow" x-data="createPageForm">
|
|
25
|
+
<div class="flex items-center justify-between ph4 pv3 bb sb-b-muted">
|
|
26
|
+
<h2 class="ma0 f5 fw6 sb-fg">Create page</h2>
|
|
27
|
+
<button class="sb-workshop-close-btn bg-transparent bn br2 sb-fg-muted pointer" @click="$dispatch('close-overlay')" aria-label="Close">×</button>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="pa4" @close-overlay.window="closeOverlay()">
|
|
30
|
+
<label class="db mb1 fw5 sb-fg f6" for="sb-workshop-page-name">Page name</label>
|
|
31
|
+
<input class="sb-input w-100 ph3 pv2 br2 f6 db mb2" id="sb-workshop-page-name" type="text"
|
|
32
|
+
placeholder="e.g. Dashboard, User Settings" autocomplete="off" spellcheck="false"
|
|
33
|
+
x-model="name" @keydown.enter="submit()" />
|
|
34
|
+
|
|
35
|
+
<div class="mb3 f7 sb-fg-muted lh-copy" x-show="routePreview">
|
|
36
|
+
Route: <code class="dib ph1 sb-bg-muted br1 code sb-fg" x-text="routePreview"></code>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<label class="flex items-center mb3 pointer sb-fg f6">
|
|
40
|
+
<input type="checkbox" class="mr2" x-model="createScene" />
|
|
41
|
+
Create scene file
|
|
42
|
+
</label>
|
|
43
|
+
|
|
44
|
+
<template x-if="error">
|
|
45
|
+
<div class="mb3 ph3 pv2 br2 sb-fg-danger f7 sb-error-bg" x-text="error"></div>
|
|
46
|
+
</template>
|
|
47
|
+
<template x-if="success">
|
|
48
|
+
<div class="mb3 ph3 pv2 br2 sb-fg-success f7" x-text="success"></div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<div class="flex justify-end">
|
|
52
|
+
<button class="sb-workshop-btn sb-workshop-btn-secondary mr2" @click="$dispatch('close-overlay')">Cancel</button>
|
|
53
|
+
<button class="sb-workshop-btn sb-workshop-btn-primary" @click="submit()" :disabled="!pascalName || submitting"
|
|
54
|
+
x-text="submitting ? 'Creating…' : 'Create'"></button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
`
|
|
59
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workshop API — page creation and listing.
|
|
3
|
+
*
|
|
4
|
+
* Routes (mounted at /_storyboard/workshop/):
|
|
5
|
+
* GET /pages — list existing pages
|
|
6
|
+
* POST /pages — create a new page (+ optional scene)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
const SCENE_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a raw name to PascalCase for use as component name + filename.
|
|
16
|
+
* "my cool page" → "MyCoolPage"
|
|
17
|
+
*/
|
|
18
|
+
function toPascalCase(str) {
|
|
19
|
+
return str
|
|
20
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
21
|
+
.split(/[\s_-]+/)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
24
|
+
.join('')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a page name.
|
|
29
|
+
*/
|
|
30
|
+
function validatePageName(name) {
|
|
31
|
+
if (!name || typeof name !== 'string') {
|
|
32
|
+
return { valid: false, error: 'Page name is required' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pascalName = toPascalCase(name.trim())
|
|
36
|
+
|
|
37
|
+
if (!pascalName) {
|
|
38
|
+
return { valid: false, error: 'Page name must contain at least one alphanumeric character' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (/^[0-9]/.test(pascalName)) {
|
|
42
|
+
return { valid: false, error: 'Page name cannot start with a number' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const reserved = ['_app', 'index', 'App', 'Index']
|
|
46
|
+
if (reserved.includes(pascalName)) {
|
|
47
|
+
return { valid: false, error: `"${pascalName}" is a reserved page name` }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { valid: true, pascalName }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a template file and replace {{PageName}} placeholders.
|
|
55
|
+
*/
|
|
56
|
+
function renderTemplate(templatesDir, templateName, pageName) {
|
|
57
|
+
const tplPath = path.join(templatesDir, `${templateName}.html`)
|
|
58
|
+
if (!fs.existsSync(tplPath)) {
|
|
59
|
+
throw new Error(`Template not found: ${templateName}`)
|
|
60
|
+
}
|
|
61
|
+
const tpl = fs.readFileSync(tplPath, 'utf-8')
|
|
62
|
+
return tpl.replaceAll('{{PageName}}', pageName)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* List all existing page files in src/pages/.
|
|
67
|
+
*/
|
|
68
|
+
function listPages(root) {
|
|
69
|
+
const pagesDir = path.join(root, 'src', 'pages')
|
|
70
|
+
if (!fs.existsSync(pagesDir)) return []
|
|
71
|
+
|
|
72
|
+
return fs.readdirSync(pagesDir)
|
|
73
|
+
.filter((f) => f.endsWith('.jsx') && !f.startsWith('_'))
|
|
74
|
+
.map((f) => {
|
|
75
|
+
const name = f.replace('.jsx', '')
|
|
76
|
+
const route = name === 'index' ? '/' : `/${name}`
|
|
77
|
+
return { name, file: f, route }
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create the pages API route handler.
|
|
83
|
+
* @param {object} ctx - Server context ({ root, sendJson })
|
|
84
|
+
* @param {string} templatesDir - Absolute path to templates directory
|
|
85
|
+
*/
|
|
86
|
+
export function createPagesHandler(ctx, templatesDir) {
|
|
87
|
+
const { root, sendJson } = ctx
|
|
88
|
+
|
|
89
|
+
return async (req, res, { body, path: routePath, method }) => {
|
|
90
|
+
if (routePath === '/pages' && method === 'GET') {
|
|
91
|
+
const pages = listPages(root)
|
|
92
|
+
sendJson(res, 200, { pages })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (routePath === '/pages' && method === 'POST') {
|
|
97
|
+
const { name, template = 'blank', createScene = true } = body
|
|
98
|
+
|
|
99
|
+
const validation = validatePageName(name)
|
|
100
|
+
if (!validation.valid) {
|
|
101
|
+
sendJson(res, 400, { error: validation.error })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { pascalName } = validation
|
|
106
|
+
const pagesDir = path.join(root, 'src', 'pages')
|
|
107
|
+
const pagePath = path.join(pagesDir, `${pascalName}.jsx`)
|
|
108
|
+
|
|
109
|
+
if (fs.existsSync(pagePath)) {
|
|
110
|
+
sendJson(res, 409, { error: `Page "${pascalName}" already exists` })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let content
|
|
115
|
+
try {
|
|
116
|
+
content = renderTemplate(templatesDir, template, pascalName)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
sendJson(res, 400, { error: err.message })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fs.mkdirSync(pagesDir, { recursive: true })
|
|
123
|
+
fs.writeFileSync(pagePath, content, 'utf-8')
|
|
124
|
+
|
|
125
|
+
const result = {
|
|
126
|
+
success: true,
|
|
127
|
+
path: `src/pages/${pascalName}.jsx`,
|
|
128
|
+
route: `/${pascalName}`,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (createScene) {
|
|
132
|
+
const dataDir = path.join(root, 'src', 'data')
|
|
133
|
+
const scenePath = path.join(dataDir, `${pascalName}.scene.json`)
|
|
134
|
+
|
|
135
|
+
if (!fs.existsSync(scenePath)) {
|
|
136
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
137
|
+
fs.writeFileSync(scenePath, SCENE_SKELETON, 'utf-8')
|
|
138
|
+
result.scenePath = `src/data/${pascalName}.scene.json`
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
sendJson(res, 201, result)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
sendJson(res, 404, { error: `Unknown route: ${method} ${routePath}` })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workshop feature registry.
|
|
3
|
+
*
|
|
4
|
+
* Maps feature names (matching keys in storyboard.config.json → workshop.features)
|
|
5
|
+
* to their module paths. The server plugin and client mount use this registry
|
|
6
|
+
* to dynamically load only the enabled features.
|
|
7
|
+
*
|
|
8
|
+
* To add a new feature:
|
|
9
|
+
* 1. Create a directory under features/ with index.js exporting the standard interface
|
|
10
|
+
* 2. Add its import here
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as createPage from './createPage/index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* All available workshop features, keyed by config name.
|
|
17
|
+
*/
|
|
18
|
+
export const features = {
|
|
19
|
+
createPage,
|
|
20
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workshop Dev Panel — floating button + menu + overlays.
|
|
3
|
+
*
|
|
4
|
+
* Uses Alpine.js for reactivity and Tachyons + sb-* tokens for styling.
|
|
5
|
+
* Injected into the page by the storyboard server plugin via transformIndexHtml.
|
|
6
|
+
*
|
|
7
|
+
* Features are loaded from the registry and rendered dynamically based on
|
|
8
|
+
* which features are enabled in storyboard.config.json → workshop.features.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import Alpine from 'alpinejs'
|
|
12
|
+
import './workshop.css'
|
|
13
|
+
import { features as allFeatures } from '../features/registry.js'
|
|
14
|
+
|
|
15
|
+
const WRENCH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M5.433 2.304A4.494 4.494 0 0 0 3.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64v.008l-.164 3.337a.75.75 0 1 1-1.498-.073l.163-3.34c.007-.14-.1-.313-.36-.465A5.986 5.986 0 0 1 2 6a5.994 5.994 0 0 1 2.567-4.92 1.482 1.482 0 0 1 1.673-.04c.462.296.76.827.76 1.423v2.076c0 .332.214.572.491.572.268 0 .492-.24.492-.572V2.463c0-.596.298-1.127.76-1.423a1.482 1.482 0 0 1 1.673.04A5.994 5.994 0 0 1 13 6a5.986 5.986 0 0 1-2.633 4.909c-.26.152-.367.325-.36.465l.164 3.34a.75.75 0 1 1-1.498.073l-.164-3.337v-.008c-.027-.717.384-1.312.902-1.64A4.494 4.494 0 0 0 11.5 6a4.494 4.494 0 0 0-1.933-3.696c-.024.017-.067.067-.067.159v2.076c0 1.074-.84 2.072-1.991 2.072-1.161 0-2.009-.998-2.009-2.072V2.463c0-.092-.043-.142-.067-.16Z"></path></svg>`
|
|
16
|
+
|
|
17
|
+
let _mounted = false
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve which features are enabled from a data attribute on the script tag,
|
|
21
|
+
* injected by the server plugin. Falls back to all features enabled.
|
|
22
|
+
*/
|
|
23
|
+
function getEnabledFeatures() {
|
|
24
|
+
const script = document.querySelector('script[data-workshop-features]')
|
|
25
|
+
if (script) {
|
|
26
|
+
try { return JSON.parse(script.dataset.workshopFeatures) } catch { /* ignore */ }
|
|
27
|
+
}
|
|
28
|
+
// Fallback: enable all registered features
|
|
29
|
+
return Object.fromEntries(Object.keys(allFeatures).map((k) => [k, true]))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function mountWorkshop() {
|
|
33
|
+
if (_mounted) return
|
|
34
|
+
_mounted = true
|
|
35
|
+
|
|
36
|
+
const enabledConfig = getEnabledFeatures()
|
|
37
|
+
const enabledFeatures = Object.entries(allFeatures)
|
|
38
|
+
.filter(([name]) => enabledConfig[name] !== false)
|
|
39
|
+
|
|
40
|
+
// Register Alpine panel component
|
|
41
|
+
Alpine.data('workshopPanel', () => ({
|
|
42
|
+
open: false,
|
|
43
|
+
overlay: null,
|
|
44
|
+
toggle() { this.open = !this.open },
|
|
45
|
+
close() { this.open = false },
|
|
46
|
+
showOverlay(name) {
|
|
47
|
+
this.overlay = name
|
|
48
|
+
this.open = false
|
|
49
|
+
},
|
|
50
|
+
closeOverlay() { this.overlay = null },
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
// Let each enabled feature register its Alpine components
|
|
54
|
+
for (const [, feature] of enabledFeatures) {
|
|
55
|
+
if (feature.clientSetup) feature.clientSetup(Alpine)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build menu items from enabled features
|
|
59
|
+
const menuItems = enabledFeatures
|
|
60
|
+
.filter(([, f]) => f.label && f.overlayId)
|
|
61
|
+
.map(([, f]) => `
|
|
62
|
+
<button class="sb-workshop-menu-item" @click="showOverlay('${f.overlayId}')">
|
|
63
|
+
<span class="sb-workshop-menu-icon">${f.icon || ''}</span> ${f.label}
|
|
64
|
+
</button>
|
|
65
|
+
`).join('')
|
|
66
|
+
|
|
67
|
+
// Build overlay templates from enabled features
|
|
68
|
+
const overlays = enabledFeatures
|
|
69
|
+
.filter(([, f]) => f.overlayId && f.overlayHtml)
|
|
70
|
+
.map(([, f]) => `
|
|
71
|
+
<template x-if="overlay === '${f.overlayId}'">
|
|
72
|
+
<div class="sb-workshop-backdrop" @click.self="closeOverlay()">
|
|
73
|
+
${f.overlayHtml()}
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
`).join('')
|
|
77
|
+
|
|
78
|
+
const container = document.createElement('div')
|
|
79
|
+
container.id = 'sb-workshop'
|
|
80
|
+
container.innerHTML = `
|
|
81
|
+
<div class="sb-workshop-wrapper" x-data="workshopPanel">
|
|
82
|
+
<button class="sb-workshop-trigger" @click="toggle()" aria-label="Workshop" title="Workshop">
|
|
83
|
+
${WRENCH_ICON}
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
<div class="sb-workshop-menu" x-show="open" x-transition @click.outside="close()">
|
|
87
|
+
<div class="sb-workshop-menu-header">Workshop</div>
|
|
88
|
+
${menuItems}
|
|
89
|
+
<div class="sb-workshop-hint">Dev-only tools</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
${overlays}
|
|
93
|
+
</div>
|
|
94
|
+
`
|
|
95
|
+
|
|
96
|
+
document.body.appendChild(container)
|
|
97
|
+
Alpine.initTree(container)
|
|
98
|
+
|
|
99
|
+
// Cmd+. / Ctrl+. toggles workshop visibility (matches devtools shortcut)
|
|
100
|
+
const wrapper = container.querySelector('.sb-workshop-wrapper')
|
|
101
|
+
let visible = true
|
|
102
|
+
window.addEventListener('keydown', (e) => {
|
|
103
|
+
if (e.key === '.' && (e.metaKey || e.ctrlKey)) {
|
|
104
|
+
visible = !visible
|
|
105
|
+
wrapper.style.display = visible ? '' : 'none'
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Auto-mount
|
|
111
|
+
mountWorkshop()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Workshop Dev Panel styles.
|
|
3
|
+
Uses Tachyons utilities + sb-* custom properties from the storyboard theme system.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* --- Wrapper & trigger --- */
|
|
7
|
+
.sb-workshop-wrapper {
|
|
8
|
+
position: fixed;
|
|
9
|
+
bottom: 24px;
|
|
10
|
+
right: 76px; /* offset to sit left of the DevTools beaker (24 + 48 + 4) */
|
|
11
|
+
z-index: 9999;
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.sb-workshop-trigger {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
padding: 12px;
|
|
19
|
+
background-color: var(--sb-bg, #161b22);
|
|
20
|
+
color: var(--sb-fg-muted, #8b949e);
|
|
21
|
+
border: 1px solid var(--sb-border, #30363d);
|
|
22
|
+
border-radius: 50%;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
25
|
+
transition: opacity 150ms ease, transform 150ms ease;
|
|
26
|
+
user-select: none;
|
|
27
|
+
}
|
|
28
|
+
.sb-workshop-trigger:hover { transform: scale(1.05); }
|
|
29
|
+
.sb-workshop-trigger:active { transform: scale(0.97); }
|
|
30
|
+
.sb-workshop-trigger svg { width: 16px; height: 16px; fill: currentColor; }
|
|
31
|
+
|
|
32
|
+
/* --- Menu --- */
|
|
33
|
+
.sb-workshop-menu {
|
|
34
|
+
position: absolute;
|
|
35
|
+
bottom: 56px;
|
|
36
|
+
right: 0;
|
|
37
|
+
min-width: 200px;
|
|
38
|
+
background-color: var(--sb-bg, #161b22);
|
|
39
|
+
border: 1px solid var(--sb-border, #30363d);
|
|
40
|
+
border-radius: 12px;
|
|
41
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.sb-workshop-menu-header {
|
|
46
|
+
padding: 10px 16px 6px;
|
|
47
|
+
font-size: 11px;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
text-transform: uppercase;
|
|
50
|
+
letter-spacing: 0.05em;
|
|
51
|
+
color: var(--sb-fg-muted, #8b949e);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.sb-workshop-menu-item {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 8px;
|
|
58
|
+
width: 100%;
|
|
59
|
+
padding: 10px 16px;
|
|
60
|
+
font-size: 13px;
|
|
61
|
+
color: var(--sb-fg, #e6edf3);
|
|
62
|
+
background: none;
|
|
63
|
+
border: none;
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
text-align: left;
|
|
66
|
+
}
|
|
67
|
+
.sb-workshop-menu-item:hover {
|
|
68
|
+
background-color: var(--sb-bg-muted, #21262d);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.sb-workshop-menu-icon {
|
|
72
|
+
width: 16px;
|
|
73
|
+
height: 16px;
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
justify-content: center;
|
|
77
|
+
font-size: 14px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.sb-workshop-hint {
|
|
81
|
+
padding: 8px 16px;
|
|
82
|
+
font-size: 11px;
|
|
83
|
+
color: var(--sb-fg-muted, #8b949e);
|
|
84
|
+
border-top: 1px solid var(--sb-border-muted, #21262d);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* --- Modal backdrop & panel --- */
|
|
88
|
+
.sb-workshop-backdrop {
|
|
89
|
+
position: fixed;
|
|
90
|
+
top: 0;
|
|
91
|
+
right: 0;
|
|
92
|
+
bottom: 0;
|
|
93
|
+
left: 0;
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
98
|
+
z-index: 10000;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.sb-workshop-modal {
|
|
102
|
+
width: 100%;
|
|
103
|
+
max-width: 480px;
|
|
104
|
+
background-color: var(--sb-bg, #161b22);
|
|
105
|
+
color: var(--sb-fg, #e6edf3);
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.sb-workshop-close-btn {
|
|
110
|
+
font-size: 20px;
|
|
111
|
+
line-height: 1;
|
|
112
|
+
width: 32px;
|
|
113
|
+
height: 32px;
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
}
|
|
118
|
+
.sb-workshop-close-btn:hover {
|
|
119
|
+
background-color: var(--sb-bg-muted, #21262d);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* --- Buttons --- */
|
|
123
|
+
.sb-workshop-btn {
|
|
124
|
+
display: inline-flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
padding: 6px 16px;
|
|
127
|
+
font-size: 13px;
|
|
128
|
+
font-weight: 500;
|
|
129
|
+
border-radius: 6px;
|
|
130
|
+
border: 1px solid transparent;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
transition: background-color 100ms ease;
|
|
133
|
+
}
|
|
134
|
+
.sb-workshop-btn:disabled {
|
|
135
|
+
opacity: 0.5;
|
|
136
|
+
cursor: not-allowed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.sb-workshop-btn-primary {
|
|
140
|
+
background-color: var(--sb-btn-success, #238636);
|
|
141
|
+
color: #ffffff;
|
|
142
|
+
border-color: rgba(240, 246, 252, 0.1);
|
|
143
|
+
}
|
|
144
|
+
.sb-workshop-btn-primary:hover:not(:disabled) {
|
|
145
|
+
background-color: #2ea043;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.sb-workshop-btn-secondary {
|
|
149
|
+
background-color: var(--sb-bg-muted, #21262d);
|
|
150
|
+
color: var(--sb-fg, #e6edf3);
|
|
151
|
+
border-color: var(--sb-border, #30363d);
|
|
152
|
+
}
|
|
153
|
+
.sb-workshop-btn-secondary:hover:not(:disabled) {
|
|
154
|
+
background-color: var(--sb-border-muted, #30363d);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* --- Error state --- */
|
|
158
|
+
.sb-error-bg {
|
|
159
|
+
background-color: rgba(209, 36, 47, 0.1);
|
|
160
|
+
}
|