@octane-xplat/workshop 0.1.0 → 0.15.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/LICENSE +21 -0
- package/MOBILE.md +143 -0
- package/README.md +305 -1
- package/dist/node/index.js +50 -0
- package/package.json +118 -5
- 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/discover.mjs
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-side story discovery — static, metadata-only.
|
|
3
|
+
*
|
|
4
|
+
* Story modules are never evaluated: this scanner reads `defineStory` option
|
|
5
|
+
* literals out of the source text. `id`, `title`, and `targets` must be
|
|
6
|
+
* literal values; anything computed fails with an actionable diagnostic.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { globSync } from 'tinyglobby'
|
|
10
|
+
|
|
11
|
+
export const ALL_TARGETS = ['web', 'ios', 'android', 'macos', 'windows', 'linux']
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INCLUDE = [
|
|
14
|
+
'**/*.stories.{ts,tsx,tsrx,mts,mjs,js,jsx}',
|
|
15
|
+
'**/*.stories.{web,mobile,ios,android,macos,windows,linux}.{ts,tsx,tsrx,mts,mjs,js,jsx}',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const DEFAULT_EXCLUDE = ['**/node_modules/**', '**/dist/**', '**/types/**']
|
|
19
|
+
|
|
20
|
+
const SUFFIX_TARGETS = {
|
|
21
|
+
web: ['web'],
|
|
22
|
+
mobile: ['ios', 'android'],
|
|
23
|
+
ios: ['ios'],
|
|
24
|
+
android: ['android'],
|
|
25
|
+
macos: ['macos'],
|
|
26
|
+
windows: ['windows'],
|
|
27
|
+
linux: ['linux'],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SUFFIX_RE = /\.stories\.(web|mobile|ios|android|macos|windows|linux)\.[cm]?[jt]srx?$/
|
|
31
|
+
|
|
32
|
+
export function storyFileTag(file) {
|
|
33
|
+
return SUFFIX_RE.exec(file)?.[1] ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function applicableTargets(declared, file) {
|
|
37
|
+
const tag = storyFileTag(file)
|
|
38
|
+
if (!tag) {
|
|
39
|
+
return declared
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const implied = SUFFIX_TARGETS[tag] ?? []
|
|
43
|
+
return declared.filter((t) => implied.includes(t))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeInclude(include) {
|
|
47
|
+
if (include === undefined) {
|
|
48
|
+
return DEFAULT_INCLUDE
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Array.isArray(include) ? [...include] : [include]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeExclude(exclude) {
|
|
55
|
+
const extra = exclude === undefined ? [] : Array.isArray(exclude) ? [...exclude] : [exclude]
|
|
56
|
+
return [...DEFAULT_EXCLUDE, ...extra]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- literal source scanning ----------
|
|
60
|
+
|
|
61
|
+
/** Strip strings, template literals, and comments while preserving length. */
|
|
62
|
+
export function maskSource(source) {
|
|
63
|
+
const out = source.split('')
|
|
64
|
+
let i = 0
|
|
65
|
+
const n = source.length
|
|
66
|
+
const blank = (from, to) => {
|
|
67
|
+
for (let k = from; k < to; k++) {
|
|
68
|
+
out[k] = out[k] === '\n' ? '\n' : ' '
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
while (i < n) {
|
|
73
|
+
const ch = source[i]
|
|
74
|
+
if (ch === '/' && source[i + 1] === '/') {
|
|
75
|
+
let j = i
|
|
76
|
+
while (j < n && source[j] !== '\n') {
|
|
77
|
+
j++
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
blank(i, j)
|
|
81
|
+
i = j
|
|
82
|
+
} else if (ch === '/' && source[i + 1] === '*') {
|
|
83
|
+
let j = source.indexOf('*/', i + 2)
|
|
84
|
+
j = j === -1 ? n : j + 2
|
|
85
|
+
blank(i, j)
|
|
86
|
+
i = j
|
|
87
|
+
} else if (ch === "'" || ch === '"') {
|
|
88
|
+
let j = i + 1
|
|
89
|
+
while (j < n) {
|
|
90
|
+
if (source[j] === '\\') {
|
|
91
|
+
j += 2
|
|
92
|
+
} else if (source[j] === ch) {
|
|
93
|
+
j++
|
|
94
|
+
break
|
|
95
|
+
} else {
|
|
96
|
+
j++
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
j = Math.min(j, n)
|
|
101
|
+
// Keep the delimiters so the slice still reads as a string shape;
|
|
102
|
+
// blank the interior only.
|
|
103
|
+
blank(i + 1, Math.max(i + 1, j - 1))
|
|
104
|
+
i = j
|
|
105
|
+
} else if (ch === '`') {
|
|
106
|
+
// Keep ${ } interpolation visible (expressions are not literals);
|
|
107
|
+
// blank only the literal text spans.
|
|
108
|
+
let j = i + 1
|
|
109
|
+
while (j < n) {
|
|
110
|
+
if (source[j] === '\\') {
|
|
111
|
+
blank(j, j + 2)
|
|
112
|
+
j += 2
|
|
113
|
+
} else if (source[j] === '`') {
|
|
114
|
+
j++
|
|
115
|
+
break
|
|
116
|
+
} else if (source[j] === '$' && source[j + 1] === '{') {
|
|
117
|
+
let depth = 1
|
|
118
|
+
let k = j + 2
|
|
119
|
+
while (k < n && depth > 0) {
|
|
120
|
+
if (source[k] === '{') {
|
|
121
|
+
depth++
|
|
122
|
+
} else if (source[k] === '}') {
|
|
123
|
+
depth--
|
|
124
|
+
} else if (source[k] === "'" || source[k] === '"') {
|
|
125
|
+
let q = k + 1
|
|
126
|
+
while (q < n && source[q] !== source[k]) {
|
|
127
|
+
q += source[q] === '\\' ? 2 : 1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
k = Math.min(q + 1, n)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
k++
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
j = k
|
|
138
|
+
} else {
|
|
139
|
+
blank(j, j + 1)
|
|
140
|
+
j++
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
i = j
|
|
145
|
+
} else {
|
|
146
|
+
i++
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return out.join('')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Find matching close for an opening bracket at index `open` in masked text. */
|
|
154
|
+
function matchBracket(masked, open, openCh, closeCh) {
|
|
155
|
+
let depth = 0
|
|
156
|
+
for (let i = open; i < masked.length; i++) {
|
|
157
|
+
const ch = masked[i]
|
|
158
|
+
if (ch === openCh) {
|
|
159
|
+
depth++
|
|
160
|
+
} else if (ch === closeCh) {
|
|
161
|
+
depth--
|
|
162
|
+
if (depth === 0) {
|
|
163
|
+
return i
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return -1
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Split a masked object-literal body into top-level key → raw value text. */
|
|
172
|
+
function objectFields(masked, source, openBrace, closeBrace) {
|
|
173
|
+
const fields = new Map()
|
|
174
|
+
let i = openBrace + 1
|
|
175
|
+
while (i < closeBrace) {
|
|
176
|
+
while (i < closeBrace && /[\s,]/.test(masked[i])) {
|
|
177
|
+
i++
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (i >= closeBrace) {
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const m = /^([A-Za-z_$][\w$]*|'[\s\S]*'|"[\s\S]*"|\d+)\s*:/.exec(masked.slice(i, closeBrace))
|
|
185
|
+
if (!m) {
|
|
186
|
+
return { fields, error: `unsupported object syntax near "${masked.slice(i, i + 24).trim()}"` }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rawKey = m[1]
|
|
190
|
+
const key =
|
|
191
|
+
rawKey.startsWith("'") || rawKey.startsWith('"')
|
|
192
|
+
? source
|
|
193
|
+
.slice(i, i + m[0].indexOf(':'))
|
|
194
|
+
.trim()
|
|
195
|
+
.slice(1, -1)
|
|
196
|
+
: rawKey
|
|
197
|
+
|
|
198
|
+
let v = i + m[0].length
|
|
199
|
+
const start = v
|
|
200
|
+
let depth = 0
|
|
201
|
+
while (v < closeBrace) {
|
|
202
|
+
const ch = masked[v]
|
|
203
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
204
|
+
depth++
|
|
205
|
+
} else if (ch === '}' || ch === ']' || ch === ')') {
|
|
206
|
+
if (depth === 0) {
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
depth--
|
|
211
|
+
} else if (ch === ',' && depth === 0) {
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
v++
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fields.set(key, { start, end: v })
|
|
219
|
+
i = v + 1
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { fields }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Evaluate a literal-only expression slice; throws on non-literal content. */
|
|
226
|
+
function literalValue(masked, source, start, end, label, file) {
|
|
227
|
+
const text = masked.slice(start, end).trim()
|
|
228
|
+
const fail = () => {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`${file}: story metadata "${label}" must be a literal value — got "${source.slice(start, end).trim().slice(0, 60)}"`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (text === '') {
|
|
235
|
+
fail()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (text === 'true') {
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (text === 'false') {
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (text === 'null') {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
|
251
|
+
return Number(text)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (/^'[^']*'$/.test(text) || /^"[^"]*"$/.test(text)) {
|
|
255
|
+
const value = source.slice(start, end).trim().slice(1, -1)
|
|
256
|
+
if (value.includes('\\')) {
|
|
257
|
+
fail()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return value
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Template literal without interpolation → masked to spaces; reject.
|
|
264
|
+
if (text.startsWith('`') || text === '') {
|
|
265
|
+
fail()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (text.startsWith('[')) {
|
|
269
|
+
const openIdx = masked.indexOf('[', start)
|
|
270
|
+
const close = matchBracket(masked, openIdx, '[', ']')
|
|
271
|
+
if (close < 0 || masked.slice(close + 1, end).trim()) {
|
|
272
|
+
fail()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const items = []
|
|
276
|
+
let seg = openIdx + 1
|
|
277
|
+
let depth = 0
|
|
278
|
+
const push = (end2) => {
|
|
279
|
+
const t = masked.slice(seg, end2).trim()
|
|
280
|
+
if (t) {
|
|
281
|
+
items.push(literalValue(masked, source, seg, end2, label, file))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (let i = openIdx + 1; i < close; i++) {
|
|
286
|
+
const ch = masked[i]
|
|
287
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
288
|
+
depth++
|
|
289
|
+
} else if (ch === '}' || ch === ']' || ch === ')') {
|
|
290
|
+
depth--
|
|
291
|
+
} else if (ch === ',' && depth === 0) {
|
|
292
|
+
push(i)
|
|
293
|
+
seg = i + 1
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
push(close)
|
|
298
|
+
return items
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fail()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const DECL_RE = /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*$/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Extract story metadata declarations from source text. Returns
|
|
308
|
+
* { stories: [...], diagnostics: [...] } — never throws on malformed input.
|
|
309
|
+
*/
|
|
310
|
+
export function scanStoryFile(source, file) {
|
|
311
|
+
const masked = maskSource(source)
|
|
312
|
+
const stories = []
|
|
313
|
+
const diagnostics = []
|
|
314
|
+
const exported = new Set()
|
|
315
|
+
for (const m of masked.matchAll(/export\s*\{([^}]*)\}/g)) {
|
|
316
|
+
for (const part of m[1].split(',')) {
|
|
317
|
+
const name = part
|
|
318
|
+
.trim()
|
|
319
|
+
.split(/\s+as\s+/)
|
|
320
|
+
.pop()
|
|
321
|
+
?.trim()
|
|
322
|
+
|
|
323
|
+
if (name) {
|
|
324
|
+
exported.add(name)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let search = 0
|
|
330
|
+
for (;;) {
|
|
331
|
+
const at = masked.indexOf('defineStory', search)
|
|
332
|
+
if (at === -1) {
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
search = at + 'defineStory'.length
|
|
337
|
+
const before = masked.slice(0, at)
|
|
338
|
+
const after = masked.slice(at + 'defineStory'.length)
|
|
339
|
+
// Word-boundary check on both sides.
|
|
340
|
+
if (/[\w$.]$/.test(before) || /^[\w$]/.test(after)) {
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Binding detection: the statement immediately preceding defineStory
|
|
345
|
+
// must be `<export>? const|let|var X =` or `export default`; the binding
|
|
346
|
+
// may also be re-exported later via `export { X }`.
|
|
347
|
+
let exportName = 'default'
|
|
348
|
+
const stmtStart = Math.max(
|
|
349
|
+
before.lastIndexOf(';'),
|
|
350
|
+
before.lastIndexOf('}'),
|
|
351
|
+
before.lastIndexOf('{'),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const stmt = before.slice(stmtStart + 1)
|
|
355
|
+
const decl = DECL_RE.exec(stmt)
|
|
356
|
+
let exportedStory = false
|
|
357
|
+
if (decl && decl.index + decl[0].length === stmt.length) {
|
|
358
|
+
exportName = decl[1]
|
|
359
|
+
exportedStory = /export\s+(const|let|var)\s/.test(stmt) || exported.has(exportName)
|
|
360
|
+
} else if (/export\s+default\s*$/.test(stmt)) {
|
|
361
|
+
exportedStory = true
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let i = at + 'defineStory'.length
|
|
365
|
+
while (i < masked.length && /\s/.test(masked[i])) {
|
|
366
|
+
i++
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Generic instantiation: defineStory<Props, State, Fixture>({ … })
|
|
370
|
+
if (masked[i] === '<') {
|
|
371
|
+
const closeAngle = matchBracket(masked, i, '<', '>')
|
|
372
|
+
if (closeAngle !== -1) {
|
|
373
|
+
i = closeAngle + 1
|
|
374
|
+
while (i < masked.length && /\s/.test(masked[i])) {
|
|
375
|
+
i++
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (masked[i] !== '(') {
|
|
381
|
+
// Not a call — an import specifier, re-export or identifier mention.
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const closeParen = matchBracket(masked, i, '(', ')')
|
|
386
|
+
if (closeParen === -1) {
|
|
387
|
+
diagnostics.push({ file, exportName, message: 'defineStory call has unbalanced parentheses' })
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let j = i + 1
|
|
392
|
+
while (j < closeParen && /\s/.test(masked[j])) {
|
|
393
|
+
j++
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (masked[j] !== '{') {
|
|
397
|
+
diagnostics.push({
|
|
398
|
+
file,
|
|
399
|
+
exportName,
|
|
400
|
+
message: 'defineStory options must be an object literal',
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const closeBrace = matchBracket(masked, j, '{', '}')
|
|
407
|
+
const { fields, error } = objectFields(masked, source, j, closeBrace)
|
|
408
|
+
if (error) {
|
|
409
|
+
diagnostics.push({ file, exportName, message: error })
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const story = { file, exportName, exported: exportedStory }
|
|
414
|
+
let failed = false
|
|
415
|
+
for (const key of ['id', 'title', 'targets', 'capabilities']) {
|
|
416
|
+
const field = fields.get(key)
|
|
417
|
+
if (!field) {
|
|
418
|
+
if (key === 'capabilities') {
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
diagnostics.push({ file, exportName, message: `defineStory requires a literal "${key}"` })
|
|
423
|
+
failed = true
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
story[key] = literalValue(masked, source, field.start, field.end, key, file)
|
|
429
|
+
} catch (e) {
|
|
430
|
+
diagnostics.push({ file, exportName, message: e.message })
|
|
431
|
+
failed = true
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (
|
|
436
|
+
!failed &&
|
|
437
|
+
(typeof story.id !== 'string' ||
|
|
438
|
+
!story.id ||
|
|
439
|
+
typeof story.title !== 'string' ||
|
|
440
|
+
!story.title ||
|
|
441
|
+
!(
|
|
442
|
+
story.targets === 'all' ||
|
|
443
|
+
(Array.isArray(story.targets) &&
|
|
444
|
+
story.targets.length > 0 &&
|
|
445
|
+
story.targets.every((t) => ALL_TARGETS.includes(t)))
|
|
446
|
+
) ||
|
|
447
|
+
!(
|
|
448
|
+
story.capabilities === undefined ||
|
|
449
|
+
(Array.isArray(story.capabilities) &&
|
|
450
|
+
story.capabilities.every((c) => typeof c === 'string' && c.length > 0))
|
|
451
|
+
))
|
|
452
|
+
) {
|
|
453
|
+
diagnostics.push({
|
|
454
|
+
file,
|
|
455
|
+
exportName,
|
|
456
|
+
message:
|
|
457
|
+
'story requires non-empty literal id/title, targets: all or a non-empty target array, and a string capability array',
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
failed = true
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!failed) {
|
|
464
|
+
stories.push(story)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { stories, diagnostics }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Discover story metadata under `root`.
|
|
473
|
+
* → { entries, diagnostics }
|
|
474
|
+
* entries: StoryMeta[] (applicable/reason computed for `target`)
|
|
475
|
+
*/
|
|
476
|
+
export function discoverStories(root, { include, exclude, target = 'web' } = {}) {
|
|
477
|
+
const files = globSync(normalizeInclude(include), {
|
|
478
|
+
cwd: root,
|
|
479
|
+
ignore: normalizeExclude(exclude),
|
|
480
|
+
absolute: true,
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
const entries = []
|
|
484
|
+
const diagnostics = []
|
|
485
|
+
const byId = new Map()
|
|
486
|
+
|
|
487
|
+
for (const file of files) {
|
|
488
|
+
let source
|
|
489
|
+
try {
|
|
490
|
+
source = readFileSync(file, 'utf8')
|
|
491
|
+
} catch (e) {
|
|
492
|
+
diagnostics.push({ file, message: `could not read story file: ${e.message}` })
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const { stories, diagnostics: fileDiagnostics } = scanStoryFile(source, file)
|
|
497
|
+
diagnostics.push(...fileDiagnostics)
|
|
498
|
+
for (const story of stories) {
|
|
499
|
+
if (!story.exported) {
|
|
500
|
+
diagnostics.push({
|
|
501
|
+
file,
|
|
502
|
+
exportName: story.exportName,
|
|
503
|
+
message: `defineStory("${story.id}") must be exported to be discovered`,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const declared =
|
|
510
|
+
story.targets === 'all'
|
|
511
|
+
? ALL_TARGETS
|
|
512
|
+
: Array.isArray(story.targets)
|
|
513
|
+
? story.targets
|
|
514
|
+
: [story.targets]
|
|
515
|
+
|
|
516
|
+
for (const t of declared) {
|
|
517
|
+
if (!ALL_TARGETS.includes(t)) {
|
|
518
|
+
diagnostics.push({
|
|
519
|
+
file,
|
|
520
|
+
exportName: story.exportName,
|
|
521
|
+
message: `story "${story.id}" declares unknown target "${t}"`,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const applicable = applicableTargets(
|
|
527
|
+
declared.filter((t) => ALL_TARGETS.includes(t)),
|
|
528
|
+
file,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
const required = story.capabilities ?? []
|
|
532
|
+
const canRun = applicable.includes(target) && required.length === 0
|
|
533
|
+
const reason = canRun
|
|
534
|
+
? undefined
|
|
535
|
+
: applicable.includes(target) && required.length > 0
|
|
536
|
+
? `required capabilities are unknown/unqualified on ${target}: ${required.join(', ')}`
|
|
537
|
+
: applicable.length === 0
|
|
538
|
+
? `declared targets and the ${storyFileTag(file) ?? 'story'} leaf do not overlap`
|
|
539
|
+
: `story targets ${applicable.join(', ')}, not ${target}`
|
|
540
|
+
|
|
541
|
+
const meta = {
|
|
542
|
+
id: story.id,
|
|
543
|
+
title: story.title,
|
|
544
|
+
targets: applicable,
|
|
545
|
+
capabilities: story.capabilities ?? [],
|
|
546
|
+
file,
|
|
547
|
+
exportName: story.exportName,
|
|
548
|
+
applicable: canRun,
|
|
549
|
+
reason,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// One story id may have platform leaves per target; two declarations
|
|
553
|
+
// claiming the same id for the same target is a real conflict.
|
|
554
|
+
const prior = byId.get(story.id) ?? []
|
|
555
|
+
const conflict = prior.find((prev) => prev.targets.some((t) => meta.targets.includes(t)))
|
|
556
|
+
if (conflict) {
|
|
557
|
+
diagnostics.push({
|
|
558
|
+
file,
|
|
559
|
+
exportName: story.exportName,
|
|
560
|
+
message: `duplicate story id "${story.id}" — also declared in ${conflict.file} (export "${conflict.exportName}")`,
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
continue
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
byId.set(story.id, [...prior, meta])
|
|
567
|
+
entries.push(meta)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
entries.sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id))
|
|
572
|
+
|
|
573
|
+
// One catalog row per story id — platform leaves are variants of one
|
|
574
|
+
// story, so the row carries the leaf applicable to this target.
|
|
575
|
+
const byRow = new Map()
|
|
576
|
+
for (const entry of entries) {
|
|
577
|
+
const list = byRow.get(entry.id) ?? []
|
|
578
|
+
list.push(entry)
|
|
579
|
+
byRow.set(entry.id, list)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const merged = [...byRow.values()].map((group) => group.find((e) => e.applicable) ?? group[0])
|
|
583
|
+
return { entries: merged, diagnostics }
|
|
584
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Failure phases surfaced by the workshop shell and CLI. */
|
|
2
|
+
export type WorkshopErrorPhase =
|
|
3
|
+
| 'discovery'
|
|
4
|
+
| 'compile'
|
|
5
|
+
| 'fixture'
|
|
6
|
+
| 'mount'
|
|
7
|
+
| 'interaction'
|
|
8
|
+
| 'cleanup'
|
|
9
|
+
| 'launch'
|
|
10
|
+
| 'connection'
|
|
11
|
+
|
|
12
|
+
export class WorkshopError extends Error {
|
|
13
|
+
readonly phase: WorkshopErrorPhase
|
|
14
|
+
readonly storyId?: string
|
|
15
|
+
readonly sourceLocation?: string
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
phase: WorkshopErrorPhase,
|
|
19
|
+
message: string,
|
|
20
|
+
options?: { storyId?: string; cause?: unknown; sourceLocation?: string },
|
|
21
|
+
) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'WorkshopError'
|
|
24
|
+
this.phase = phase
|
|
25
|
+
this.storyId = options?.storyId
|
|
26
|
+
this.sourceLocation = options?.sourceLocation
|
|
27
|
+
if (options?.cause !== undefined) {
|
|
28
|
+
;(this as { cause?: unknown }).cause = options.cause
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function describeCause(cause: unknown): string {
|
|
34
|
+
if (cause instanceof Error) {
|
|
35
|
+
// JSC stacks often start directly at a `fn@file:line` frame without the
|
|
36
|
+
// `Name: message` head — keep the message visible either way.
|
|
37
|
+
const head = `${cause.name}: ${cause.message}`
|
|
38
|
+
const tail = cause.stack?.split('\n').slice(0, 3).join('\n')
|
|
39
|
+
if (!tail) {
|
|
40
|
+
return head
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return tail.startsWith(head) ? tail : `${head}\n${tail}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return String(cause)
|
|
47
|
+
}
|