@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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +305 -1
  4. package/dist/node/index.js +50 -0
  5. package/package.json +118 -5
  6. package/src/catalog.ts +58 -0
  7. package/src/contract.ts +257 -0
  8. package/src/controls.ts +159 -0
  9. package/src/discover.mjs +584 -0
  10. package/src/errors.ts +47 -0
  11. package/src/fixture.ts +158 -0
  12. package/src/index.ts +6 -0
  13. package/src/macos/controller.ts +170 -0
  14. package/src/macos/index.macos.ts +2 -0
  15. package/src/macos/shell.macos.mjs +673 -0
  16. package/src/macos/stage.macos.ts +182 -0
  17. package/src/macos-node.mjs +95 -0
  18. package/src/macos-vite.mjs +47 -0
  19. package/src/mobile/controller.ts +134 -0
  20. package/src/mobile/entry.mobile.ts +607 -0
  21. package/src/mobile/index.mobile.ts +3 -0
  22. package/src/mobile/mount.mobile.ts +60 -0
  23. package/src/mobile/tree.mobile.tsrx +17 -0
  24. package/src/mobile-node.mjs +126 -0
  25. package/src/mobile-regex.mjs +60 -0
  26. package/src/node.d.ts +65 -0
  27. package/src/node.mjs +536 -0
  28. package/src/session.ts +520 -0
  29. package/src/vite-mobile.d.ts +21 -0
  30. package/src/vite-mobile.mjs +101 -0
  31. package/src/vite.d.ts +11 -0
  32. package/src/vite.mjs +315 -0
  33. package/src/web/entry.web.ts +270 -0
  34. package/src/web/index.web.ts +7 -0
  35. package/src/web/mount.web.ts +86 -0
  36. package/src/web/shell.css +449 -0
  37. package/src/web/shell.web.tsrx +759 -0
  38. package/src/windows/preflight.mjs +113 -0
  39. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  40. package/types/generated/catalog.d.ts +30 -0
  41. package/types/generated/contract.d.ts +145 -0
  42. package/types/generated/controls.d.ts +28 -0
  43. package/types/generated/errors.d.ts +13 -0
  44. package/types/generated/fixture.d.ts +53 -0
  45. package/types/generated/index.d.ts +6 -0
  46. package/types/generated/macos/controller.d.ts +52 -0
  47. package/types/generated/macos/index.macos.d.ts +2 -0
  48. package/types/generated/macos/stage.macos.d.ts +10 -0
  49. package/types/generated/mobile/controller.d.ts +31 -0
  50. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  51. package/types/generated/mobile/index.mobile.d.ts +3 -0
  52. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  53. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  54. package/types/generated/session.d.ts +84 -0
  55. package/types/generated/web/entry.web.d.ts +53 -0
  56. package/types/generated/web/index.web.d.ts +7 -0
  57. package/types/generated/web/mount.web.d.ts +2 -0
  58. package/types/generated/web/shell.web.d.ts +4 -0
@@ -0,0 +1,182 @@
1
+ import {
2
+ defineUniversalComponent,
3
+ universalChildren,
4
+ universalComponent,
5
+ universalTry,
6
+ } from 'octane/universal/native'
7
+
8
+ import { createMacOSRoot } from '@octane-xplat/macos-renderer'
9
+ import type { StageHost, MountRequest, MountedStory } from '../session'
10
+
11
+ const RENDERER = 'macos'
12
+ const UNIVERSAL_COMPONENT = Symbol.for('octane.universal.component')
13
+
14
+ /** Compiler output is already defined; plain functions need the stamp before
15
+ * the root will mount them (same rule hmrUniversalComponent applies). */
16
+ const asComponent = (component: any) =>
17
+ component?.[UNIVERSAL_COMPONENT]
18
+ ? component
19
+ : defineUniversalComponent(RENDERER, component, {
20
+ module: '@octane-xplat/workshop/macos-stage',
21
+ } as any)
22
+
23
+ /**
24
+ * Provider/story composition in explicit universal-runtime calls — the same
25
+ * ABI the TSRX compiler emits, but dynamic components stay data. Compiled
26
+ * components are invoked by the root, never called as plain functions.
27
+ */
28
+ function storyTree(request: MountRequest, args: Record<string, unknown>) {
29
+ const component = asComponent(request.component)
30
+ let tree: unknown = universalComponent(RENDERER, component, args as any)
31
+ for (const provider of [...request.providers].reverse() as any[]) {
32
+ const inner = tree
33
+ tree = universalComponent(RENDERER, asComponent(provider), {
34
+ fixture: request.fixture,
35
+ children: universalChildren(RENDERER, () => inner as any),
36
+ })
37
+ }
38
+
39
+ // The catch body runs inside the render pass — octane rejects store
40
+ // updates there, so report after the pass commits (same rule as web).
41
+ return universalTry(
42
+ () => tree as any,
43
+ null,
44
+ (error) => {
45
+ queueMicrotask(() => request.onRenderError(error))
46
+ return null
47
+ },
48
+ )
49
+ }
50
+
51
+ /** Universal roots mount compiler-defined components only — the stage root
52
+ * is a static wrapper; story/props arrive through its props. */
53
+ const StoryTreeRoot = defineUniversalComponent(
54
+ RENDERER,
55
+ (p: { request: MountRequest; args: Record<string, unknown> }) => storyTree(p.request, p.args),
56
+ { module: '@octane-xplat/workshop/macos-stage' } as any,
57
+ )
58
+
59
+ export interface MacOSStageOptions {
60
+ /** An isolated native stage view; shell layout may move/resize it without remounting. */
61
+ view: any
62
+ /** The shell's window — story-owned windows and sheets anchor to it. */
63
+ parentWindow: any
64
+ fontFamily?: string
65
+ }
66
+
67
+ /** Session-owned AppKit root. Full bags remove omitted props, including Unset. */
68
+ export function createMacOSStageHost(options: MacOSStageOptions): StageHost {
69
+ return {
70
+ mount(request: MountRequest): MountedStory {
71
+ const root = createMacOSRoot(options.view, { fontFamily: options.fontFamily })
72
+ let disposed = false
73
+ const bridge = globalThis as any
74
+ const previousOpen = bridge.__xplatAppKitOpenWindow
75
+ const previousSheet = bridge.__xplatAppKitMountSheet
76
+ const windows = new Set<any>()
77
+ const open = (settings: any = {}) => {
78
+ if (disposed || request.signal.aborted) {
79
+ throw new Error('Story generation has retired')
80
+ }
81
+
82
+ if (typeof previousOpen !== 'function') {
83
+ throw new Error('Consumer AppKit window service is unavailable')
84
+ }
85
+
86
+ const win = previousOpen({
87
+ ...settings,
88
+ parent: settings.parent ?? options.parentWindow,
89
+ ...(settings.component
90
+ ? {
91
+ component: StoryTreeRoot,
92
+ props: {
93
+ request: { ...request, component: settings.component },
94
+ args: { ...request.props, ...settings.props },
95
+ },
96
+ }
97
+ : {}),
98
+ })
99
+
100
+ if (!win || typeof win.close !== 'function') {
101
+ throw new Error(
102
+ 'Consumer AppKit window service must return an owned closeable controller',
103
+ )
104
+ }
105
+
106
+ windows.add(win)
107
+ return win
108
+ }
109
+
110
+ const sheet = (component: any, props: any, settings: any) =>
111
+ open({ ...settings, kind: 'dialog', component, props })
112
+
113
+ bridge.__xplatAppKitOpenWindow = open
114
+ bridge.__xplatAppKitMountSheet = sheet
115
+ const unmount = () => {
116
+ if (disposed) {
117
+ return
118
+ }
119
+
120
+ disposed = true
121
+ if (bridge.__xplatAppKitOpenWindow === open) {
122
+ bridge.__xplatAppKitOpenWindow = previousOpen
123
+ }
124
+
125
+ if (bridge.__xplatAppKitMountSheet === sheet) {
126
+ bridge.__xplatAppKitMountSheet = previousSheet
127
+ }
128
+
129
+ const failures: unknown[] = []
130
+ for (const win of windows) {
131
+ try {
132
+ win.close()
133
+ } catch (error) {
134
+ failures.push(error)
135
+ }
136
+ }
137
+
138
+ windows.clear()
139
+ try {
140
+ root.unmount()
141
+ } catch (error) {
142
+ failures.push(error)
143
+ }
144
+
145
+ if (failures.length) {
146
+ throw new AggregateError(failures, 'AppKit story disposal failed')
147
+ }
148
+ }
149
+
150
+ const render = (args: Record<string, unknown>) =>
151
+ root.render(
152
+ StoryTreeRoot as any,
153
+ {
154
+ request,
155
+ args,
156
+ parentWindow: options.parentWindow,
157
+ } as any,
158
+ )
159
+
160
+ try {
161
+ render(request.props)
162
+ } catch (error: any) {
163
+ try {
164
+ unmount()
165
+ } catch (cleanup) {
166
+ throw new AggregateError([error, cleanup], 'AppKit mount and rollback failed')
167
+ }
168
+
169
+ throw error
170
+ }
171
+
172
+ return {
173
+ update(args) {
174
+ if (!disposed) {
175
+ render({ ...args })
176
+ }
177
+ },
178
+ unmount,
179
+ }
180
+ },
181
+ }
182
+ }
@@ -0,0 +1,95 @@
1
+ import { createRequire } from 'node:module'
2
+ import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'
3
+ import { join, relative, dirname } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /** Generate a separate consumer-owned cache profile; leave app entries untouched. */
7
+ export async function workshopMacOSDev({ root, config, story }) {
8
+ if (process.platform !== 'darwin' || process.arch !== 'arm64') {
9
+ throw new Error('The AppKit workshop requires a macOS arm64 execution host')
10
+ }
11
+
12
+ const require = createRequire(join(root, 'package.json'))
13
+ for (const name of [
14
+ '@octane-xplat/macos-renderer',
15
+ '@nativescript/macos-node-api',
16
+ 'octane',
17
+ '@octanejs/vite-plugin',
18
+ 'vite',
19
+ ]) {
20
+ try {
21
+ require.resolve(name)
22
+ } catch {
23
+ throw new Error(
24
+ `AppKit workshop prerequisite missing: ${name}; use the matching versions from your app's macOS setup`,
25
+ )
26
+ }
27
+ }
28
+
29
+ const cache = join(root, 'node_modules/.cache/xplat/workshop-macos')
30
+ await mkdir(cache, { recursive: true })
31
+ const directory = await mkdtemp(join(cache, 'session-'))
32
+ const source = dirname(fileURLToPath(import.meta.url))
33
+ const catalogPlugin = join(source, 'macos-vite.mjs')
34
+ const shell = join(source, 'macos/shell.macos.mjs')
35
+ const bundle = join(directory, 'stories.cjs')
36
+ const shellBundle = join(directory, 'shell.cjs')
37
+ const catalogFile = join(directory, 'catalog.gen.mjs')
38
+ const common = `import { xplatMacOS } from '@octane-xplat/cli/macos/vite'
39
+ import { macOSCatalogPlugin } from ${JSON.stringify(catalogPlugin)}
40
+ const root = ${JSON.stringify(root)}
41
+ `
42
+
43
+ await writeFile(
44
+ join(directory, 'stories.config.mjs'),
45
+ common +
46
+ `
47
+ export default async ({ mode }) => {
48
+ const settings = await xplatMacOS(mode, { root, packaged: false, hmr: false })
49
+ return { ...settings, plugins: [macOSCatalogPlugin({ root, config: ${JSON.stringify(config)}, catalogFile: ${JSON.stringify(catalogFile)} }), ...settings.plugins],
50
+ build: { ...settings.build, outDir: ${JSON.stringify(directory)}, emptyOutDir: false,
51
+ lib: { entry: ${JSON.stringify(catalogFile)}, formats: ['cjs'], fileName: () => 'stories.cjs' } } }
52
+ }
53
+ `,
54
+ )
55
+
56
+ await writeFile(
57
+ join(directory, 'entry.mjs'),
58
+ `import { startMacOSWorkshop } from ${JSON.stringify(shell)}
59
+ startMacOSWorkshop({ bundleFile: ${JSON.stringify(bundle)}, initialStoryId: ${JSON.stringify(story ?? null)},
60
+ offscreen: process.env.OCTANE_WORKSHOP_OFFSCREEN === '1' })
61
+ `,
62
+ )
63
+
64
+ await writeFile(
65
+ join(directory, 'shell.config.mjs'),
66
+ common +
67
+ `
68
+ export default async ({ mode }) => {
69
+ const settings = await xplatMacOS(mode, { root, packaged: true, entry: ${JSON.stringify(join(directory, 'entry.mjs'))} })
70
+ return { ...settings, build: { ...settings.build, outDir: ${JSON.stringify(directory)}, emptyOutDir: false,
71
+ lib: { entry: ${JSON.stringify(join(directory, 'entry.mjs'))}, formats: ['cjs'], fileName: () => 'shell.cjs' } } }
72
+ }
73
+ `,
74
+ )
75
+
76
+ try {
77
+ const { runMacOSDev } = await import('@octane-xplat/cli/macos')
78
+ return await runMacOSDev(root, {
79
+ workshop: true,
80
+ target: {
81
+ runtime: 'appkit-node-api',
82
+ renderer: 'appkit',
83
+ dev: {
84
+ productName: 'Octane component workshop',
85
+ viteConfig: relative(root, join(directory, 'stories.config.mjs')),
86
+ bundleFile: relative(root, bundle),
87
+ shellViteConfig: relative(root, join(directory, 'shell.config.mjs')),
88
+ shellBundleFile: relative(root, shellBundle),
89
+ },
90
+ },
91
+ })
92
+ } finally {
93
+ await rm(directory, { recursive: true, force: true })
94
+ }
95
+ }
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { discoverStories } from './discover.mjs'
4
+
5
+ /**
6
+ * Regenerates the real catalog module on every (re)build. Static applicability
7
+ * is resolved before any platform leaf enters the bundle — incompatible leaves
8
+ * keep a catalog entry but never a `load` import.
9
+ */
10
+ export function macOSCatalogPlugin({ root, config = {}, catalogFile }) {
11
+ const generate = (ctx) => {
12
+ const { entries, diagnostics } = discoverStories(root, { ...config.stories, target: 'macos' })
13
+ for (const entry of entries) {
14
+ ctx?.addWatchFile?.(entry.file)
15
+ }
16
+
17
+ const providers = (config.providerModules ?? []).map(
18
+ (file, i) => `import * as preset${i} from ${JSON.stringify(resolve(root, file))}`,
19
+ )
20
+
21
+ const catalog = entries.map(
22
+ (entry) => `{
23
+ ...${JSON.stringify(entry)},
24
+ ${entry.applicable ? `load: () => import(${JSON.stringify(entry.file)})` : ''}
25
+ }`,
26
+ )
27
+
28
+ const next = `${providers.join('\n')}
29
+ export const catalog = [${catalog.join(',\n')}]
30
+ export const diagnostics = ${JSON.stringify(diagnostics)}
31
+ export const providers = [${(config.providerModules ?? []).map((_, i) => `...(preset${i}.providers ?? preset${i}.default ?? [])`).join(',')}]
32
+ `
33
+
34
+ // Same-content regeneration must not retrigger the watcher.
35
+ if (!existsSync(catalogFile) || readFileSync(catalogFile, 'utf8') !== next) {
36
+ writeFileSync(catalogFile, next)
37
+ }
38
+ }
39
+
40
+ return {
41
+ name: 'xplat-workshop-macos-catalog',
42
+ buildStart() {
43
+ // Runs on every rebuild; story modules stay lazily imported.
44
+ generate.call(this)
45
+ },
46
+ }
47
+ }
@@ -0,0 +1,134 @@
1
+ import type { CatalogEntry } from '../catalog'
2
+ import type { StoryDefinition } from '../contract'
3
+ import { WorkshopSession } from '../session'
4
+ import { applyControlInput, type ControlValueInput } from '../controls'
5
+
6
+ /** Window geometry changes shell presentation only, never the story root. */
7
+ export function mobileShellMode(width: number, height: number): 'wide' | 'compact' | 'phone' {
8
+ if (width >= 1000 && height >= 500) {
9
+ return 'wide'
10
+ }
11
+
12
+ return width <= 600 ? 'phone' : 'compact'
13
+ }
14
+
15
+ export class MobileController {
16
+ selectedId: string | null = null
17
+ catalog: readonly CatalogEntry[]
18
+ query = ''
19
+ panel: 'stories' | 'controls' | null = null
20
+ stageOnly = false
21
+ missingId: string | null = null
22
+ private generation = 0
23
+ private disposed = false
24
+
25
+ constructor(
26
+ readonly session: WorkshopSession,
27
+ catalog: readonly CatalogEntry[],
28
+ readonly changed: () => void,
29
+ ) {
30
+ this.catalog = catalog
31
+ }
32
+
33
+ async select(id: string, revision?: string): Promise<void> {
34
+ if (this.disposed) {
35
+ return
36
+ }
37
+
38
+ const generation = ++this.generation
39
+ this.selectedId = id
40
+ this.panel = null
41
+ const entry = this.catalog.find((item) => item.id === id)
42
+ this.missingId = entry ? null : id
43
+ this.changed()
44
+ // Filtering is enforced again at the runtime boundary, before load.
45
+ if (!entry?.applicable || !entry.load) {
46
+ await this.session.dispose()
47
+ return
48
+ }
49
+
50
+ try {
51
+ if (revision) {
52
+ this.session.markRebuilding()
53
+ }
54
+
55
+ const module = (await entry.load(revision)) as Record<string, unknown>
56
+ if (generation !== this.generation || this.disposed) {
57
+ return
58
+ }
59
+
60
+ const story = module[entry.exportName] as StoryDefinition<any, any, any>
61
+ if (!story || story.id !== entry.id) {
62
+ throw new Error(`Story export ${entry.exportName} does not match ${entry.id}`)
63
+ }
64
+
65
+ if (revision) {
66
+ await this.session.reload(story)
67
+ } else {
68
+ await this.session.select(story)
69
+ }
70
+ } catch (error) {
71
+ if (generation === this.generation) {
72
+ this.session.markCompileError(error)
73
+ }
74
+ }
75
+ }
76
+
77
+ updateCatalog(
78
+ catalog: readonly CatalogEntry[],
79
+ diagnostics: readonly { message: string }[] = [],
80
+ ): void {
81
+ if (this.disposed) {
82
+ return
83
+ }
84
+
85
+ ++this.generation
86
+ this.catalog = catalog
87
+ this.changed()
88
+ if (diagnostics.length) {
89
+ this.session.markCompileError(diagnostics.map((d) => d.message).join('\n'))
90
+ } else if (this.selectedId) {
91
+ void this.select(this.selectedId, String(Date.now()))
92
+ }
93
+ }
94
+
95
+ /**
96
+ * One Back/Escape step: dismiss shell chrome before the host treats the key
97
+ * as navigation. The active panel closes first; a stage-only session exits
98
+ * next; nothing to dismiss returns null so the key keeps its host meaning.
99
+ * Story content is untouched — panel/flag changes never mount or dispose.
100
+ */
101
+ dismissOverlay(): 'panel' | 'stage-only' | null {
102
+ if (this.panel) {
103
+ this.panel = null
104
+ return 'panel'
105
+ }
106
+
107
+ if (this.stageOnly) {
108
+ this.stageOnly = false
109
+ return 'stage-only'
110
+ }
111
+
112
+ return null
113
+ }
114
+
115
+ control(key: string, value: ControlValueInput): string | undefined {
116
+ const story = this.session.currentStory
117
+ if (!story || this.session.state.phase !== 'ready') {
118
+ return 'Wait for the story to be ready'
119
+ }
120
+
121
+ const result = applyControlInput(story, this.session.currentProps ?? {}, key, value)
122
+ if (result.props) {
123
+ this.session.applyProps(result.props)
124
+ }
125
+
126
+ return result.error?.message
127
+ }
128
+
129
+ async dispose(): Promise<void> {
130
+ this.disposed = true
131
+ ++this.generation
132
+ await this.session.dispose()
133
+ }
134
+ }