@weasel-js/forge 1.11.1 → 1.13.0-rc.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 (50) hide show
  1. package/README.md +110 -5
  2. package/dist/{FrameController-Cj_i-Rdk.d.ts → FrameController-Tv3jrHyT.d.ts} +3 -3
  3. package/dist/{chunk-JTCUIJPZ.js → chunk-35DOPXHZ.js} +119 -10
  4. package/dist/chunk-35DOPXHZ.js.map +1 -0
  5. package/dist/{chunk-7BQPHMA2.js → chunk-3BAM6M26.js} +2 -2
  6. package/dist/chunk-3BAM6M26.js.map +1 -0
  7. package/dist/{chunk-ZAWXMUGZ.js → chunk-5WS67HMU.js} +8 -3
  8. package/dist/chunk-5WS67HMU.js.map +1 -0
  9. package/dist/{chunk-BKPDKUDN.js → chunk-BUXUWAI4.js} +45 -60
  10. package/dist/chunk-BUXUWAI4.js.map +1 -0
  11. package/dist/chunk-ORMMZ5PV.js +59 -0
  12. package/dist/chunk-ORMMZ5PV.js.map +1 -0
  13. package/dist/chunk-RKQ277P5.js +501 -0
  14. package/dist/chunk-RKQ277P5.js.map +1 -0
  15. package/dist/chunk-STF6NQ5N.js +60 -0
  16. package/dist/chunk-STF6NQ5N.js.map +1 -0
  17. package/dist/chunk-VUPN73JP.js +16 -0
  18. package/dist/chunk-VUPN73JP.js.map +1 -0
  19. package/dist/{config-DN6IaNf6.d.ts → config-Bd6FVlh7.d.ts} +22 -5
  20. package/dist/frame/index.css +37 -0
  21. package/dist/frame/index.css.map +1 -1
  22. package/dist/frame/index.d.ts +62 -29
  23. package/dist/frame/index.js +138 -8
  24. package/dist/frame/index.js.map +1 -1
  25. package/dist/index.d.ts +28 -5
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/mountFrame-FdJDb3TB.d.ts +35 -0
  29. package/dist/shell/index.css +147 -2
  30. package/dist/shell/index.css.map +1 -1
  31. package/dist/shell/index.d.ts +44 -8
  32. package/dist/shell/index.js +1614 -277
  33. package/dist/shell/index.js.map +1 -1
  34. package/dist/test/index.css +58 -0
  35. package/dist/test/index.css.map +1 -0
  36. package/dist/test/index.d.ts +1 -1
  37. package/dist/test/index.js +4 -4
  38. package/dist/vite/index.js +571 -121
  39. package/dist/vite/index.js.map +1 -1
  40. package/package.json +13 -8
  41. package/dist/chunk-5DXUNLV2.js +0 -6
  42. package/dist/chunk-5DXUNLV2.js.map +0 -1
  43. package/dist/chunk-7BQPHMA2.js.map +0 -1
  44. package/dist/chunk-BKPDKUDN.js.map +0 -1
  45. package/dist/chunk-FZNABWJC.js +0 -282
  46. package/dist/chunk-FZNABWJC.js.map +0 -1
  47. package/dist/chunk-JTCUIJPZ.js.map +0 -1
  48. package/dist/chunk-WEQSLPKT.js +0 -9
  49. package/dist/chunk-WEQSLPKT.js.map +0 -1
  50. package/dist/chunk-ZAWXMUGZ.js.map +0 -1
@@ -1,11 +1,14 @@
1
- import { DEFAULT_TITLE } from '../chunk-5DXUNLV2.js';
2
- import { isGallery } from '../chunk-WEQSLPKT.js';
3
- import { PROP_ARG_TYPES_EXPORT, storyNameFromExport, storyId } from '../chunk-ZAWXMUGZ.js';
1
+ import { CAPTURE_PAGES, DEFAULT_TITLE, CAPTURE_PATH, parsePayload, messageOf } from '../chunk-ORMMZ5PV.js';
2
+ import { byComponent } from '../chunk-STF6NQ5N.js';
3
+ import { isGallery } from '../chunk-VUPN73JP.js';
4
+ import { PROP_ARG_TYPES_EXPORT, storyNameFromExport, storyId } from '../chunk-5WS67HMU.js';
4
5
  import { readFileSync, mkdtempSync, existsSync, mkdirSync, writeFileSync, globSync, statSync, watch, rmSync } from 'fs';
5
6
  import { resolve, sep, relative, join, dirname, basename, matchesGlob, posix } from 'path';
6
7
  import { parse } from '@babel/parser';
7
8
  import { execFile } from 'child_process';
8
9
  import { fileURLToPath } from 'url';
10
+ import { createHash } from 'crypto';
11
+ import { stableStringify } from '@weasel-js/core/json';
9
12
  import { tmpdir } from 'os';
10
13
 
11
14
  // src/vite/html.ts
@@ -81,7 +84,7 @@ function createDepGraph({ root, read }) {
81
84
  reset() {
82
85
  resolutions.clear();
83
86
  },
84
- async build(entries, resolve5) {
87
+ async build(entries, resolve6) {
85
88
  const resolveFrom = (spec, importer) => {
86
89
  let byImporter = resolutions.get(importer);
87
90
  if (!byImporter) {
@@ -90,7 +93,7 @@ function createDepGraph({ root, read }) {
90
93
  }
91
94
  let pending = byImporter.get(spec);
92
95
  if (!pending) {
93
- pending = resolve5(spec, importer).then(
96
+ pending = resolve6(spec, importer).then(
94
97
  (id) => {
95
98
  const file = id?.split("?")[0];
96
99
  return file && !file.startsWith("\0") && SCRIPT.test(file) && !file.split(/[\\/]/).includes("node_modules") ? file : null;
@@ -853,13 +856,13 @@ function createTracer(read) {
853
856
  }
854
857
  return modules.get(file) ?? null;
855
858
  };
856
- return (resolve5) => {
859
+ return (resolve6) => {
857
860
  const trace = {
858
861
  reads: /* @__PURE__ */ new Set(),
859
862
  unresolved: false,
860
863
  info,
861
864
  async target(spec, importer) {
862
- const id = (await resolve5(spec, importer).catch(() => null))?.split("?")[0];
865
+ const id = (await resolve6(spec, importer).catch(() => null))?.split("?")[0];
863
866
  if (!id) trace.unresolved = true;
864
867
  return id && !id.startsWith("\0") ? id : null;
865
868
  },
@@ -901,8 +904,8 @@ function sameResolution(a, b) {
901
904
  }
902
905
  function createWrapperResolver(read) {
903
906
  const tracer = createTracer(read);
904
- return async (file, callees, resolve5) => {
905
- const trace = tracer(resolve5);
907
+ return async (file, callees, resolve6) => {
908
+ const trace = tracer(resolve6);
906
909
  const wrappers = /* @__PURE__ */ new Set();
907
910
  const direct = [...trace.info(file)?.imports.values() ?? []].some((imp) => imp.spec === "@weasel-js/forge");
908
911
  if (callees.length === 0 && !direct) return { wrappers, foreign: NO_FOREIGN, reads: trace.reads, unresolved: false };
@@ -929,8 +932,8 @@ function indexStory(code, file, source, resolved) {
929
932
  const foreign = resolved ? foreignFrom(resolved.foreign) : void 0;
930
933
  return indexFile(code, file, autoTitle(file, source.root, source.stories), resolved?.wrappers, foreign);
931
934
  }
932
- async function indexStoryResolved(code, file, source, resolve5, decide = createWrapperResolver((path) => readFileSync(path, "utf8"))) {
933
- const resolution = await decide(file, foreignCallees(code, file), resolve5);
935
+ async function indexStoryResolved(code, file, source, resolve6, decide = createWrapperResolver((path) => readFileSync(path, "utf8"))) {
936
+ const resolution = await decide(file, foreignCallees(code, file), resolve6);
934
937
  return { entries: indexStory(code, file, source, resolution), resolution };
935
938
  }
936
939
  function ownEntries(realms) {
@@ -945,56 +948,6 @@ function ownEntries(realms) {
945
948
  }
946
949
  return [];
947
950
  }
948
-
949
- // src/vite/entries.ts
950
- function shellEntry(base, stories) {
951
- return `import { mountWorkshop } from '@weasel-js/forge/shell';
952
- import '@weasel-js/labkit/styles.css';
953
- import '@weasel-js/forge/shell.css';
954
- import index from 'virtual:forge/index.js';
955
- import importers from 'virtual:forge/importers.js';
956
- import config from 'virtual:forge/shell-config.js';
957
- import setup from 'virtual:forge/frame-config.js';
958
-
959
- const workshop = mountWorkshop({
960
- index,
961
- importers,
962
- setup,
963
- config,
964
- frameUrl: ${JSON.stringify(`${base}frame.html`)},
965
- stories: ${JSON.stringify(stories)},
966
- });
967
- import.meta.hot?.on('forge:index', (next) => workshop.setIndex(next));
968
- // Read from source on first request, so the graph costs nothing until the page is up.
969
- import('virtual:forge/deps.js').then((m) => workshop.setDependencies(m.default));
970
- import.meta.hot?.on('forge:deps', (next) => workshop.setDependencies(next));
971
- // An edit to a story file, or to a module one imports, reaches here as an update of the importers module, whose
972
- // fresh import() URLs carry the new timestamps; the files it reaches arrive just before, so the reload waits for
973
- // the importers that can fetch them.
974
- const stale = new Set();
975
- import.meta.hot?.on('forge:story', ({ file }) => stale.add(file));
976
- import.meta.hot?.accept('virtual:forge/importers.js', (next) => {
977
- if (!next) return;
978
- workshop.setImporters(next.default);
979
- for (const file of stale) workshop.reloadStory(file);
980
- stale.clear();
981
- });
982
- `;
983
- }
984
- function frameEntry(styles) {
985
- return `import { mountFrame } from '@weasel-js/forge/frame';
986
- import '@weasel-js/forge/frame.css';
987
- ${styles.map((path) => `import ${JSON.stringify(path)};
988
- `).join("")}import index from 'virtual:forge/index.js';
989
- import importers from 'virtual:forge/importers.js';
990
- import setup from 'virtual:forge/frame-config.js';
991
-
992
- mountFrame({ index, importers, setup });
993
- // The importers module is shared with the workshop page, which accepts a story edit in place; a frame document
994
- // showing the old module reloads instead, and without this the edit would dead-end here and reload every page.
995
- import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
996
- `;
997
- }
998
951
  var PLATFORM = /[\\/]node_modules[\\/](@types[\\/]react(-dom)?|typescript)[\\/]/;
999
952
  async function createPropReader(root, stories, { skip } = {}) {
1000
953
  const skipped = (file) => PLATFORM.test(file) || skip?.(file) === true;
@@ -1070,36 +1023,89 @@ function componentProps(tsm, checker, source) {
1070
1023
  }
1071
1024
  function propTable(tsm, checker, props, at, skipped) {
1072
1025
  const argTypes = {};
1073
- const sources = /* @__PURE__ */ new Set();
1026
+ const reading = { tsm, checker, at, skipped, sources: /* @__PURE__ */ new Set() };
1074
1027
  for (const prop2 of checker.getPropertiesOfType(checker.getApparentType(props))) {
1075
- const files = (prop2.getDeclarations() ?? []).map((declaration) => declaration.getSourceFile().fileName);
1028
+ const files = declaredIn(prop2);
1076
1029
  if (files.length > 0 && files.every(skipped)) continue;
1077
1030
  const argType = controlFor(
1078
- tsm,
1031
+ reading,
1079
1032
  checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(prop2, at)),
1080
1033
  () => writtenOrder(tsm, checker, prop2)
1081
1034
  );
1082
1035
  if (!argType) continue;
1083
1036
  const description = tsm.displayPartsToString(prop2.getDocumentationComment(checker)).replace(/\s+/g, " ").trim();
1084
1037
  argTypes[prop2.getName()] = description ? { ...argType, description } : argType;
1085
- for (const file of files) if (!/[\\/]node_modules[\\/]/.test(file)) sources.add(file);
1038
+ record(reading, files);
1086
1039
  }
1087
- return { argTypes, sources: [...sources] };
1040
+ return { argTypes, sources: [...reading.sources] };
1088
1041
  }
1089
- function controlFor(tsm, type, written) {
1090
- const { TypeFlags } = tsm;
1042
+ var declaredIn = (symbol) => (symbol.getDeclarations() ?? []).map((declaration) => declaration.getSourceFile().fileName);
1043
+ var IN_PACKAGES = /[\\/]node_modules[\\/]/;
1044
+ function record({ sources }, files) {
1045
+ for (const file of files) if (!IN_PACKAGES.test(file)) sources.add(file);
1046
+ }
1047
+ function controlFor(reading, type, written) {
1048
+ const { TypeFlags } = reading.tsm;
1091
1049
  const parts = type.isUnion() ? type.types : [type];
1092
- const every = (flags) => parts.every((part) => part.flags & flags);
1093
- if (every(TypeFlags.BooleanLike)) return { control: { type: "boolean" } };
1094
- if (type.flags & TypeFlags.String) return { control: { type: "text" } };
1050
+ if (parts.every((part) => part.flags & TypeFlags.BooleanLike)) return { control: { type: "boolean" } };
1051
+ if (parts.some((part) => part.flags & TypeFlags.String)) return { control: { type: "text" } };
1095
1052
  if (type.flags & TypeFlags.Number) return { control: { type: "number" } };
1096
- if (parts.length > 1 && every(TypeFlags.StringLiteral)) {
1097
- const options = parts.map((part) => part.value);
1053
+ const options = literalsOf(reading, parts);
1054
+ if (options.length > 1) {
1098
1055
  const order = written();
1099
1056
  const same = order?.length === options.length && options.every((option) => order.includes(option));
1100
1057
  return { options: same ? order : options };
1101
1058
  }
1102
- return null;
1059
+ const shape = shapeOf(reading, type, 0);
1060
+ return shape && shape.name !== "enum" && !(shape.name in SCALARS) ? { control: "object", type: shape } : null;
1061
+ }
1062
+ var SCALARS = { boolean: true, string: true, number: true };
1063
+ function literalsOf({ tsm }, parts) {
1064
+ return parts.filter((part) => part.flags & tsm.TypeFlags.StringLiteral).map((part) => part.value);
1065
+ }
1066
+ var MAX_DEPTH2 = 4;
1067
+ function shapeOf(reading, type, depth) {
1068
+ const { tsm, checker } = reading;
1069
+ const { TypeFlags } = tsm;
1070
+ if (depth > MAX_DEPTH2) return null;
1071
+ const whole = checker.getNonNullableType(type);
1072
+ const parts = whole.isUnion() ? whole.types : [whole];
1073
+ if (parts.length === 0) return null;
1074
+ if (parts.every((part) => part.flags & TypeFlags.BooleanLike)) return { name: "boolean" };
1075
+ if (parts.some((part) => part.flags & TypeFlags.String)) return { name: "string" };
1076
+ if (whole.flags & TypeFlags.Number) return { name: "number" };
1077
+ const literals = literalsOf(reading, parts);
1078
+ if (literals.length === parts.length || literals.length > 1) return { name: "enum", value: literals };
1079
+ if (parts.length > 1) {
1080
+ const members = parts.map((part) => shapeOf(reading, part, depth + 1));
1081
+ return members.every((member) => member?.name === "object") ? { name: "union", value: members } : null;
1082
+ }
1083
+ if (checker.isArrayType(whole)) {
1084
+ const [item] = checker.getTypeArguments(whole);
1085
+ const shape = item && shapeOf(reading, item, depth + 1);
1086
+ return shape ? { name: "array", value: shape } : null;
1087
+ }
1088
+ if (checker.isTupleType(whole)) {
1089
+ const items = checker.getTypeArguments(whole).map((item) => shapeOf(reading, item, depth + 1));
1090
+ const [first] = items;
1091
+ const same = first && items.every((item) => JSON.stringify(item) === JSON.stringify(first));
1092
+ return same ? { name: "array", value: first } : null;
1093
+ }
1094
+ if (!(whole.flags & (TypeFlags.Object | TypeFlags.Intersection))) return null;
1095
+ if (whole.getCallSignatures().length > 0 || whole.getConstructSignatures().length > 0) return null;
1096
+ if (checker.getIndexInfosOfType(whole).length > 0) return null;
1097
+ const declared = (whole.getSymbol() ?? whole.aliasSymbol)?.getDeclarations() ?? [];
1098
+ if (declared.some((declaration) => IN_PACKAGES.test(declaration.getSourceFile().fileName))) return null;
1099
+ const fields = {};
1100
+ for (const prop2 of checker.getPropertiesOfType(whole)) {
1101
+ const files = declaredIn(prop2);
1102
+ if (files.some((file) => IN_PACKAGES.test(file) || reading.skipped(file))) return null;
1103
+ const field = shapeOf(reading, checker.getTypeOfSymbolAtLocation(prop2, reading.at), depth + 1);
1104
+ if (field) fields[prop2.getName()] = field;
1105
+ else if (!(prop2.flags & tsm.SymbolFlags.Optional)) return null;
1106
+ record(reading, files);
1107
+ }
1108
+ return Object.keys(fields).length > 0 ? { name: "object", value: fields } : null;
1103
1109
  }
1104
1110
  function writtenOrder(tsm, checker, prop2) {
1105
1111
  const [declaration, ...others] = prop2.getDeclarations() ?? [];
@@ -1126,6 +1132,7 @@ function writtenOrder(tsm, checker, prop2) {
1126
1132
  if (alias && rest.length === 0 && tsm.isTypeAliasDeclaration(alias) && !alias.typeParameters) {
1127
1133
  return literals(alias.type);
1128
1134
  }
1135
+ if (target && target.flags & (tsm.SymbolFlags.Interface | tsm.SymbolFlags.Class)) return [];
1129
1136
  }
1130
1137
  return null;
1131
1138
  };
@@ -1159,10 +1166,339 @@ export const ${PROP_ARG_TYPES_EXPORT} = ${JSON.stringify(table.argTypes)};
1159
1166
  }
1160
1167
  };
1161
1168
  }
1169
+ var IDLE_CLOSE_MS = 12e4;
1170
+ var SHOT_DEADLINE_MS = 2e4;
1171
+ var QUEUE_CAP = 8;
1172
+ var QUEUE_FULL = "capture queue is full";
1173
+ var INSTALL_PLAYWRIGHT = "install playwright";
1174
+ var INSTALL_CHROMIUM = "install Chromium for Playwright: npx playwright install chromium";
1175
+ var loadPlaywright = () => import('playwright');
1176
+ async function captureAvailability(load = loadPlaywright) {
1177
+ let playwright;
1178
+ try {
1179
+ playwright = await load();
1180
+ } catch {
1181
+ return { unavailable: INSTALL_PLAYWRIGHT };
1182
+ }
1183
+ let executable;
1184
+ try {
1185
+ executable = playwright.chromium.executablePath();
1186
+ } catch (err) {
1187
+ return { unavailable: messageOf(err) };
1188
+ }
1189
+ return existsSync(executable) ? { available: true } : { unavailable: INSTALL_CHROMIUM };
1190
+ }
1191
+ var WIRE_HEADERS = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding"]);
1192
+ async function passThrough(route, fetchImpl) {
1193
+ const request = route.request();
1194
+ const method = request.method();
1195
+ if (method !== "GET" && method !== "HEAD") return route.abort();
1196
+ try {
1197
+ const response = await fetchImpl(request.url(), { method, headers: request.headers(), redirect: "manual" });
1198
+ await route.fulfill({
1199
+ status: response.status,
1200
+ headers: Object.fromEntries([...response.headers].filter(([name]) => !WIRE_HEADERS.has(name))),
1201
+ body: Buffer.from(await response.arrayBuffer())
1202
+ });
1203
+ } catch {
1204
+ await route.abort();
1205
+ }
1206
+ }
1207
+ async function guard(page, url, allow, fetchImpl) {
1208
+ const own = new URL(url);
1209
+ const secure = own.protocol === "https:";
1210
+ const reach = new Set(allow);
1211
+ await page.route("**/*", (route) => {
1212
+ const origin = new URL(route.request().url()).origin;
1213
+ if (origin === own.origin) return route.continue();
1214
+ if (!reach.has(origin)) return route.abort();
1215
+ return secure ? passThrough(route, fetchImpl) : route.continue();
1216
+ });
1217
+ const ownSocket = `${secure ? "wss:" : "ws:"}//${own.host}`;
1218
+ await page.routeWebSocket(
1219
+ (u) => `${u.protocol}//${u.host}` !== ownSocket,
1220
+ (ws) => ws.close()
1221
+ );
1222
+ }
1223
+ async function launchCaptureBrowser(load = loadPlaywright, fetchImpl = fetch) {
1224
+ let playwright;
1225
+ try {
1226
+ playwright = await load();
1227
+ } catch {
1228
+ return { unavailable: INSTALL_PLAYWRIGHT };
1229
+ }
1230
+ let chromium;
1231
+ try {
1232
+ chromium = Promise.resolve(await playwright.chromium.launch({ headless: true }));
1233
+ } catch (err) {
1234
+ return { unavailable: messageOf(err) };
1235
+ }
1236
+ const contexts = /* @__PURE__ */ new Map();
1237
+ let idle;
1238
+ let outstanding = 0;
1239
+ let tail = Promise.resolve();
1240
+ const shut = async () => {
1241
+ clearTimeout(idle);
1242
+ const closing = chromium;
1243
+ chromium = null;
1244
+ contexts.clear();
1245
+ await closing?.then((b) => b.close(), () => {
1246
+ });
1247
+ };
1248
+ const arm = () => {
1249
+ clearTimeout(idle);
1250
+ idle = setTimeout(() => void shut(), IDLE_CLOSE_MS);
1251
+ idle.unref?.();
1252
+ };
1253
+ const browser = () => {
1254
+ if (!chromium) {
1255
+ const launching = playwright.chromium.launch({ headless: true });
1256
+ chromium = launching;
1257
+ launching.catch(() => {
1258
+ if (chromium === launching) chromium = null;
1259
+ });
1260
+ }
1261
+ return chromium;
1262
+ };
1263
+ const contextFor = (dpr, secure) => {
1264
+ const key = `${dpr}:${secure}`;
1265
+ let context = contexts.get(key);
1266
+ if (!context) {
1267
+ const making = browser().then(
1268
+ (b) => b.newContext({ deviceScaleFactor: dpr, ignoreHTTPSErrors: secure, serviceWorkers: "block" })
1269
+ );
1270
+ context = making;
1271
+ contexts.set(key, making);
1272
+ making.catch(() => {
1273
+ if (contexts.get(key) === making) contexts.delete(key);
1274
+ });
1275
+ }
1276
+ return context;
1277
+ };
1278
+ const render = async (page, payload, url, allow) => {
1279
+ await guard(page, url, allow, fetchImpl);
1280
+ await page.setViewportSize({ width: Math.ceil(payload.box.width), height: Math.ceil(payload.box.height) });
1281
+ await page.emulateMedia({ colorScheme: payload.colorScheme });
1282
+ await page.goto(url);
1283
+ await page.waitForFunction(() => window.__forgeCapture !== void 0);
1284
+ await page.evaluate((p) => window.__forgeCapture.render(p), payload);
1285
+ return page.locator("[data-fg-capture]").screenshot({ type: "png" });
1286
+ };
1287
+ const shoot = async (url, payload, allow) => {
1288
+ let page = null;
1289
+ let late = false;
1290
+ let timer;
1291
+ const deadline = new Promise((_, reject) => {
1292
+ timer = setTimeout(() => reject(new Error(`capture took longer than ${SHOT_DEADLINE_MS / 1e3} s`)), SHOT_DEADLINE_MS);
1293
+ });
1294
+ const work = (async () => {
1295
+ const opened = await (await contextFor(payload.dpr, url.startsWith("https:"))).newPage();
1296
+ page = opened;
1297
+ if (late) throw new Error("capture timed out");
1298
+ return render(opened, payload, url, allow);
1299
+ })();
1300
+ try {
1301
+ return await Promise.race([work, deadline]);
1302
+ } finally {
1303
+ late = true;
1304
+ clearTimeout(timer);
1305
+ await page?.close().catch(() => {
1306
+ });
1307
+ void work.catch(() => page?.close().catch(() => {
1308
+ }));
1309
+ }
1310
+ };
1311
+ arm();
1312
+ return {
1313
+ capture(url, payload, allow = []) {
1314
+ if (outstanding >= QUEUE_CAP) return Promise.reject(new Error(QUEUE_FULL));
1315
+ outstanding += 1;
1316
+ clearTimeout(idle);
1317
+ const run = tail.then(() => shoot(url, payload, allow));
1318
+ tail = run.catch(() => {
1319
+ });
1320
+ return run.finally(() => {
1321
+ outstanding -= 1;
1322
+ if (outstanding === 0) arm();
1323
+ });
1324
+ },
1325
+ close: shut
1326
+ };
1327
+ }
1328
+
1329
+ // src/vite/capture/cache.ts
1330
+ function createCaptureCache(limits = { entries: 64, bytes: 64 * 1024 * 1024 }) {
1331
+ const held = /* @__PURE__ */ new Map();
1332
+ let bytes = 0;
1333
+ const drop = (key) => {
1334
+ const png = held.get(key);
1335
+ if (!png) return;
1336
+ held.delete(key);
1337
+ bytes -= png.length;
1338
+ };
1339
+ return {
1340
+ get(key) {
1341
+ const png = held.get(key);
1342
+ if (!png) return void 0;
1343
+ held.delete(key);
1344
+ held.set(key, png);
1345
+ return png;
1346
+ },
1347
+ set(key, png) {
1348
+ drop(key);
1349
+ if (png.length > limits.bytes) return;
1350
+ held.set(key, png);
1351
+ bytes += png.length;
1352
+ for (const oldest of held.keys()) {
1353
+ if (held.size <= limits.entries && bytes <= limits.bytes) break;
1354
+ drop(oldest);
1355
+ }
1356
+ },
1357
+ clear() {
1358
+ held.clear();
1359
+ bytes = 0;
1360
+ },
1361
+ get size() {
1362
+ return held.size;
1363
+ },
1364
+ get bytes() {
1365
+ return bytes;
1366
+ }
1367
+ };
1368
+ }
1369
+
1370
+ // src/vite/capture/endpoint.ts
1371
+ var MAX_BODY = 16 * 1024 * 1024;
1372
+ var keyOf2 = (payload) => createHash("sha1").update(stableStringify(payload)).digest("hex");
1373
+ var urlHost = (address) => {
1374
+ const v4 = address.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/, "");
1375
+ return v4.includes(":") ? `[${v4.replace("%", "%25")}]` : v4;
1376
+ };
1377
+ var socketOrigin = (req, protocol) => `${protocol}://${urlHost(req.socket.localAddress ?? "127.0.0.1")}:${req.socket.localPort}`;
1378
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
1379
+ function ownOrigin(origin, req, protocol, server) {
1380
+ let url;
1381
+ try {
1382
+ url = new URL(origin);
1383
+ } catch {
1384
+ return false;
1385
+ }
1386
+ if (url.origin === socketOrigin(req, protocol)) return true;
1387
+ if (url.protocol === `${protocol}:` && LOOPBACK.has(url.hostname) && url.port === String(req.socket.localPort)) return true;
1388
+ const printed = [...server.resolvedUrls?.local ?? [], ...server.resolvedUrls?.network ?? []];
1389
+ return printed.some((u) => new URL(u).origin === url.origin);
1390
+ }
1391
+ var json = (res, status, body2) => {
1392
+ res.statusCode = status;
1393
+ res.setHeader("Content-Type", "application/json");
1394
+ res.end(JSON.stringify(body2));
1395
+ };
1396
+ function readBody(req) {
1397
+ return new Promise((resolve6, reject) => {
1398
+ const chunks = [];
1399
+ let size = 0;
1400
+ req.on("data", (chunk) => {
1401
+ size += chunk.length;
1402
+ if (size <= MAX_BODY) return void chunks.push(chunk);
1403
+ req.pause();
1404
+ req.removeAllListeners("data");
1405
+ resolve6(null);
1406
+ });
1407
+ req.on("end", () => resolve6(Buffer.concat(chunks).toString("utf8")));
1408
+ req.on("error", reject);
1409
+ });
1410
+ }
1411
+ function serveCapture(server, base, allowOrigins = () => []) {
1412
+ const cache = createCaptureCache();
1413
+ let launched = null;
1414
+ let revision = 0;
1415
+ const browser = () => {
1416
+ if (!launched) {
1417
+ const launching = launchCaptureBrowser();
1418
+ launched = launching;
1419
+ void launching.then((result) => {
1420
+ if ("unavailable" in result && launched === launching) launched = null;
1421
+ });
1422
+ }
1423
+ return launched;
1424
+ };
1425
+ const protocol = server.config.server.https ? "https" : "http";
1426
+ const capture = async (req, res) => {
1427
+ const origin = req.headers.origin;
1428
+ if (origin !== void 0 && !ownOrigin(origin, req, protocol, server)) {
1429
+ return json(res, 403, { error: "a capture is only taken for this server\u2019s own pages" });
1430
+ }
1431
+ const type = (req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
1432
+ if (type !== "application/json") return json(res, 415, { error: "a capture payload is sent as application/json" });
1433
+ const body2 = await readBody(req);
1434
+ if (body2 === null) {
1435
+ res.setHeader("Connection", "close");
1436
+ json(res, 413, { error: "capture payload is too large" });
1437
+ return void res.once("finish", () => req.destroy());
1438
+ }
1439
+ let parsed;
1440
+ try {
1441
+ parsed = JSON.parse(body2);
1442
+ } catch {
1443
+ return json(res, 400, { error: "capture payload is not JSON" });
1444
+ }
1445
+ const payload = parsePayload(parsed);
1446
+ if ("error" in payload) return json(res, 400, payload);
1447
+ const key = keyOf2(payload);
1448
+ let png = cache.get(key);
1449
+ if (!png) {
1450
+ const result = await browser();
1451
+ if ("unavailable" in result) return json(res, 503, { available: false, reason: result.unavailable });
1452
+ const page = `${socketOrigin(req, protocol)}${base}${payload.isolate ? CAPTURE_PAGES.frame : CAPTURE_PAGES.shell}`;
1453
+ const at = revision;
1454
+ try {
1455
+ png = await result.capture(page, payload, allowOrigins());
1456
+ } catch (err) {
1457
+ const message = messageOf(err);
1458
+ if (message === QUEUE_FULL) return json(res, 503, { available: false, reason: QUEUE_FULL });
1459
+ return json(res, 500, { error: message });
1460
+ }
1461
+ if (at === revision) cache.set(key, png);
1462
+ }
1463
+ res.statusCode = 200;
1464
+ res.setHeader("Content-Type", "image/png");
1465
+ res.end(png);
1466
+ };
1467
+ const status = async (res) => {
1468
+ const result = await captureAvailability();
1469
+ json(res, 200, "unavailable" in result ? { available: false, reason: result.unavailable } : { available: true });
1470
+ };
1471
+ server.middlewares.use((req, res, next) => {
1472
+ if (req.url?.split("?")[0] !== `${base}${CAPTURE_PATH}`) return next();
1473
+ if (req.method === "GET") return void status(res).catch(next);
1474
+ if (req.method === "POST") return void capture(req, res).catch(next);
1475
+ next();
1476
+ });
1477
+ return {
1478
+ revise() {
1479
+ revision += 1;
1480
+ cache.clear();
1481
+ },
1482
+ async close() {
1483
+ revision += 1;
1484
+ cache.clear();
1485
+ const closing = launched;
1486
+ launched = null;
1487
+ const result = await closing;
1488
+ if (result && !("unavailable" in result)) await result.close();
1489
+ }
1490
+ };
1491
+ }
1162
1492
 
1163
1493
  // src/vite/servePages.ts
1164
- var PAGES2 = { "/": "shell-entry.js", "/index.html": "shell-entry.js", "/frame.html": "frame-entry.js" };
1165
- function servePages(server, base, title) {
1494
+ var PAGES2 = {
1495
+ "/": "shell-entry.js",
1496
+ "/index.html": "shell-entry.js",
1497
+ "/frame.html": "frame-entry.js",
1498
+ [`/${CAPTURE_PAGES.shell}`]: "capture-entry.js",
1499
+ [`/${CAPTURE_PAGES.frame}`]: "capture-frame-entry.js"
1500
+ };
1501
+ function servePages(server, base, shell) {
1166
1502
  server.middlewares.use((req, res, next) => {
1167
1503
  const url = req.url ?? "/";
1168
1504
  const path = url.split("?")[0] ?? "/";
@@ -1170,7 +1506,7 @@ function servePages(server, base, title) {
1170
1506
  const page = path === prefix ? "/" : path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : null;
1171
1507
  const entry = req.method === "GET" && page !== null ? PAGES2[page] : void 0;
1172
1508
  if (!entry) return next();
1173
- server.transformIndexHtml(url, html(entry, "serve", title())).then(
1509
+ server.transformIndexHtml(url, html(entry, "serve", shell().title)).then(
1174
1510
  (doc) => {
1175
1511
  res.statusCode = 200;
1176
1512
  res.setHeader("Content-Type", "text/html");
@@ -1179,15 +1515,14 @@ function servePages(server, base, title) {
1179
1515
  (err) => next(err)
1180
1516
  );
1181
1517
  });
1518
+ return serveCapture(server, base, () => shell().allowOrigins);
1182
1519
  }
1183
-
1184
- // src/vite/shellTitle.ts
1185
- function shellTitle(code, file) {
1520
+ function configOf(code, file) {
1186
1521
  let program;
1187
1522
  try {
1188
1523
  program = parseFile(code, file);
1189
1524
  } catch {
1190
- return void 0;
1525
+ return null;
1191
1526
  }
1192
1527
  const bindings = /* @__PURE__ */ new Map();
1193
1528
  let config;
@@ -1202,7 +1537,46 @@ function shellTitle(code, file) {
1202
1537
  }
1203
1538
  }
1204
1539
  const wrapper = calleeOf(config, bindings);
1205
- return stringOf(prop(objectOf(config, bindings, new Set(wrapper ? [wrapper] : [])), "title"), bindings);
1540
+ return { config: objectOf(config, bindings, new Set(wrapper ? [wrapper] : [])), bindings };
1541
+ }
1542
+ function shellTitle(code, file) {
1543
+ const read = configOf(code, file);
1544
+ return read ? stringOf(prop(read.config, "title"), read.bindings) : void 0;
1545
+ }
1546
+ function arrayOf(node, bindings, depth = 0) {
1547
+ if (!node || depth > 16) return null;
1548
+ if (node.type === "ArrayExpression") return node;
1549
+ if (node.type === "Identifier") return arrayOf(bindings.get(node.name), bindings, depth + 1);
1550
+ if ("expression" in node && (node.type.startsWith("TS") || node.type === "ParenthesizedExpression")) {
1551
+ return arrayOf(node.expression, bindings, depth + 1);
1552
+ }
1553
+ return null;
1554
+ }
1555
+ var originOf = (url) => {
1556
+ if (url === void 0) return [];
1557
+ try {
1558
+ const { origin } = new URL(url);
1559
+ return origin === "null" ? [] : [origin];
1560
+ } catch {
1561
+ return [];
1562
+ }
1563
+ };
1564
+ function shellAllowOrigins(code, file) {
1565
+ const read = configOf(code, file);
1566
+ if (!read) return [];
1567
+ const capture = objectOf(prop(read.config, "capture"), read.bindings, /* @__PURE__ */ new Set());
1568
+ const list = arrayOf(prop(capture, "allowOrigins"), read.bindings);
1569
+ return (list?.elements ?? []).flatMap((el) => el && el.type !== "SpreadElement" ? originOf(stringOf(el, read.bindings)) : []);
1570
+ }
1571
+ function readShellFields(file) {
1572
+ let code;
1573
+ try {
1574
+ if (file === void 0) throw new Error("no shell config");
1575
+ code = readFileSync(file, "utf8");
1576
+ } catch {
1577
+ return { title: void 0, allowOrigins: [] };
1578
+ }
1579
+ return { title: shellTitle(code, file), allowOrigins: shellAllowOrigins(code, file) };
1206
1580
  }
1207
1581
  var SHIMS = [
1208
1582
  [/^@?storybook\/preview-api$/, "@weasel-js/forge/preview-api"],
@@ -1219,6 +1593,105 @@ function storybookShims() {
1219
1593
  }
1220
1594
  };
1221
1595
  }
1596
+
1597
+ // src/vite/capture/entries.ts
1598
+ var body = (mount) => `import index from 'virtual:forge/index.js';
1599
+ import importers from 'virtual:forge/importers.js';
1600
+ import setup from 'virtual:forge/frame-config.js';
1601
+
1602
+ ${mount}
1603
+ // The endpoint drops its captures on any edit; a page loaded before one would render the old module.
1604
+ import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
1605
+ `;
1606
+ function captureEntry() {
1607
+ return `import { mountCapture } from '@weasel-js/forge/frame';
1608
+ import '@weasel-js/labkit/styles.css';
1609
+ import '@weasel-js/forge/shell.css';
1610
+ import config from 'virtual:forge/shell-config.js';
1611
+ ${body("mountCapture({ index, importers, setup, labTheme: config.labTheme });")}`;
1612
+ }
1613
+ function captureFrameEntry(styles) {
1614
+ return `import { mountCapture } from '@weasel-js/forge/frame';
1615
+ import '@weasel-js/forge/frame.css';
1616
+ ${styles.map((path) => `import ${JSON.stringify(path)};
1617
+ `).join("")}${body("mountCapture({ index, importers, setup });")}`;
1618
+ }
1619
+
1620
+ // src/vite/entries.ts
1621
+ function shellEntry(base, stories) {
1622
+ return `import { followEdits, mountWorkshop } from '@weasel-js/forge/shell';
1623
+ import '@weasel-js/labkit/styles.css';
1624
+ import '@weasel-js/forge/shell.css';
1625
+ import index from 'virtual:forge/index.js';
1626
+ import importers from 'virtual:forge/importers.js';
1627
+ import config from 'virtual:forge/shell-config.js';
1628
+ import setup from 'virtual:forge/frame-config.js';
1629
+
1630
+ const workshop = mountWorkshop({
1631
+ index,
1632
+ importers,
1633
+ setup,
1634
+ config,
1635
+ frameUrl: ${JSON.stringify(`${base}frame.html`)},
1636
+ captureBase: ${JSON.stringify(base)},
1637
+ stories: ${JSON.stringify(stories)},
1638
+ });
1639
+ // Read from source on first request, so the graph costs nothing until the page is up.
1640
+ import('virtual:forge/deps.js').then((m) => workshop.setDependencies(m.default));
1641
+ const edits = followEdits(import.meta.hot, workshop);
1642
+ import.meta.hot?.accept('virtual:forge/importers.js', (next) => edits.importers(next?.default));
1643
+ `;
1644
+ }
1645
+ function frameEntry(styles) {
1646
+ return `import { mountFrame } from '@weasel-js/forge/frame';
1647
+ import '@weasel-js/forge/frame.css';
1648
+ ${styles.map((path) => `import ${JSON.stringify(path)};
1649
+ `).join("")}import index from 'virtual:forge/index.js';
1650
+ import importers from 'virtual:forge/importers.js';
1651
+ import setup from 'virtual:forge/frame-config.js';
1652
+
1653
+ mountFrame({ index, importers, setup });
1654
+ // The importers module is shared with the workshop page, which accepts a story edit in place; a frame document
1655
+ // showing the old module reloads instead, and without this the edit would dead-end here and reload every page.
1656
+ import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
1657
+ `;
1658
+ }
1659
+
1660
+ // src/vite/virtualModules.ts
1661
+ var PREFIX = "virtual:forge/";
1662
+ var MODULES = /* @__PURE__ */ new Set([
1663
+ "index.js",
1664
+ "importers.js",
1665
+ "deps.js",
1666
+ "frame-config.js",
1667
+ "shell-config.js",
1668
+ "shell-entry.js",
1669
+ "frame-entry.js",
1670
+ "capture-entry.js",
1671
+ "capture-frame-entry.js"
1672
+ ]);
1673
+ function virtualModules(options, src) {
1674
+ const configModule = (path) => path ? `export { default } from ${JSON.stringify(resolve(src.root(), path))};
1675
+ ` : "export default {};\n";
1676
+ const frameStyles = () => (options.frameStyles ?? []).map((path) => resolve(src.root(), path));
1677
+ return {
1678
+ "index.js": () => `export default ${JSON.stringify(src.index())};
1679
+ `,
1680
+ "importers.js": () => {
1681
+ const lines = [...src.files()].filter(([, entries]) => entries.length > 0).map(([file]) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(file)}),`);
1682
+ return `export default {
1683
+ ${lines.join("\n")}
1684
+ };
1685
+ `;
1686
+ },
1687
+ "frame-config.js": () => configModule(options.frameConfig),
1688
+ "shell-config.js": () => configModule(options.shellConfig),
1689
+ "shell-entry.js": () => shellEntry(src.base(), options.stories),
1690
+ "frame-entry.js": () => frameEntry(frameStyles()),
1691
+ "capture-entry.js": () => captureEntry(),
1692
+ "capture-frame-entry.js": () => captureFrameEntry(frameStyles())
1693
+ };
1694
+ }
1222
1695
  var WAIT_MS = 5e3;
1223
1696
  var REWRITE_MS = 250;
1224
1697
  var watchDir = (dir, onEvent) => {
@@ -1230,7 +1703,7 @@ var watchDir = (dir, onEvent) => {
1230
1703
  };
1231
1704
  function fileEventsArrive({ waitMs = WAIT_MS, watch = watchDir } = {}) {
1232
1705
  const dir = mkdtempSync(join(tmpdir(), "forge-watch-"));
1233
- return new Promise((resolve5) => {
1706
+ return new Promise((resolve6) => {
1234
1707
  let settled2 = false;
1235
1708
  let writes = 0;
1236
1709
  let stop;
@@ -1243,7 +1716,7 @@ function fileEventsArrive({ waitMs = WAIT_MS, watch = watchDir } = {}) {
1243
1716
  clearTimeout(deadline);
1244
1717
  stop?.();
1245
1718
  rmSync(dir, { recursive: true, force: true });
1246
- resolve5(arrived);
1719
+ resolve6(arrived);
1247
1720
  };
1248
1721
  const write = () => writeFileSync(join(dir, "probe"), String(writes++));
1249
1722
  try {
@@ -1276,8 +1749,6 @@ function watchProbe({ probe } = {}) {
1276
1749
  }
1277
1750
 
1278
1751
  // src/vite/plugin.ts
1279
- var PREFIX = "virtual:forge/";
1280
- var MODULES = /* @__PURE__ */ new Set(["index.js", "importers.js", "deps.js", "frame-config.js", "shell-config.js", "shell-entry.js", "frame-entry.js"]);
1281
1752
  var SCRIPT2 = /\.[cm]?[jt]sx?$/;
1282
1753
  function forge(options) {
1283
1754
  let root = process.cwd();
@@ -1314,7 +1785,7 @@ function forge(options) {
1314
1785
  );
1315
1786
  return byFile;
1316
1787
  };
1317
- const index = () => [...files().values()].flat();
1788
+ const index = () => byComponent([...files().values()].flat());
1318
1789
  const settle = async (targets, resolveImport) => {
1319
1790
  const decide = createWrapperResolver((file) => readFileSync(file, "utf8"));
1320
1791
  const current = files();
@@ -1348,32 +1819,9 @@ function forge(options) {
1348
1819
  }
1349
1820
  return graph;
1350
1821
  }
1351
- const titleUnder = (at) => {
1352
- if (!options.shellConfig) return void 0;
1353
- const file = resolve(at, options.shellConfig);
1354
- try {
1355
- return shellTitle(readFileSync(file, "utf8"), file);
1356
- } catch {
1357
- return void 0;
1358
- }
1359
- };
1360
- const configModule = (path) => path ? `export { default } from ${JSON.stringify(resolve(root, path))};
1361
- ` : "export default {};\n";
1362
- const modules = {
1363
- "index.js": () => `export default ${JSON.stringify(index())};
1364
- `,
1365
- "importers.js": () => {
1366
- const lines = [...files()].filter(([, entries]) => entries.length > 0).map(([file]) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(file)}),`);
1367
- return `export default {
1368
- ${lines.join("\n")}
1369
- };
1370
- `;
1371
- },
1372
- "frame-config.js": () => configModule(options.frameConfig),
1373
- "shell-config.js": () => configModule(options.shellConfig),
1374
- "shell-entry.js": () => shellEntry(base, options.stories),
1375
- "frame-entry.js": () => frameEntry((options.frameStyles ?? []).map((path) => resolve(root, path)))
1376
- };
1822
+ const shellUnder = (at) => readShellFields(options.shellConfig ? resolve(at, options.shellConfig) : void 0);
1823
+ const modules = virtualModules(options, { index, files, root: () => root, base: () => base });
1824
+ let capture = null;
1377
1825
  const invalidate = (server, names) => {
1378
1826
  for (const env of Object.values(server.environments)) {
1379
1827
  for (const name of names) {
@@ -1384,12 +1832,12 @@ ${lines.join("\n")}
1384
1832
  };
1385
1833
  function redeps(server) {
1386
1834
  if (depsSent === null || depsPending) return;
1387
- const resolve5 = async (spec, importer) => (await server.environments.client.pluginContainer.resolveId(spec, importer))?.id ?? null;
1835
+ const resolve6 = async (spec, importer) => (await server.environments.client.pluginContainer.resolveId(spec, importer))?.id ?? null;
1388
1836
  depsPending = Promise.resolve().then(async () => {
1389
- const graph = await graphOf(resolve5);
1390
- const json = JSON.stringify(graph);
1391
- if (json === depsSent) return;
1392
- depsSent = json;
1837
+ const graph = await graphOf(resolve6);
1838
+ const json2 = JSON.stringify(graph);
1839
+ if (json2 === depsSent) return;
1840
+ depsSent = json2;
1393
1841
  invalidate(server, ["deps.js"]);
1394
1842
  server.ws.send({ type: "custom", event: "forge:deps", data: graph });
1395
1843
  }).catch(logError).finally(() => {
@@ -1453,7 +1901,7 @@ ${lines.join("\n")}
1453
1901
  ];
1454
1902
  const optimizeDeps = { entries };
1455
1903
  if (command !== "build") return { optimizeDeps };
1456
- return { optimizeDeps, build: { rolldownOptions: { input: writePages(at, titleUnder(at)) } } };
1904
+ return { optimizeDeps, build: { rolldownOptions: { input: writePages(at, shellUnder(at).title) } } };
1457
1905
  },
1458
1906
  generateBundle: {
1459
1907
  order: "post",
@@ -1490,6 +1938,7 @@ ${lines.join("\n")}
1490
1938
  },
1491
1939
  hotUpdate({ modules: modules2, server }) {
1492
1940
  if (this.environment.name !== "client") return;
1941
+ capture?.revise();
1493
1942
  for (const file of storiesReaching(modules2)) server.ws.send({ type: "custom", event: "forge:story", data: { file } });
1494
1943
  },
1495
1944
  configureServer(server) {
@@ -1506,8 +1955,9 @@ ${lines.join("\n")}
1506
1955
  }, logError);
1507
1956
  });
1508
1957
  }
1509
- servePages(server, base, () => titleUnder(root));
1510
- }
1958
+ capture = servePages(server, base, () => shellUnder(root));
1959
+ },
1960
+ closeServer: () => capture?.close()
1511
1961
  }
1512
1962
  ];
1513
1963
  }