@weasel-js/forge 1.5.1 → 1.6.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 (39) hide show
  1. package/README.md +72 -10
  2. package/dist/{FrameController-BPfCt4PJ.d.ts → FrameController-BspC9Zr5.d.ts} +67 -4
  3. package/dist/chunk-A4JOEGYQ.js +325 -0
  4. package/dist/chunk-A4JOEGYQ.js.map +1 -0
  5. package/dist/{chunk-2MADHLRA.js → chunk-FWPBKKYB.js} +58 -231
  6. package/dist/chunk-FWPBKKYB.js.map +1 -0
  7. package/dist/{chunk-6SRQIHJQ.js → chunk-HRGPFQBW.js} +3 -3
  8. package/dist/{chunk-6SRQIHJQ.js.map → chunk-HRGPFQBW.js.map} +1 -1
  9. package/dist/chunk-PO5JLVYT.js +318 -0
  10. package/dist/chunk-PO5JLVYT.js.map +1 -0
  11. package/dist/{chunk-TLO6UHF2.js → chunk-VEKEBILL.js} +17 -4
  12. package/dist/chunk-VEKEBILL.js.map +1 -0
  13. package/dist/{config-CrLpfSq9.d.ts → config-DU_WuORS.d.ts} +7 -1
  14. package/dist/csf/preview-api.js +1 -2
  15. package/dist/csf/preview-api.js.map +1 -1
  16. package/dist/frame/index.css +102 -0
  17. package/dist/frame/index.css.map +1 -1
  18. package/dist/frame/index.d.ts +11 -3
  19. package/dist/frame/index.js +365 -5
  20. package/dist/frame/index.js.map +1 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/shell/index.css +202 -13
  24. package/dist/shell/index.css.map +1 -1
  25. package/dist/shell/index.d.ts +76 -12
  26. package/dist/shell/index.js +1075 -187
  27. package/dist/shell/index.js.map +1 -1
  28. package/dist/test/index.d.ts +3 -3
  29. package/dist/test/index.js +67 -72
  30. package/dist/test/index.js.map +1 -1
  31. package/dist/vite/index.js +68 -11
  32. package/dist/vite/index.js.map +1 -1
  33. package/package.json +5 -4
  34. package/dist/chunk-2JDYOBWM.js +0 -118
  35. package/dist/chunk-2JDYOBWM.js.map +0 -1
  36. package/dist/chunk-2MADHLRA.js.map +0 -1
  37. package/dist/chunk-FTFR2YPM.js +0 -13
  38. package/dist/chunk-FTFR2YPM.js.map +0 -1
  39. package/dist/chunk-TLO6UHF2.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { F as FrameSetup } from '../FrameController-BPfCt4PJ.js';
1
+ import { F as FrameSetup } from '../FrameController-BspC9Zr5.js';
2
2
  import '@weasel-js/labkit/config';
3
3
  import 'react';
4
4
 
@@ -9,8 +9,8 @@ interface RunStoryOptions {
9
9
  viewport?: (width: number, height: number) => Promise<void>;
10
10
  }
11
11
  /**
12
- * Renders one story the way the workshop's frame does — defaults, no state, no globals — then plays it.
13
- * Throws on any fault the frame reports, or a play function that fails.
12
+ * Renders one story in the test page the way the workshop does — defaults, no state, no globals — then plays it.
13
+ * Throws on a render the story's boundary caught, or a play function that fails.
14
14
  */
15
15
  declare function runStory(mod: Record<string, unknown>, exportName: string, file: string, autoTitle: string, { setup, viewport }?: RunStoryOptions): Promise<void>;
16
16
 
@@ -1,72 +1,41 @@
1
- import { loadStories, startFrame } from '../chunk-2MADHLRA.js';
1
+ import { StoryTrial } from '../chunk-A4JOEGYQ.js';
2
+ import { loadStories } from '../chunk-FWPBKKYB.js';
2
3
  import '../chunk-2GXKNXTJ.js';
3
- import '../chunk-6SRQIHJQ.js';
4
- import { openChannel } from '../chunk-2JDYOBWM.js';
5
- import '../chunk-TLO6UHF2.js';
6
- import '../chunk-FTFR2YPM.js';
4
+ import '../chunk-HRGPFQBW.js';
5
+ import '../chunk-VEKEBILL.js';
6
+ import { withValueAtPath } from '@weasel-js/labkit/config';
7
+ import { useState, useEffect } from 'react';
8
+ import { createRoot } from 'react-dom/client';
9
+ import { jsx } from 'react/jsx-runtime';
7
10
 
8
- // src/test/runStory.ts
9
11
  function phased(phase, error) {
10
12
  const message = error instanceof Error ? error.message : String(error);
11
13
  const out = new Error(`${phase} fault: ${message}`, { cause: error });
12
14
  if (error instanceof Error && error.stack) out.stack = `${phase} fault: ${error.stack}`;
13
15
  return out;
14
16
  }
15
- function inbox(channel) {
16
- const received = [];
17
- let failure = null;
18
- const wake = /* @__PURE__ */ new Set();
19
- const off = channel.on((msg) => {
20
- if (msg.type === "fault" && !failure) {
21
- failure = new Error(`${msg.phase} fault: ${msg.message}`);
22
- if (msg.stack) failure.stack = `${msg.phase} fault: ${msg.stack}`;
23
- }
24
- received.push(msg);
25
- for (const fn of [...wake]) fn();
26
- });
27
- const check = () => {
28
- if (failure) throw failure;
29
- };
30
- const next = (type) => new Promise((resolve, reject) => {
31
- const poll = () => {
32
- if (failure) {
33
- wake.delete(poll);
34
- reject(failure);
35
- return;
36
- }
37
- const at = received.findIndex((m) => m.type === type);
38
- if (at === -1) return;
39
- wake.delete(poll);
40
- resolve(received.splice(at, 1)[0]);
41
- };
42
- wake.add(poll);
43
- poll();
44
- });
45
- const fault = () => new Promise((resolve) => {
46
- const done = () => {
47
- clearTimeout(timer);
48
- wake.delete(onMessage);
49
- resolve(failure);
50
- };
51
- const onMessage = () => {
52
- if (failure) done();
53
- };
54
- const timer = setTimeout(done, 0);
55
- wake.add(onMessage);
56
- onMessage();
57
- });
58
- return { next, check, fault, close: off };
59
- }
60
17
  var settle = () => new Promise((resolve) => {
61
18
  requestAnimationFrame(() => setTimeout(resolve, 0));
62
19
  });
20
+ function TestTrial({ story, setup, hostRef, onConfig, onRendered, onError }) {
21
+ const [config, setConfigState] = useState(() => story.config.defaults());
22
+ const [state, setState] = useState(null);
23
+ useEffect(() => onConfig(config), [config, onConfig]);
24
+ const ctx = {
25
+ config,
26
+ state,
27
+ setConfig: (path, value) => setConfigState((prev) => withValueAtPath(prev, path, value)),
28
+ setState
29
+ };
30
+ return /* @__PURE__ */ jsx(StoryTrial, { story, setup, ctx, hostRef, onRendered, onError });
31
+ }
63
32
  var leftover = null;
64
33
  var pageSize = null;
65
- async function runStory(mod, exportName, file, autoTitle, { setup, viewport } = {}) {
34
+ async function runStory(mod, exportName, file, autoTitle, { setup = {}, viewport } = {}) {
66
35
  leftover?.();
67
36
  let story;
68
37
  try {
69
- story = loadStories(mod, autoTitle, setup?.parameters).find((s) => s.exportName === exportName);
38
+ story = loadStories(mod, autoTitle, setup.parameters).find((s) => s.exportName === exportName);
70
39
  } catch (error) {
71
40
  throw phased("load", error);
72
41
  }
@@ -76,49 +45,75 @@ async function runStory(mod, exportName, file, autoTitle, { setup, viewport } =
76
45
  if (viewport && (window.innerWidth !== size.width || window.innerHeight !== size.height)) {
77
46
  await viewport(size.width, size.height);
78
47
  }
79
- const { port1, port2 } = new MessageChannel();
80
- const shell = openChannel(port1);
81
- const frame = openChannel(port2);
82
- const messages = inbox(shell);
83
48
  const container = document.createElement("div");
84
49
  document.body.append(container);
85
- let stopFrame;
50
+ let root;
86
51
  const cleanup = () => {
87
52
  if (leftover === cleanup) leftover = null;
88
53
  let thrown = null;
89
54
  try {
90
- messages.close();
91
- stopFrame?.();
55
+ root?.unmount();
92
56
  } catch (error) {
93
57
  thrown = { error };
94
58
  } finally {
95
- stopFrame = void 0;
96
- shell.close();
97
- frame.close();
59
+ root = void 0;
98
60
  container.remove();
99
61
  }
100
62
  return thrown;
101
63
  };
102
64
  leftover = cleanup;
65
+ const hostRef = { current: null };
66
+ let config;
67
+ let renderError = null;
68
+ let markRendered = () => {
69
+ };
70
+ const rendered = new Promise((resolve) => {
71
+ markRendered = resolve;
72
+ });
73
+ const fail = (error) => {
74
+ renderError ??= { error };
75
+ markRendered();
76
+ };
77
+ const checkRender = () => {
78
+ if (renderError) throw phased("render", renderError.error);
79
+ };
103
80
  let failure = null;
104
81
  try {
105
82
  try {
106
- stopFrame = startFrame({ story, channel: frame, container, setup });
83
+ await setup.prepare?.(story);
84
+ root = createRoot(container, { onUncaughtError: fail });
85
+ root.render(
86
+ /* @__PURE__ */ jsx(
87
+ TestTrial,
88
+ {
89
+ story,
90
+ setup,
91
+ hostRef,
92
+ onConfig: (next) => {
93
+ config = next;
94
+ },
95
+ onRendered: markRendered,
96
+ onError: fail
97
+ }
98
+ )
99
+ );
107
100
  } catch (error) {
108
101
  throw phased("mount", error);
109
102
  }
110
- await messages.next("ready");
111
- shell.send({ type: "init", config: story.config.defaults(), state: null, globals: {} });
112
- await messages.next("answers");
103
+ await rendered;
113
104
  await settle();
114
- messages.check();
105
+ checkRender();
106
+ const host = hostRef.current;
107
+ if (!host) throw new Error("render fault: the story host never mounted");
115
108
  if (story.play) {
116
- shell.send({ type: "play" });
117
- const played = await messages.next("played");
118
- if (!played.ok) throw await messages.fault() ?? new Error(`play fault: ${played.message ?? "failed"}`);
109
+ try {
110
+ await story.play({ canvasElement: host, config, globals: {} });
111
+ } catch (error) {
112
+ throw phased("play", error);
113
+ }
119
114
  await settle();
115
+ checkRender();
120
116
  }
121
- messages.check();
122
117
  } catch (error) {
123
118
  failure = { error };
124
119
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/test/runStory.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { type FrameSetup, startFrame } from '../frame/FrameController';\nimport { loadStories } from '../frame/mountFrame';\nimport { type Channel, openChannel } from '../protocol/channel';\nimport type { FromFrame, ToFrame, Viewport } from '../protocol/messages';\n\ntype Received<T extends FromFrame['type']> = Extract<FromFrame, { type: T }>;\n\n/** Prefixes `error` with the phase it came from, keeping its stack. */\nfunction phased(phase: string, error: unknown): Error {\n const message = error instanceof Error ? error.message : String(error);\n const out = new Error(`${phase} fault: ${message}`, { cause: error });\n if (error instanceof Error && error.stack) out.stack = `${phase} fault: ${error.stack}`;\n return out;\n}\n\nfunction inbox(channel: Channel<FromFrame, ToFrame>) {\n const received: FromFrame[] = [];\n let failure: Error | null = null;\n const wake = new Set<() => void>();\n const off = channel.on((msg) => {\n if (msg.type === 'fault' && !failure) {\n failure = new Error(`${msg.phase} fault: ${msg.message}`);\n if (msg.stack) failure.stack = `${msg.phase} fault: ${msg.stack}`;\n }\n received.push(msg);\n for (const fn of [...wake]) fn();\n });\n\n const check = () => {\n if (failure) throw failure;\n };\n\n const next = <T extends FromFrame['type']>(type: T): Promise<Received<T>> =>\n new Promise((resolve, reject) => {\n const poll = () => {\n if (failure) {\n wake.delete(poll);\n reject(failure);\n return;\n }\n const at = received.findIndex((m) => m.type === type);\n if (at === -1) return;\n wake.delete(poll);\n resolve(received.splice(at, 1)[0] as Received<T>);\n };\n wake.add(poll);\n poll();\n });\n\n /** The first fault, if one arrives by the next macrotask. */\n const fault = (): Promise<Error | null> =>\n new Promise((resolve) => {\n const done = () => {\n clearTimeout(timer);\n wake.delete(onMessage);\n resolve(failure);\n };\n const onMessage = () => {\n if (failure) done();\n };\n const timer = setTimeout(done, 0);\n wake.add(onMessage);\n onMessage();\n });\n\n return { next, check, fault, close: off };\n}\n\nconst settle = () =>\n new Promise<void>((resolve) => {\n requestAnimationFrame(() => setTimeout(resolve, 0));\n });\n\nexport interface RunStoryOptions {\n /** The frame config (`frameConfig`). */\n setup?: FrameSetup;\n /** Resizes the page, for a story that names a viewport. Without it, stories run at whatever size the page is. */\n viewport?: (width: number, height: number) => Promise<void>;\n}\n\n/** Stops the story a timed-out test left mounted. */\nlet leftover: (() => unknown) | null = null;\n/** The page's size before any story resized it; a story with no viewport runs at this size. */\nlet pageSize: Viewport | null = null;\n\n/**\n * Renders one story the way the workshop's frame does — defaults, no state, no globals — then plays it.\n * Throws on any fault the frame reports, or a play function that fails.\n */\nexport async function runStory(\n mod: Record<string, unknown>,\n exportName: string,\n file: string,\n autoTitle: string,\n { setup, viewport }: RunStoryOptions = {},\n): Promise<void> {\n leftover?.();\n let story: ReturnType<typeof loadStories>[number] | undefined;\n try {\n story = loadStories(mod, autoTitle, setup?.parameters).find((s) => s.exportName === exportName);\n } catch (error) {\n throw phased('load', error);\n }\n if (!story) throw new Error(`load fault: ${file} has no story export \"${exportName}\"`);\n\n pageSize ??= { width: window.innerWidth, height: window.innerHeight };\n const size = story.viewport ?? pageSize;\n if (viewport && (window.innerWidth !== size.width || window.innerHeight !== size.height)) {\n await viewport(size.width, size.height);\n }\n\n const { port1, port2 } = new MessageChannel();\n const shell = openChannel<FromFrame, ToFrame>(port1);\n const frame = openChannel<ToFrame, FromFrame>(port2);\n const messages = inbox(shell);\n const container = document.createElement('div');\n document.body.append(container);\n let stopFrame: (() => void) | undefined;\n /** Returns what `stopFrame` threw, having released everything else regardless. */\n const cleanup = (): { error: unknown } | null => {\n if (leftover === cleanup) leftover = null;\n let thrown: { error: unknown } | null = null;\n try {\n messages.close();\n stopFrame?.();\n } catch (error) {\n thrown = { error };\n } finally {\n stopFrame = undefined;\n shell.close();\n frame.close();\n container.remove();\n }\n return thrown;\n };\n leftover = cleanup;\n\n let failure: { error: unknown } | null = null;\n try {\n try {\n stopFrame = startFrame({ story, channel: frame, container, setup });\n } catch (error) {\n throw phased('mount', error);\n }\n await messages.next('ready');\n shell.send({ type: 'init', config: story.config.defaults(), state: null, globals: {} });\n await messages.next('answers');\n await settle();\n messages.check();\n if (story.play) {\n shell.send({ type: 'play' });\n const played = await messages.next('played');\n if (!played.ok) throw (await messages.fault()) ?? new Error(`play fault: ${played.message ?? 'failed'}`);\n await settle();\n }\n messages.check();\n } catch (error) {\n failure = { error };\n }\n const cleanupError = cleanup();\n if (failure) throw failure.error;\n if (cleanupError) throw phased('cleanup', cleanupError.error);\n}\n"]}
1
+ {"version":3,"sources":["../../src/test/runStory.tsx"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { withValueAtPath } from '@weasel-js/labkit/config';\nimport { type RefObject, useEffect, useState } from 'react';\nimport { createRoot, type Root } from 'react-dom/client';\nimport type { FrameSetup } from '../frame/FrameController';\nimport type { Viewport } from '../protocol/messages';\nimport { StoryTrial } from '../shell/StoryTrial';\nimport { loadStories } from '../story/load';\nimport type { LoadedStory } from '../story/types';\n\n/** Prefixes `error` with the phase it came from, keeping its stack. */\nfunction phased(phase: string, error: unknown): Error {\n const message = error instanceof Error ? error.message : String(error);\n const out = new Error(`${phase} fault: ${message}`, { cause: error });\n if (error instanceof Error && error.stack) out.stack = `${phase} fault: ${error.stack}`;\n return out;\n}\n\nconst settle = () =>\n new Promise<void>((resolve) => {\n requestAnimationFrame(() => setTimeout(resolve, 0));\n });\n\ninterface TestTrialProps {\n story: LoadedStory;\n setup: FrameSetup;\n hostRef: RefObject<HTMLElement | null>;\n /** The config in force after each commit, so `play` reads what the story last wrote. */\n onConfig: (config: unknown) => void;\n onRendered: () => void;\n onError: (error: unknown) => void;\n}\n\n/** The trial a story test runs in: its config and state, held the way a lab trial holds them. */\nfunction TestTrial({ story, setup, hostRef, onConfig, onRendered, onError }: TestTrialProps) {\n const [config, setConfigState] = useState<unknown>(() => story.config.defaults());\n const [state, setState] = useState<unknown>(null);\n useEffect(() => onConfig(config), [config, onConfig]);\n const ctx = {\n config,\n state,\n setConfig: (path: string, value: unknown) => setConfigState((prev: unknown) => withValueAtPath(prev, path, value)),\n setState,\n };\n return <StoryTrial story={story} setup={setup} ctx={ctx} hostRef={hostRef} onRendered={onRendered} onError={onError} />;\n}\n\nexport interface RunStoryOptions {\n /** The frame config (`frameConfig`). */\n setup?: FrameSetup;\n /** Resizes the page, for a story that names a viewport. Without it, stories run at whatever size the page is. */\n viewport?: (width: number, height: number) => Promise<void>;\n}\n\n/** Stops the story a timed-out test left mounted. */\nlet leftover: (() => unknown) | null = null;\n/** The page's size before any story resized it; a story with no viewport runs at this size. */\nlet pageSize: Viewport | null = null;\n\n/**\n * Renders one story in the test page the way the workshop does — defaults, no state, no globals — then plays it.\n * Throws on a render the story's boundary caught, or a play function that fails.\n */\nexport async function runStory(\n mod: Record<string, unknown>,\n exportName: string,\n file: string,\n autoTitle: string,\n { setup = {}, viewport }: RunStoryOptions = {},\n): Promise<void> {\n leftover?.();\n let story: LoadedStory | undefined;\n try {\n story = loadStories(mod, autoTitle, setup.parameters).find((s) => s.exportName === exportName);\n } catch (error) {\n throw phased('load', error);\n }\n if (!story) throw new Error(`load fault: ${file} has no story export \"${exportName}\"`);\n\n pageSize ??= { width: window.innerWidth, height: window.innerHeight };\n const size = story.viewport ?? pageSize;\n if (viewport && (window.innerWidth !== size.width || window.innerHeight !== size.height)) {\n await viewport(size.width, size.height);\n }\n\n const container = document.createElement('div');\n document.body.append(container);\n let root: Root | undefined;\n /** Returns what unmounting threw, having released everything else regardless. */\n const cleanup = (): { error: unknown } | null => {\n if (leftover === cleanup) leftover = null;\n let thrown: { error: unknown } | null = null;\n try {\n root?.unmount();\n } catch (error) {\n thrown = { error };\n } finally {\n root = undefined;\n container.remove();\n }\n return thrown;\n };\n leftover = cleanup;\n\n const hostRef: RefObject<HTMLElement | null> = { current: null };\n let config: unknown;\n let renderError: { error: unknown } | null = null;\n let markRendered: () => void = () => {};\n const rendered = new Promise<void>((resolve) => {\n markRendered = resolve;\n });\n const fail = (error: unknown) => {\n renderError ??= { error };\n // An error the boundary could not catch leaves nothing to announce `rendered`; the check below throws instead.\n markRendered();\n };\n const checkRender = () => {\n if (renderError) throw phased('render', renderError.error);\n };\n\n let failure: { error: unknown } | null = null;\n try {\n try {\n await setup.prepare?.(story);\n root = createRoot(container, { onUncaughtError: fail });\n root.render(\n <TestTrial\n story={story}\n setup={setup}\n hostRef={hostRef}\n onConfig={(next) => {\n config = next;\n }}\n onRendered={markRendered}\n onError={fail}\n />,\n );\n } catch (error) {\n throw phased('mount', error);\n }\n await rendered;\n await settle();\n checkRender();\n const host = hostRef.current;\n if (!host) throw new Error('render fault: the story host never mounted');\n if (story.play) {\n try {\n await story.play({ canvasElement: host, config, globals: {} });\n } catch (error) {\n throw phased('play', error);\n }\n await settle();\n checkRender();\n }\n } catch (error) {\n failure = { error };\n }\n const cleanupError = cleanup();\n if (failure) throw failure.error;\n if (cleanupError) throw phased('cleanup', cleanupError.error);\n}\n"]}
@@ -1,5 +1,5 @@
1
- import { storyNameFromExport, storyId } from '../chunk-6SRQIHJQ.js';
2
- import { existsSync, readFileSync, mkdirSync, writeFileSync, globSync } from 'fs';
1
+ import { storyNameFromExport, storyId } from '../chunk-HRGPFQBW.js';
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, globSync, statSync } from 'fs';
3
3
  import { resolve, relative, sep, matchesGlob, dirname, join, basename } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { parse } from '@babel/parser';
@@ -99,24 +99,31 @@ function indexProgram(program, file, autoTitle2) {
99
99
  }
100
100
  }
101
101
  let metaNode;
102
+ let metaDoc;
102
103
  const exported = [];
103
104
  for (const stmt of program.body) {
104
- if (stmt.type === "ExportDefaultDeclaration") metaNode = stmt.declaration;
105
+ if (stmt.type === "ExportDefaultDeclaration") {
106
+ metaNode = stmt.declaration;
107
+ metaDoc = docOf(stmt);
108
+ }
105
109
  if (stmt.type !== "ExportNamedDeclaration" || stmt.exportKind === "type") continue;
110
+ const doc = docOf(stmt);
106
111
  const decl = stmt.declaration;
107
112
  if (decl?.type === "VariableDeclaration") {
108
113
  for (const d of decl.declarations) {
109
- if (d.id.type === "Identifier") exported.push({ name: d.id.name, node: d.init ?? void 0 });
114
+ if (d.id.type === "Identifier") exported.push({ name: d.id.name, node: d.init ?? void 0, doc: docOf(d) ?? doc });
110
115
  }
111
116
  } else if (decl?.type === "FunctionDeclaration" && decl.id) {
112
- exported.push({ name: decl.id.name, node: void 0 });
117
+ exported.push({ name: decl.id.name, node: void 0, doc });
113
118
  }
114
119
  for (const spec of stmt.specifiers) {
115
120
  if (spec.type !== "ExportSpecifier" || spec.exportKind === "type") continue;
116
121
  const name = exportedName(spec.exported);
117
122
  const node = stmt.source ? void 0 : bindings.get(spec.local.name);
118
- if (name === "default") metaNode = node;
119
- else exported.push({ name, node });
123
+ if (name === "default") {
124
+ metaNode = node;
125
+ metaDoc ??= doc;
126
+ } else exported.push({ name, node, doc });
120
127
  }
121
128
  }
122
129
  if (!metaNode && !program.body.some(isDefaultExport)) return [];
@@ -124,21 +131,50 @@ function indexProgram(program, file, autoTitle2) {
124
131
  const title = stringProp(meta, "title") ?? autoTitle2;
125
132
  const include = storyFilter(meta, "includeStories");
126
133
  const exclude = storyFilter(meta, "excludeStories");
127
- return exported.filter(({ name }) => name !== "__namedExportsOrder").filter(({ name }) => (!include || include(name)) && (!exclude || !exclude(name))).map(({ name, node }) => {
134
+ const componentNode = prop(meta, "component");
135
+ const componentName = componentNode?.type === "Identifier" ? componentNode.name : void 0;
136
+ const metaIsolate = isolateOf(meta, bindings, wrappers, "default");
137
+ return exported.filter(({ name }) => name !== "__namedExportsOrder").filter(({ name }) => (!include || include(name)) && (!exclude || !exclude(name))).map(({ name, node, doc }) => {
128
138
  const spec = objectOf(node, bindings, wrappers);
139
+ const isolate = isolateOf(spec, bindings, wrappers, name) ?? metaIsolate;
129
140
  return {
130
141
  id: storyId(title, name),
131
142
  title,
132
143
  name: stringProp(spec, "name") ?? stringProp(spec, "storyName") ?? storyNameFromExport(name),
133
144
  exportName: name,
134
- file
145
+ file,
146
+ ...doc === void 0 ? {} : { description: doc },
147
+ ...metaDoc === void 0 ? {} : { componentDescription: metaDoc },
148
+ ...componentName === void 0 ? {} : { componentName },
149
+ ...isolate === void 0 ? {} : { isolate }
135
150
  };
136
151
  });
137
152
  }
153
+ function isolateOf(obj, bindings, wrappers, exportName) {
154
+ const parameters = objectOf(prop(obj, "parameters"), bindings, wrappers);
155
+ const forge2 = objectOf(prop(parameters, "forge"), bindings, wrappers);
156
+ const value = prop(obj, "isolate") ?? prop(forge2, "isolate");
157
+ if (value === void 0) return void 0;
158
+ const literal = stringLiteral(value);
159
+ if (literal === void 0) throw new Error(`${exportName}: isolate must be a string literal`);
160
+ return literal;
161
+ }
162
+ function stringLiteral(node) {
163
+ if (node.type === "StringLiteral") return node.value;
164
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis.map((q) => q.value.cooked ?? q.value.raw).join("");
165
+ return void 0;
166
+ }
138
167
  function isDefaultExport(stmt) {
139
168
  if (stmt.type === "ExportDefaultDeclaration") return true;
140
169
  return stmt.type === "ExportNamedDeclaration" && stmt.specifiers.some((s) => s.type === "ExportSpecifier" && exportedName(s.exported) === "default");
141
170
  }
171
+ function docOf(node) {
172
+ const comments = node.leadingComments;
173
+ const last = comments?.[comments.length - 1];
174
+ if (last?.type !== "CommentBlock" || !last.value.startsWith("*")) return void 0;
175
+ const text = last.value.slice(1).split("\n").map((line) => line.replace(/^\s*\*/, "").trim()).join("\n").trim();
176
+ return text || void 0;
177
+ }
142
178
  function exportedName(node) {
143
179
  return node.type === "Identifier" ? node.name : node.value;
144
180
  }
@@ -224,7 +260,7 @@ function forge(options) {
224
260
  let logger;
225
261
  const glob = () => {
226
262
  const files2 = options.stories.flatMap(
227
- (pattern) => globSync(pattern, { cwd: root, exclude: (name) => basename(String(name)) === "node_modules" }).map((f) => resolve(root, f)).sort()
263
+ (pattern) => globSync(pattern, { cwd: root, exclude: (name) => basename(String(name)) === "node_modules" }).map((f) => resolve(root, f)).filter((f) => statSync(f).isFile()).sort()
228
264
  );
229
265
  return [...new Set(files2)];
230
266
  };
@@ -266,15 +302,29 @@ ${lines.join("\n")}
266
302
  import '@weasel-js/labkit/styles.css';
267
303
  import '@weasel-js/forge/shell.css';
268
304
  import index from 'virtual:forge/index.js';
305
+ import importers from 'virtual:forge/importers.js';
269
306
  import config from 'virtual:forge/shell-config.js';
307
+ import setup from 'virtual:forge/frame-config.js';
270
308
 
271
309
  const workshop = mountWorkshop({
272
310
  index,
311
+ importers,
312
+ setup,
273
313
  config,
274
314
  frameUrl: ${JSON.stringify(`${base}frame.html`)},
275
315
  stories: ${JSON.stringify(options.stories)},
276
316
  });
277
317
  import.meta.hot?.on('forge:index', (next) => workshop.setIndex(next));
318
+ // A story edit reaches here as an update of the importers module, whose fresh import() URLs carry the new
319
+ // timestamps; the file it names arrives just before, so the reload waits for the importers that can fetch it.
320
+ const stale = new Set();
321
+ import.meta.hot?.on('forge:story', ({ file }) => stale.add(file));
322
+ import.meta.hot?.accept('virtual:forge/importers.js', (next) => {
323
+ if (!next) return;
324
+ workshop.setImporters(next.default);
325
+ for (const file of stale) workshop.reloadStory(file);
326
+ stale.clear();
327
+ });
278
328
  `,
279
329
  "frame-entry.js": () => `import { mountFrame } from '@weasel-js/forge/frame';
280
330
  import '@weasel-js/forge/frame.css';
@@ -283,10 +333,14 @@ import importers from 'virtual:forge/importers.js';
283
333
  import setup from 'virtual:forge/frame-config.js';
284
334
 
285
335
  mountFrame({ index, importers, setup });
336
+ // The importers module is shared with the workshop page, which accepts a story edit in place; a frame document
337
+ // showing the old module reloads instead, and without this the edit would dead-end here and reload every page.
338
+ import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
286
339
  `
287
340
  };
288
341
  function reindex(server, file, event) {
289
342
  const current = files();
343
+ if (event === "change" && current.has(file)) server.ws.send({ type: "custom", event: "forge:story", data: { file } });
290
344
  if (event === "change" && !current.has(file)) return;
291
345
  if (event === "add" && !matches(file)) return;
292
346
  if (event === "unlink" && !current.has(file)) return;
@@ -398,6 +452,8 @@ function forgeTest(options) {
398
452
  `import { test as __forge_test } from "vitest";`,
399
453
  `import { page as __forge_page } from "vitest/browser";`,
400
454
  `import { runStory as __forge_runStory } from "@weasel-js/forge/test";`,
455
+ // shell.css draws the story host; frame.css still holds the boundary's fault rule.
456
+ `import "@weasel-js/forge/shell.css";`,
401
457
  `import "@weasel-js/forge/frame.css";`,
402
458
  ...options.frameConfig ? [`import __forge_frame_config from ${q(resolve(root, options.frameConfig))};`] : [],
403
459
  `const __forge_file = ${q(id)};`,
@@ -408,8 +464,9 @@ function forgeTest(options) {
408
464
  " viewport: (width, height) => __forge_page.viewport(width, height),",
409
465
  "};",
410
466
  "const __forge_run = async (exportName) => __forge_runStory(await import(/* @vite-ignore */ import.meta.url), exportName, __forge_file, __forge_title, __forge_options);",
467
+ // An isolated story asks for a document of its own, which the test page is not; its reason goes in the name.
411
468
  ...entries.map(
412
- (entry) => `__forge_test(${q(entry.name)}, () => __forge_run(${q(entry.exportName)}), __forge_timeout);`
469
+ (entry) => entry.isolate === void 0 ? `__forge_test(${q(entry.name)}, () => __forge_run(${q(entry.exportName)}), __forge_timeout);` : `__forge_test.skip(${q(`${entry.name} (isolated: ${entry.isolate})`)}, () => {});`
413
470
  )
414
471
  ];
415
472
  return { code: `${code}