@octane-xplat/workshop 0.0.0-nightly.g2e22171
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/LICENSE +21 -0
- package/MOBILE.md +143 -0
- package/README.md +307 -0
- package/dist/node/index.js +50 -0
- package/package.json +127 -0
- package/src/catalog.ts +58 -0
- package/src/contract.ts +257 -0
- package/src/controls.ts +159 -0
- package/src/discover.mjs +584 -0
- package/src/errors.ts +47 -0
- package/src/fixture.ts +158 -0
- package/src/index.ts +6 -0
- package/src/macos/controller.ts +170 -0
- package/src/macos/index.macos.ts +2 -0
- package/src/macos/shell.macos.mjs +673 -0
- package/src/macos/stage.macos.ts +182 -0
- package/src/macos-node.mjs +95 -0
- package/src/macos-vite.mjs +47 -0
- package/src/mobile/controller.ts +134 -0
- package/src/mobile/entry.mobile.ts +607 -0
- package/src/mobile/index.mobile.ts +3 -0
- package/src/mobile/mount.mobile.ts +60 -0
- package/src/mobile/tree.mobile.tsrx +17 -0
- package/src/mobile-node.mjs +126 -0
- package/src/mobile-regex.mjs +60 -0
- package/src/node.d.ts +65 -0
- package/src/node.mjs +536 -0
- package/src/session.ts +520 -0
- package/src/vite-mobile.d.ts +21 -0
- package/src/vite-mobile.mjs +101 -0
- package/src/vite.d.ts +11 -0
- package/src/vite.mjs +315 -0
- package/src/web/entry.web.ts +270 -0
- package/src/web/index.web.ts +7 -0
- package/src/web/mount.web.ts +86 -0
- package/src/web/shell.css +449 -0
- package/src/web/shell.web.tsrx +759 -0
- package/src/windows/preflight.mjs +113 -0
- package/types/generated/.tsrx-typegen-manifest.json +24 -0
- package/types/generated/catalog.d.ts +30 -0
- package/types/generated/contract.d.ts +145 -0
- package/types/generated/controls.d.ts +28 -0
- package/types/generated/errors.d.ts +13 -0
- package/types/generated/fixture.d.ts +53 -0
- package/types/generated/index.d.ts +6 -0
- package/types/generated/macos/controller.d.ts +52 -0
- package/types/generated/macos/index.macos.d.ts +2 -0
- package/types/generated/macos/stage.macos.d.ts +10 -0
- package/types/generated/mobile/controller.d.ts +31 -0
- package/types/generated/mobile/entry.mobile.d.ts +22 -0
- package/types/generated/mobile/index.mobile.d.ts +3 -0
- package/types/generated/mobile/mount.mobile.d.ts +4 -0
- package/types/generated/mobile/tree.mobile.d.ts +4 -0
- package/types/generated/session.d.ts +84 -0
- package/types/generated/web/entry.web.d.ts +53 -0
- package/types/generated/web/index.web.d.ts +7 -0
- package/types/generated/web/mount.web.d.ts +2 -0
- package/types/generated/web/shell.web.d.ts +4 -0
package/src/vite.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xplatWorkshop — the workshop dev-server plugin.
|
|
3
|
+
*
|
|
4
|
+
* Emits a virtual catalog module whose metadata comes from static discovery
|
|
5
|
+
* (never module evaluation), a virtual entry that boots the adaptive shell,
|
|
6
|
+
* and a virtual index.html. Story modules stay lazily imported per selection;
|
|
7
|
+
* loaders exist only for stories applicable to the build target, so a
|
|
8
|
+
* platform-specific leaf is never imported into an incompatible bundle.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
11
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { discoverStories, normalizeInclude } from './discover.mjs'
|
|
14
|
+
|
|
15
|
+
const CATALOG_ID = '\0xplat-workshop-catalog'
|
|
16
|
+
const ENTRY_ID = '\0xplat-workshop-entry'
|
|
17
|
+
const STORY_FILE_RE =
|
|
18
|
+
/\.stories(?:\.(?:web|mobile|ios|android|macos|windows|linux))?\.(?:[cm]?[jt]s|[jt]sx|[jt]srx)$/
|
|
19
|
+
|
|
20
|
+
const WEB_EXTENSIONS = [
|
|
21
|
+
'.web.tsrx',
|
|
22
|
+
'.tsrx',
|
|
23
|
+
'.web.tsx',
|
|
24
|
+
'.tsx',
|
|
25
|
+
'.web.ts',
|
|
26
|
+
'.mjs',
|
|
27
|
+
'.mts',
|
|
28
|
+
'.ts',
|
|
29
|
+
'.jsx',
|
|
30
|
+
'.js',
|
|
31
|
+
'.json',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
// Per-target resolution for DOM-rendering hosts. Linux renders the same DOM
|
|
35
|
+
// inside the system webview, so its chain mirrors the consumer Linux preset
|
|
36
|
+
// (apps/linux/vite.linux.config.ts): .linux → .web → shared, conditions
|
|
37
|
+
// 'linux' then 'web'.
|
|
38
|
+
const TARGET_RESOLUTION = {
|
|
39
|
+
web: { conditions: ['web'], extensions: WEB_EXTENSIONS },
|
|
40
|
+
linux: {
|
|
41
|
+
conditions: ['linux', 'web'],
|
|
42
|
+
extensions: [
|
|
43
|
+
'.linux.tsrx',
|
|
44
|
+
'.web.tsrx',
|
|
45
|
+
'.tsrx',
|
|
46
|
+
'.linux.tsx',
|
|
47
|
+
'.web.tsx',
|
|
48
|
+
'.tsx',
|
|
49
|
+
'.linux.ts',
|
|
50
|
+
'.web.ts',
|
|
51
|
+
'.mjs',
|
|
52
|
+
'.mts',
|
|
53
|
+
'.ts',
|
|
54
|
+
'.jsx',
|
|
55
|
+
'.js',
|
|
56
|
+
'.json',
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fsUrl = (file) => `/@fs/${file.split(sep).join('/')}`
|
|
62
|
+
|
|
63
|
+
function catalogCode(root, config, target) {
|
|
64
|
+
const storiesConfig = config?.stories ?? {}
|
|
65
|
+
const { entries, diagnostics } = discoverStories(root, {
|
|
66
|
+
include: storiesConfig.include,
|
|
67
|
+
exclude: storiesConfig.exclude,
|
|
68
|
+
target,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const include = JSON.stringify(normalizeInclude(storiesConfig.include))
|
|
72
|
+
const loaders = entries
|
|
73
|
+
.filter((entry) => entry.applicable && diagnostics.length === 0)
|
|
74
|
+
.map(
|
|
75
|
+
(entry) => `\t${JSON.stringify(entry.id)}: (stamp) => stamp === undefined
|
|
76
|
+
\t\t? import(${JSON.stringify(fsUrl(entry.file))})
|
|
77
|
+
\t\t: import(/* @vite-ignore */ ${JSON.stringify(fsUrl(entry.file))} + '?t=' + encodeURIComponent(String(stamp))),`,
|
|
78
|
+
)
|
|
79
|
+
.join('\n')
|
|
80
|
+
|
|
81
|
+
return `import { catalogDidUpdate } from '@octane-xplat/workshop/web'
|
|
82
|
+
|
|
83
|
+
export const catalogMeta = ${JSON.stringify(entries)}
|
|
84
|
+
export const diagnostics = ${JSON.stringify(diagnostics)}
|
|
85
|
+
export const includePatterns = ${include}
|
|
86
|
+
|
|
87
|
+
const loaders = {
|
|
88
|
+
${loaders}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const catalog = catalogMeta.map((entry) =>
|
|
92
|
+
loaders[entry.id] ? { ...entry, load: loaders[entry.id] } : entry,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
export function loadStory(id, stamp) {
|
|
96
|
+
const entry = catalog.find((item) => item.id === id)
|
|
97
|
+
if (!entry?.load) {
|
|
98
|
+
return Promise.reject(
|
|
99
|
+
new Error(
|
|
100
|
+
entry
|
|
101
|
+
? 'story "' + id + '" is not supported on this target'
|
|
102
|
+
: 'story "' + id + '" is not in the catalog',
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return entry.load(stamp)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
catalogDidUpdate(catalog, diagnostics)
|
|
110
|
+
if (import.meta.hot) {
|
|
111
|
+
import.meta.hot.accept((next) => catalogDidUpdate(next?.catalog ?? catalog, next?.diagnostics ?? diagnostics))
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function entryCode(config, target, story, packageDir, root) {
|
|
117
|
+
const providerModules = config?.providerModules ?? []
|
|
118
|
+
const imports = providerModules
|
|
119
|
+
.map((specifier, index) => {
|
|
120
|
+
const abs = resolve(root, specifier)
|
|
121
|
+
return `import * as __workshopPreset${index} from ${JSON.stringify(fsUrl(abs))}`
|
|
122
|
+
})
|
|
123
|
+
.join('\n')
|
|
124
|
+
|
|
125
|
+
const providers = providerModules
|
|
126
|
+
.map(
|
|
127
|
+
(_, index) =>
|
|
128
|
+
`...(__workshopPreset${index}.providers ?? __workshopPreset${index}.default ?? [])`,
|
|
129
|
+
)
|
|
130
|
+
.join(', ')
|
|
131
|
+
|
|
132
|
+
const shellCss = fsUrl(join(packageDir, 'web', 'shell.css'))
|
|
133
|
+
|
|
134
|
+
return `import { catalog, loadStory, diagnostics } from ${JSON.stringify(CATALOG_ID)}
|
|
135
|
+
import { startWorkshop } from '@octane-xplat/workshop/web'
|
|
136
|
+
import '@octane-xplat/ui/theme/tokens.css'
|
|
137
|
+
import ${JSON.stringify(shellCss)}
|
|
138
|
+
${imports}
|
|
139
|
+
|
|
140
|
+
startWorkshop({
|
|
141
|
+
catalog,
|
|
142
|
+
diagnostics,
|
|
143
|
+
loadStory,
|
|
144
|
+
target: ${JSON.stringify(target)},
|
|
145
|
+
initialStoryId: ${JSON.stringify(story ?? null)},
|
|
146
|
+
globalProviders: [${providers}],
|
|
147
|
+
hot: import.meta.hot,
|
|
148
|
+
})
|
|
149
|
+
`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const WORKSHOP_HTML = `<!doctype html>
|
|
153
|
+
<html lang="en">
|
|
154
|
+
<head>
|
|
155
|
+
<meta charset="utf-8" />
|
|
156
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
157
|
+
<title>octane-xplat workshop</title>
|
|
158
|
+
<style>html,body{margin:0;height:100%}#workshop-root{height:100%}</style>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<div id="workshop-root"></div>
|
|
162
|
+
<script type="module" src="/@id/__x00__xplat-workshop-entry"></script>
|
|
163
|
+
</body>
|
|
164
|
+
</html>
|
|
165
|
+
`
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {object} [options]
|
|
169
|
+
* @param {string} options.cwd - consumer project root
|
|
170
|
+
* @param {object} [options.config] - resolved workshop.config.* contents
|
|
171
|
+
* @param {string} [options.target] - launch target (DOM hosts: 'web' | 'linux')
|
|
172
|
+
* @param {string} [options.story] - initial stable story id
|
|
173
|
+
*/
|
|
174
|
+
export function xplatWorkshop(options = {}) {
|
|
175
|
+
const target = options.target ?? 'web'
|
|
176
|
+
const story = options.story ?? null
|
|
177
|
+
const resolution = TARGET_RESOLUTION[target] ?? TARGET_RESOLUTION.web
|
|
178
|
+
let root = options.cwd ?? process.cwd()
|
|
179
|
+
let config = options.config ?? {}
|
|
180
|
+
|
|
181
|
+
const virtualModules = {
|
|
182
|
+
name: 'xplat-workshop-virtual',
|
|
183
|
+
configResolved(resolved) {
|
|
184
|
+
root = options.cwd ?? resolved.root
|
|
185
|
+
},
|
|
186
|
+
resolveId(id) {
|
|
187
|
+
if (id === CATALOG_ID || id === ENTRY_ID) {
|
|
188
|
+
return id
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return null
|
|
192
|
+
},
|
|
193
|
+
load(id) {
|
|
194
|
+
if (id === CATALOG_ID) {
|
|
195
|
+
return catalogCode(root, config, target)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (id === ENTRY_ID) {
|
|
199
|
+
return entryCode(config, target, story, dirname(fileURLToPath(import.meta.url)), root)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return null
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const shell = {
|
|
207
|
+
name: 'xplat-workshop-shell',
|
|
208
|
+
config() {
|
|
209
|
+
return {
|
|
210
|
+
optimizeDeps: {
|
|
211
|
+
exclude: ['@octane-xplat/workshop'],
|
|
212
|
+
// The packed workshop is excluded from compilation, so explicitly
|
|
213
|
+
// optimize its portable composition dependency and CJS transitive graph.
|
|
214
|
+
include: ['@octane-xplat/workshop > storybook/preview-api'],
|
|
215
|
+
},
|
|
216
|
+
resolve: {
|
|
217
|
+
conditions: [...resolution.conditions],
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
configResolved(resolved) {
|
|
222
|
+
// The octane plugin's extension list merges first, which would let the
|
|
223
|
+
// unsuffixed .tsrx leaf shadow the target's leaf. Environments carry
|
|
224
|
+
// their own resolved lists — rewrite every one.
|
|
225
|
+
const apply = (scope) => {
|
|
226
|
+
scope.resolve.extensions = [
|
|
227
|
+
...resolution.extensions,
|
|
228
|
+
...scope.resolve.extensions.filter((ext) => !resolution.extensions.includes(ext)),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
scope.resolve.conditions = [
|
|
232
|
+
...resolution.conditions,
|
|
233
|
+
...scope.resolve.conditions.filter((cond) => !resolution.conditions.includes(cond)),
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
apply(resolved)
|
|
238
|
+
for (const env of Object.values(resolved.environments ?? {})) {
|
|
239
|
+
apply(env)
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
hotUpdate: {
|
|
243
|
+
// 'pre' plus registration ahead of the octane plugin: octane's own
|
|
244
|
+
// hotUpdate full-reloads every octane file whose modules are not all
|
|
245
|
+
// self-accepting, which would reset the workshop page on every story
|
|
246
|
+
// save. Answering with the accepting catalog alone makes octane's
|
|
247
|
+
// every-self-accepting early return match, while the catalog update
|
|
248
|
+
// re-discovers metadata and remounts the story through a fresh
|
|
249
|
+
// stamped loader import.
|
|
250
|
+
order: 'pre',
|
|
251
|
+
handler(ctx) {
|
|
252
|
+
if (this.environment.name !== 'client' || !STORY_FILE_RE.test(ctx.file)) {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const catalog = this.environment.moduleGraph.getModuleById(CATALOG_ID)
|
|
257
|
+
if (!catalog) {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.environment.moduleGraph.invalidateModule(catalog)
|
|
262
|
+
// The environment-aware hook includes create/delete. Stamped story
|
|
263
|
+
// nodes carry no graph importer edge (@vite-ignore loader import),
|
|
264
|
+
// so seeding them would dead-end propagation into a full reload.
|
|
265
|
+
return [catalog]
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
configureServer(s) {
|
|
269
|
+
// The workshop page is a separate dev entry — intercept '/' before
|
|
270
|
+
// vite's own index fallback can serve the consumer app's document.
|
|
271
|
+
s.middlewares.use((req, res, next) => {
|
|
272
|
+
const url = (req.url ?? '').split('?')[0]
|
|
273
|
+
if (url === '/' || url === '/index.html') {
|
|
274
|
+
res.statusCode = 200
|
|
275
|
+
res.setHeader('content-type', 'text/html; charset=utf-8')
|
|
276
|
+
res.end(WORKSHOP_HTML)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (url.startsWith('/@workshop-assets/')) {
|
|
281
|
+
return serveAsset(config, root, url, res, next)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
next()
|
|
285
|
+
})
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return [virtualModules, shell]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function serveAsset(config, root, url, res, next) {
|
|
293
|
+
const assets = config?.assets ?? {}
|
|
294
|
+
const rest = url.slice('/@workshop-assets/'.length)
|
|
295
|
+
const name = rest.split('/')[0]
|
|
296
|
+
const dir = assets[name] ? resolve(root, assets[name]) : null
|
|
297
|
+
if (!dir) {
|
|
298
|
+
next()
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const file = resolve(dir, rest.slice(name.length + 1))
|
|
303
|
+
if (
|
|
304
|
+
!(file === dir || file.startsWith(dir + sep)) ||
|
|
305
|
+
!existsSync(file) ||
|
|
306
|
+
!statSync(file).isFile()
|
|
307
|
+
) {
|
|
308
|
+
res.statusCode = 404
|
|
309
|
+
res.end('not found')
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
res.statusCode = 200
|
|
314
|
+
res.end(readFileSync(file))
|
|
315
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workshop web entry — boots the shell, wires the session to HMR events, and
|
|
3
|
+
* receives refreshed catalogs when the virtual catalog module re-executes.
|
|
4
|
+
*/
|
|
5
|
+
import { createElement, createRoot } from 'octane'
|
|
6
|
+
import { createStore } from '@octane-xplat/ui/data'
|
|
7
|
+
import type { CatalogEntry } from '../catalog'
|
|
8
|
+
import type { StoryDefinition, WorkshopTarget } from '../contract'
|
|
9
|
+
import type { SessionSnapshot } from '../session'
|
|
10
|
+
import { WorkshopSession } from '../session'
|
|
11
|
+
import { createWebStageHost } from './mount.web'
|
|
12
|
+
import { WorkshopApp } from './shell.web.tsrx'
|
|
13
|
+
|
|
14
|
+
export interface ShellState {
|
|
15
|
+
mode: 'wide' | 'compact' | 'phone'
|
|
16
|
+
query: string
|
|
17
|
+
selectedId: string | null
|
|
18
|
+
expanded: ReadonlySet<string>
|
|
19
|
+
catalogCollapsed: boolean
|
|
20
|
+
controlsCollapsed: boolean
|
|
21
|
+
auxPanel: 'stories' | 'controls' | null
|
|
22
|
+
stageOnly: boolean
|
|
23
|
+
stageBackground: 'default' | 'light' | 'dark'
|
|
24
|
+
stageBounds: 'available' | 'small' | 'medium'
|
|
25
|
+
catalog: CatalogEntry[]
|
|
26
|
+
catalogError: string | undefined
|
|
27
|
+
missingId: string | null
|
|
28
|
+
session: SessionSnapshot
|
|
29
|
+
drafts: Record<string, string>
|
|
30
|
+
fieldErrors: Record<string, string>
|
|
31
|
+
unsetKeys: ReadonlySet<string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkshopRuntime {
|
|
35
|
+
store: ReturnType<typeof createStore<ShellState>>
|
|
36
|
+
session: WorkshopSession
|
|
37
|
+
mountHost: HTMLElement
|
|
38
|
+
selectEntry(entry: CatalogEntry): Promise<void>
|
|
39
|
+
updateCatalog(entries: CatalogEntry[], diagnostics?: readonly { message: string }[]): void
|
|
40
|
+
loadGeneration: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let runtime: WorkshopRuntime | null = null
|
|
44
|
+
|
|
45
|
+
/** Called by the generated catalog module on load and on every HMR re-execution. */
|
|
46
|
+
export function catalogDidUpdate(
|
|
47
|
+
entries: CatalogEntry[],
|
|
48
|
+
diagnostics: readonly { message: string }[] = [],
|
|
49
|
+
): void {
|
|
50
|
+
runtime?.updateCatalog(entries, diagnostics)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WorkshopBootOptions {
|
|
54
|
+
catalog: CatalogEntry[]
|
|
55
|
+
diagnostics?: readonly { message: string }[]
|
|
56
|
+
target: WorkshopTarget
|
|
57
|
+
initialStoryId?: string | null
|
|
58
|
+
globalProviders?: readonly unknown[]
|
|
59
|
+
/** Vite HMR handle — passed by the generated entry because lib builds
|
|
60
|
+
* fold `import.meta.hot` to undefined. */
|
|
61
|
+
hot?: { on?: (event: string, cb: (payload?: unknown) => void) => void }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function startWorkshop(options: WorkshopBootOptions): WorkshopRuntime {
|
|
65
|
+
const mountHost = document.createElement('div')
|
|
66
|
+
mountHost.className = 'xw-stage-host'
|
|
67
|
+
|
|
68
|
+
const session = new WorkshopSession({
|
|
69
|
+
host: createWebStageHost(mountHost),
|
|
70
|
+
target: options.target,
|
|
71
|
+
globalProviders: options.globalProviders ?? [],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const store = createStore<ShellState>({
|
|
75
|
+
mode: 'wide',
|
|
76
|
+
query: '',
|
|
77
|
+
selectedId: null,
|
|
78
|
+
expanded: new Set(groupKeys(options.catalog)),
|
|
79
|
+
catalogCollapsed: false,
|
|
80
|
+
controlsCollapsed: false,
|
|
81
|
+
auxPanel: null,
|
|
82
|
+
stageOnly: false,
|
|
83
|
+
stageBackground: 'default',
|
|
84
|
+
stageBounds: 'available',
|
|
85
|
+
catalog: options.catalog,
|
|
86
|
+
catalogError: options.diagnostics?.length
|
|
87
|
+
? options.diagnostics.map((d) => d.message).join('\n')
|
|
88
|
+
: undefined,
|
|
89
|
+
missingId: null,
|
|
90
|
+
session: session.state,
|
|
91
|
+
drafts: {},
|
|
92
|
+
fieldErrors: {},
|
|
93
|
+
unsetKeys: new Set(),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
session.subscribe((snapshot) => {
|
|
97
|
+
store.update((s) => ({ ...s, session: snapshot }))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
runtime = {
|
|
101
|
+
store,
|
|
102
|
+
session,
|
|
103
|
+
mountHost,
|
|
104
|
+
loadGeneration: 0,
|
|
105
|
+
async selectEntry(entry) {
|
|
106
|
+
if (!entry.load) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const generation = ++runtime!.loadGeneration
|
|
111
|
+
try {
|
|
112
|
+
const loaded = await entry.load()
|
|
113
|
+
if (generation !== runtime!.loadGeneration) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const definition = resolveDefinition(loaded, entry)
|
|
118
|
+
await session.select(definition)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (generation === runtime!.loadGeneration) {
|
|
121
|
+
session.markCompileError(error)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
updateCatalog(entries, diagnostics = []) {
|
|
126
|
+
const generation = ++runtime!.loadGeneration
|
|
127
|
+
const stamp = String(Date.now())
|
|
128
|
+
store.update((s) => ({
|
|
129
|
+
...s,
|
|
130
|
+
catalog: entries,
|
|
131
|
+
catalogError: diagnostics.length ? diagnostics.map((d) => d.message).join('\n') : undefined,
|
|
132
|
+
expanded: new Set([...s.expanded, ...groupKeys(entries)]),
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
if (diagnostics.length) {
|
|
136
|
+
session.markCompileError(diagnostics.map((d) => d.message).join('\n'))
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const current = store.getSnapshot().selectedId
|
|
141
|
+
if (!current) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const entry = pickEntry(entries, current)
|
|
146
|
+
if (!entry?.load) {
|
|
147
|
+
store.update((s) => ({ ...s, missingId: current }))
|
|
148
|
+
void session.dispose()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
void (async () => {
|
|
153
|
+
try {
|
|
154
|
+
const loaded = await entry.load!(stamp)
|
|
155
|
+
if (generation !== runtime!.loadGeneration) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await session.reload(resolveDefinition(loaded, entry))
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (generation === runtime!.loadGeneration) {
|
|
162
|
+
session.markCompileError(error)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
})()
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Automation hook — tests and the dev bridge read the live session/store.
|
|
170
|
+
;(globalThis as Record<string, unknown>).__xplatWorkshopRuntime = runtime
|
|
171
|
+
|
|
172
|
+
// Dev-server lifecycle: rebuilds mark the stage rebuilding; a failed
|
|
173
|
+
// transform keeps the last good stage and marks it stale.
|
|
174
|
+
let updateLoadGeneration = 0
|
|
175
|
+
options.hot?.on?.('vite:beforeUpdate', () => {
|
|
176
|
+
updateLoadGeneration = runtime!.loadGeneration
|
|
177
|
+
session.markRebuilding()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
options.hot?.on?.('vite:afterUpdate', () => {
|
|
181
|
+
// Octane component modules self-accept HMR, which can stop propagation
|
|
182
|
+
// before the catalog. Retire/remount once the successful update has
|
|
183
|
+
// applied; a catalog update already owns its own fresh generation.
|
|
184
|
+
if (runtime!.loadGeneration === updateLoadGeneration && session.state.stale) {
|
|
185
|
+
runtime!.updateCatalog(store.getSnapshot().catalog)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
options.hot?.on?.('vite:error', (payload) => {
|
|
190
|
+
const err = payload as { err?: { message?: string } } | undefined
|
|
191
|
+
session.markCompileError(err?.err?.message ?? 'source update failed')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
options.hot?.on?.('vite:ws:disconnect', () => session.markDisconnected())
|
|
195
|
+
options.hot?.on?.('vite:ws:connect', () => session.markConnected())
|
|
196
|
+
|
|
197
|
+
const hostElement = document.getElementById('workshop-root') ?? document.body
|
|
198
|
+
createRoot(hostElement).render(createElement(WorkshopApp as never, { runtime } as never))
|
|
199
|
+
|
|
200
|
+
// Hash selection: #/story/<id> survives reloads and deep-links a story.
|
|
201
|
+
const idFromHash = () => {
|
|
202
|
+
const raw = location.hash.replace(/^#\/?story\//, '')
|
|
203
|
+
try {
|
|
204
|
+
return decodeURIComponent(raw) || null
|
|
205
|
+
} catch {
|
|
206
|
+
return raw || null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const pickEntry = (entries: CatalogEntry[], id: string) =>
|
|
211
|
+
entries.find((e) => e.id === id && e.load) ?? entries.find((e) => e.id === id)
|
|
212
|
+
|
|
213
|
+
const initialId = options.initialStoryId ?? idFromHash()
|
|
214
|
+
if (initialId) {
|
|
215
|
+
const entry = pickEntry(options.catalog, initialId)
|
|
216
|
+
if (entry?.load) {
|
|
217
|
+
store.update((s) => ({ ...s, selectedId: initialId }))
|
|
218
|
+
void runtime.selectEntry(entry)
|
|
219
|
+
} else {
|
|
220
|
+
store.update((s) => ({ ...s, selectedId: initialId, missingId: initialId }))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
globalThis.addEventListener?.('hashchange', () => {
|
|
225
|
+
const id = idFromHash()
|
|
226
|
+
if (id && id !== store.getSnapshot().selectedId) {
|
|
227
|
+
const entry = pickEntry(store.getSnapshot().catalog, id)
|
|
228
|
+
if (entry?.load) {
|
|
229
|
+
store.update((s) => ({ ...s, selectedId: id, missingId: null }))
|
|
230
|
+
void runtime!.selectEntry(entry)
|
|
231
|
+
} else {
|
|
232
|
+
++runtime!.loadGeneration
|
|
233
|
+
store.update((s) => ({ ...s, selectedId: id, missingId: id }))
|
|
234
|
+
void session.dispose()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return runtime
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Group keys (`name@depth`) for every title segment group in the catalog. */
|
|
243
|
+
function groupKeys(entries: CatalogEntry[]): string[] {
|
|
244
|
+
const keys = new Set<string>()
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
const segments = entry.title
|
|
247
|
+
.split('/')
|
|
248
|
+
.map((seg) => seg.trim())
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
|
|
251
|
+
for (let depth = 0; depth < segments.length - 1; depth++) {
|
|
252
|
+
keys.add(`${segments[depth]}@${depth}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return [...keys]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function resolveDefinition(loaded: unknown, entry: CatalogEntry): StoryDefinition<any, any, any> {
|
|
260
|
+
const mod = loaded as Record<string, unknown>
|
|
261
|
+
const candidate: unknown = mod?.[entry.exportName]
|
|
262
|
+
const definition = candidate as StoryDefinition<any, any, any> | undefined
|
|
263
|
+
if (!definition || typeof definition !== 'object' || definition.id !== entry.id) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`story module ${entry.file} did not provide a story definition at export "${entry.exportName}"`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return definition
|
|
270
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web runtime entry — the catalog virtual module and the generated workshop
|
|
3
|
+
* entry import this. Exposes startWorkshop plus the catalog-update seam the
|
|
4
|
+
* virtual catalog calls back into after HMR re-execution.
|
|
5
|
+
*/
|
|
6
|
+
export { createWebStageHost } from './mount.web'
|
|
7
|
+
export { startWorkshop, catalogDidUpdate } from './entry.web'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web stage host — mounts a prepared story renderable inside the shell's
|
|
3
|
+
* stage element through the real Octane DOM renderer. Each generation owns
|
|
4
|
+
* its root; `unmount` disposes the subtree including owned portals.
|
|
5
|
+
*/
|
|
6
|
+
import { ErrorBoundary, createElement, createRoot } from 'octane'
|
|
7
|
+
import { composeStory } from 'storybook/preview-api'
|
|
8
|
+
import type { MountedStory, MountRequest, StageHost } from '../session'
|
|
9
|
+
|
|
10
|
+
function storyTree(request: MountRequest): any {
|
|
11
|
+
let inner: any = createElement(request.component as any, request.props)
|
|
12
|
+
for (const provider of [...request.providers].reverse()) {
|
|
13
|
+
inner = createElement(provider as any, { fixture: request.fixture } as any, inner)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A story render error swaps to a marker node and reports to the session;
|
|
17
|
+
// the shell keeps navigation/reset reachable around the failed subtree.
|
|
18
|
+
return createElement(
|
|
19
|
+
ErrorBoundary as any,
|
|
20
|
+
{
|
|
21
|
+
fallback: (error: unknown) => {
|
|
22
|
+
// The fallback runs inside the render pass — octane rejects store
|
|
23
|
+
// updates there, so report after the pass commits.
|
|
24
|
+
queueMicrotask(() => request.onRenderError(error))
|
|
25
|
+
return createElement('div', {
|
|
26
|
+
className: 'xw-stage-error-marker',
|
|
27
|
+
role: 'alert',
|
|
28
|
+
})
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
inner,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createWebStageHost(stageElement: HTMLElement): StageHost {
|
|
36
|
+
return {
|
|
37
|
+
mount(request: MountRequest): MountedStory {
|
|
38
|
+
// Pin the visual harness's portable composition subset to Storybook
|
|
39
|
+
// 10.6.1. Octane still mounts the compiled component; the session
|
|
40
|
+
// owns fixtures, providers, cancellation and every cleanup.
|
|
41
|
+
const composed = composeStory(
|
|
42
|
+
{ args: request.props },
|
|
43
|
+
{ title: request.story.title, component: request.component } as any,
|
|
44
|
+
{ render: (props: any) => storyTree({ ...request, props }) } as any,
|
|
45
|
+
{},
|
|
46
|
+
'Story',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const mountPoint = globalThis.document.createElement('div')
|
|
50
|
+
mountPoint.className = 'xw-stage-mount'
|
|
51
|
+
stageElement.append(mountPoint)
|
|
52
|
+
const root = createRoot(mountPoint)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
root.render(composed(request.props) as any)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
root.unmount()
|
|
58
|
+
mountPoint.remove()
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let disposed = false
|
|
63
|
+
return {
|
|
64
|
+
update(props: Record<string, unknown>) {
|
|
65
|
+
if (disposed) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
root.render(composed(props) as any)
|
|
70
|
+
},
|
|
71
|
+
unmount() {
|
|
72
|
+
if (disposed) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
disposed = true
|
|
77
|
+
try {
|
|
78
|
+
root.unmount()
|
|
79
|
+
} finally {
|
|
80
|
+
mountPoint.remove()
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|