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