forgebook 0.2.0 → 0.3.1

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 (94) hide show
  1. package/README.md +113 -5
  2. package/dist/bin/forgebook/agentLoopCommands.mjs +3 -0
  3. package/dist/bin/forgebook/agentText.d.ts +2 -2
  4. package/dist/bin/forgebook/agentText.js +15 -13
  5. package/dist/bin/forgebook/agents.md +21 -6
  6. package/dist/bin/forgebook/mcpTools.js +1 -1
  7. package/dist/bin/forgebook/newStoriesFile.js +7 -2
  8. package/dist/bin/forgebook-docs/collectDocs.js +1 -1
  9. package/dist/src/argsProps.d.ts +32 -0
  10. package/dist/src/argsProps.js +69 -0
  11. package/dist/src/catalogue.d.ts +48 -5
  12. package/dist/src/catalogue.js +47 -8
  13. package/dist/src/frame/mountFrame.js +26 -7
  14. package/dist/src/frame/touchCaret.d.ts +43 -0
  15. package/dist/src/frame/touchCaret.js +195 -0
  16. package/dist/src/frame/touchEvents.d.ts +3 -2
  17. package/dist/src/frame/touchEvents.js +17 -18
  18. package/dist/src/frame/touchLayer.js +48 -7
  19. package/dist/src/propGroups.d.ts +66 -3
  20. package/dist/src/propGroups.js +67 -13
  21. package/dist/src/shell/ApiReference.d.ts +9 -4
  22. package/dist/src/shell/ApiReference.js +47 -39
  23. package/dist/src/shell/AudienceSections.js +1 -1
  24. package/dist/src/shell/ComponentPage.d.ts +5 -2
  25. package/dist/src/shell/ComponentPage.js +4 -18
  26. package/dist/src/shell/DocBlocks.js +1 -1
  27. package/dist/src/shell/PropRows.d.ts +42 -0
  28. package/dist/src/shell/PropRows.js +60 -0
  29. package/dist/src/shell/PropsTab.d.ts +14 -10
  30. package/dist/src/shell/PropsTab.js +18 -58
  31. package/dist/src/shell/RequiredFirstSwitch.d.ts +16 -0
  32. package/dist/src/shell/RequiredFirstSwitch.js +8 -0
  33. package/dist/src/shell/Stage.js +11 -23
  34. package/dist/src/shell/StoryCoverage.js +4 -1
  35. package/dist/src/shell/docPage.css +6 -0
  36. package/dist/src/shell/dockTabs.d.ts +7 -5
  37. package/dist/src/shell/dockTabs.js +4 -4
  38. package/dist/src/shell/reportedPanel.d.ts +12 -0
  39. package/dist/src/shell/reportedPanel.js +19 -0
  40. package/dist/src/shell/shell.css +56 -1
  41. package/dist/src/shell/strings.d.ts +2 -0
  42. package/dist/src/shell/strings.js +2 -0
  43. package/dist/src/shell/usePanelDocs.d.ts +11 -0
  44. package/dist/src/shell/usePanelDocs.js +29 -0
  45. package/dist/src/shell/useRequiredFirst.d.ts +27 -0
  46. package/dist/src/shell/useRequiredFirst.js +67 -0
  47. package/dist/src/storyCheck.d.ts +2 -0
  48. package/dist/src/storyCheck.js +2 -1
  49. package/dist/src/storyGlobals.js +2 -1
  50. package/dist/src/storyTree.d.ts +11 -1
  51. package/dist/src/storyTree.js +3 -1
  52. package/dist/src/viewState.d.ts +7 -0
  53. package/dist/src/viewState.js +13 -0
  54. package/dist/src/vite/agent.types.d.ts +12 -0
  55. package/dist/src/vite/agentApi.js +6 -1
  56. package/dist/src/vite/agentRoutes.js +18 -4
  57. package/dist/src/vite/builtTexts.d.ts +2 -2
  58. package/dist/src/vite/builtTexts.js +7 -6
  59. package/dist/src/vite/configFile.d.ts +3 -2
  60. package/dist/src/vite/configFile.js +63 -14
  61. package/dist/src/vite/docPagesModule.d.ts +10 -7
  62. package/dist/src/vite/docPagesModule.js +14 -11
  63. package/dist/src/vite/docsExtractor.d.ts +6 -0
  64. package/dist/src/vite/docsExtractor.js +13 -0
  65. package/dist/src/vite/fileAccess.d.ts +75 -0
  66. package/dist/src/vite/fileAccess.js +199 -0
  67. package/dist/src/vite/forgebookPlugin.d.ts +1 -1
  68. package/dist/src/vite/forgebookPlugin.js +67 -98
  69. package/dist/src/vite/framesServer.js +21 -5
  70. package/dist/src/vite/generatedModules.d.ts +23 -0
  71. package/dist/src/vite/generatedModules.js +58 -0
  72. package/dist/src/vite/hotSnippets.js +7 -3
  73. package/dist/src/vite/indexStoryFile.d.ts +2 -2
  74. package/dist/src/vite/indexStoryFile.js +2 -2
  75. package/dist/src/vite/indexedParameters.d.ts +1 -1
  76. package/dist/src/vite/indexedParameters.js +21 -1
  77. package/dist/src/vite/originGuard.d.ts +14 -4
  78. package/dist/src/vite/originGuard.js +15 -6
  79. package/dist/src/vite/pages.d.ts +3 -2
  80. package/dist/src/vite/pages.js +23 -16
  81. package/dist/src/vite/pluginIds.d.ts +24 -2
  82. package/dist/src/vite/pluginIds.js +44 -6
  83. package/dist/src/vite/pluginModules.d.ts +39 -10
  84. package/dist/src/vite/pluginModules.js +67 -22
  85. package/dist/src/vite/prebundled.js +2 -1
  86. package/dist/src/vite/searchModule.d.ts +6 -4
  87. package/dist/src/vite/searchModule.js +8 -7
  88. package/dist/src/vite/storyFilesModule.d.ts +59 -27
  89. package/dist/src/vite/storyFilesModule.js +88 -58
  90. package/dist/src/vite/storyPatterns.d.ts +39 -0
  91. package/dist/src/vite/storyPatterns.js +100 -0
  92. package/dist/src/vite/textQueries.d.ts +7 -1
  93. package/dist/src/vite/textQueries.js +7 -1
  94. package/package.json +4 -4
@@ -13,38 +13,40 @@
13
13
  * come the same way, with `?forgebook-docs-pages` (`docPagesModule.ts`), and the written home with `?forgebook-home`. It also sets Lightning CSS as the CSS transformer with the forced-states visitor (`forcedStates.ts`) and lets Sass resolve
14
14
  * `pkg:` URLs, which the library's style sheets use.
15
15
  *
16
- * A pattern's module watches its story files, so an edit renews it. Vite watches only its root for new files, and the
16
+ * A group's module watches its story files, so an edit renews it. Vite watches only its root for new files, and the
17
17
  * sources' story files live in other packages: `watch` names their folders, and a story file added or removed there
18
- * renews every pattern's module and reloads the page, so the sidebar lists it at once.
18
+ * renews the modules of the groups whose list it changes and reloads the page, so the sidebar lists it at once; one no
19
+ * group lists (a `!` pattern leaves it out) reloads nothing.
19
20
  *
20
21
  * @module
21
22
  */
22
- import { existsSync } from "node:fs";
23
23
  import { extname, join, resolve } from "node:path";
24
- import { isFileLoadingAllowed } from "vite";
24
+ import { checksA11y, offersUsedIn, readsComponentDocs } from "../catalogue.js";
25
25
  import { SAME_ORIGIN } from "../frameOrigins.js";
26
26
  import { createLiveErrors, isLiveErrorReport, LIVE_ERROR_EVENT } from "../liveErrors.js";
27
27
  import { agentMiddleware, FRAMED_AGENT_PAGES } from "./agentRoutes.js";
28
28
  import { STORY_FILE } from "./autoTitle.js";
29
- import { axeAddress, axeBuildAsset } from "./axeScript.js";
29
+ import { axeBuildAsset } from "./axeScript.js";
30
30
  import { groupTexts } from "./builtTexts.js";
31
31
  import { catalogueConfig } from "./catalogueConfig.js";
32
32
  import { currentCloseWatch } from "./closeWatch.js";
33
33
  import { findConfigFile, loadConfigFile, packageResolver } from "./configFile.js";
34
34
  import { codeStamp, diskCache } from "./diskCache.js";
35
- import { createDocsExtractor } from "./docsExtractor.js";
35
+ import { createDocsExtractor, NO_DOCS_EXTRACTOR } from "./docsExtractor.js";
36
+ import { devServerAccess, VITEST_HOOKS } from "./fileAccess.js";
36
37
  import { FORCED_STATES_VISITOR } from "./forcedStates.js";
37
38
  import { builtPlacement } from "./framesConfig.js";
38
39
  import { serveFrames } from "./framesServer.js";
40
+ import { generatedId, generatedModule } from "./generatedModules.js";
39
41
  import { missingHeightsMiddleware } from "./heightsRoute.js";
40
42
  import { FILES_MODULE_HOT, renewedModule, servedPatterns, storyFileHotHooks } from "./hotSnippets.js";
41
43
  import { inlineJson, packedJsonOf } from "./packedModule.js";
42
- import { AGENT_SOURCES_FILE, agentSourcesModule, configModule, frameEntry, PAGE_ENTRIES, PAGE_FILES, pageHtml, pageModuleOf, pageModulePath, shellEntry, storiesSourceModule } from "./pages.js";
44
+ import { AGENT_SOURCES_FILE, configModule, PAGE_FILES, pageHtml, pageModulePath } from "./pages.js";
43
45
  import { CONFIG_ID, DOCS_PAGES_PREFIX, patternId, TOKENS_ID, virtualId } from "./pluginIds.js";
44
- import { docsModule, docsValue, FILES_PREFIX, patternModule, REACH_ID_PREFIX, reachModule, searchIndexes, USAGE_ID_PREFIX, usageModule } from "./pluginModules.js";
46
+ import { docsModule, docsValue, FILES_PREFIX, listingRenewals, patternModule, REACH_ID_PREFIX, reachModule, searchIndexes, USAGE_ID_PREFIX, usageModule } from "./pluginModules.js";
45
47
  import { isKeptIndex, storyFilesGroupOf } from "./storyFilesModule.js";
46
48
  import { tokensModule } from "./storylessRoutes.js";
47
- import { CODE_QUERY, DOCS_PAGE_QUERY, HOME_QUERY, MARKDOWN_QUERY, PAGE_QUERY } from "./textQueries.js";
49
+ import { DOCS_PAGE_QUERY, DOCS_QUERY, HOME_QUERY, RENEWED_QUERIES, TEXT_QUERIES } from "./textQueries.js";
48
50
  import { VITE_PATHS } from "./vitePaths.js";
49
51
  /** This module's folder, in the core's package: the core's own dependencies resolve from here, not the project's. */
50
52
  const PACKAGE_DIR = import.meta.dirname;
@@ -62,31 +64,6 @@ const sassOptions = async () => {
62
64
  const DOCS_PAGE_FILE = /\.md$/;
63
65
  /** A TypeScript source file, whose edit may change a component's docs. */
64
66
  const SOURCE_FILE = /\.tsx?$/;
65
- /** The query that asks for a story file's component docs. */
66
- const DOCS_QUERY = "?forgebook-docs";
67
- /** The queries `textModules.ts` answers. */
68
- const TEXT_QUERIES = [CODE_QUERY, PAGE_QUERY, MARKDOWN_QUERY];
69
- /** The queries of a story file's modules the shell reads, which hand an edit to it in place on a dev server. */
70
- const RENEWED_QUERIES = [DOCS_QUERY, CODE_QUERY, PAGE_QUERY];
71
- /** The queries the plugin answers with a file's text, so each read keeps to what the dev server may serve. */
72
- const FILE_QUERIES = [
73
- DOCS_QUERY,
74
- CODE_QUERY,
75
- PAGE_QUERY,
76
- MARKDOWN_QUERY,
77
- DOCS_PAGE_QUERY,
78
- HOME_QUERY
79
- ];
80
- /**
81
- * Whether a dev server may read the file a query names: a plugin's answer never passes Vite's own check of
82
- * `server.fs`, so without this any file could be asked for by adding a query to its address.
83
- *
84
- * @param id - the module's id.
85
- * @param server - the dev server; none in a build, which reads only what the sources import.
86
- */
87
- const isReadable = (id, server) => server === undefined ||
88
- !FILE_QUERIES.some((query) => id.endsWith(query)) ||
89
- isFileLoadingAllowed(server.config, id.slice(0, id.lastIndexOf("?")));
90
67
  /**
91
68
  * Renews modules in every environment that loaded them: the agents' routes load them on the server too.
92
69
  *
@@ -120,8 +97,9 @@ const requestedPage = (url) => {
120
97
  * @param watch - adds a file whose edit renews the module.
121
98
  * @param isServing - whether a dev server runs.
122
99
  * @param plugins - the catalogue's plugins, whose fences a page may draw.
100
+ * @param isKept - whether a page its group lists may be read.
123
101
  */
124
- const docsModuleOf = async (id, watch, isServing, plugins) => {
102
+ const docsModuleOf = async (id, watch, isServing, plugins, isKept) => {
125
103
  const isHome = id.endsWith(HOME_QUERY);
126
104
  const isPage = id.endsWith(DOCS_PAGE_QUERY);
127
105
  if (!isHome && !isPage && !id.startsWith(DOCS_PAGES_PREFIX))
@@ -133,47 +111,8 @@ const docsModuleOf = async (id, watch, isServing, plugins) => {
133
111
  const file = id.slice(0, -DOCS_PAGE_QUERY.length);
134
112
  return { code: docPageModule(file, watch, isServing, plugins), moduleType: "js" };
135
113
  }
136
- const { root, pattern } = storyFilesGroupOf(id.slice(DOCS_PAGES_PREFIX.length));
137
- return { code: docPagesModule(root, pattern, watch, isServing, plugins), moduleType: "js" };
138
- };
139
- /**
140
- * A module the plugin generates for a catalogue's pages, when the id names one: an entry, the project's own stories'
141
- * source, or a page's HTML (unless the folder has one of its own). The configuration's data waits for the frames'
142
- * placement, so the plugin's `load` makes it.
143
- *
144
- * @param id - the module's id.
145
- * @param config - the catalogue's configuration.
146
- * @param command - whether a dev server runs, or a build: where the frame finds axe's script.
147
- */
148
- const generatedModule = (id, config, command) => {
149
- const named = pageModuleOf(id, config.dir);
150
- if (named === undefined)
151
- return undefined;
152
- if (named.kind === "entry") {
153
- const code = named.page === "index.html" ? shellEntry(config) : frameEntry(config, axeAddress(command, PACKAGE_DIR));
154
- return { code, moduleType: "js" };
155
- }
156
- if (named.kind === "stories-source")
157
- return { code: storiesSourceModule(config), moduleType: "js" };
158
- if (named.kind === "agent-sources")
159
- return { code: agentSourcesModule(config), moduleType: "js" };
160
- return existsSync(id) ? undefined : pageHtml(named.page, config, builtPlacement(config.frames));
161
- };
162
- /**
163
- * The id of a module the plugin generates for a catalogue's pages, when an import names one: a page or an entry by
164
- * its address (`/index.html`, `/.forgebook-shell.ts`) or by its path, a source by its path.
165
- *
166
- * @param source - the import's specifier.
167
- * @param dir - the configuration's folder.
168
- */
169
- const generatedId = (source, dir) => {
170
- const byAddress = PAGE_FILES.find((page) => source === `/${PAGE_ENTRIES[page]}`);
171
- if (byAddress !== undefined)
172
- return pageModulePath(dir, PAGE_ENTRIES[byAddress]);
173
- const pageByAddress = PAGE_FILES.find((page) => source === `/${page}`);
174
- if (pageByAddress !== undefined)
175
- return pageModulePath(dir, pageByAddress);
176
- return pageModuleOf(source, dir) === undefined ? undefined : VITE_PATHS.normalize(source);
114
+ const { root, patterns } = storyFilesGroupOf(id.slice(DOCS_PAGES_PREFIX.length));
115
+ return { code: docPagesModule(root, patterns, watch, isServing, plugins, isKept), moduleType: "js" };
177
116
  };
178
117
  /**
179
118
  * The catalogue's Vite plugin: the story index, the docs, the code and the Markdown of the stories, the forced-states
@@ -184,8 +123,11 @@ const generatedId = (source, dir) => {
184
123
  * @returns the plugin, for the app's `plugins`.
185
124
  */
186
125
  export const forgebookPlugin = (options = {}) => {
187
- /** The modules of the patterns, renewed when a story file is added or removed. */
188
- const filesModules = new Set();
126
+ /**
127
+ * The modules of the groups of story files, with the files each listed when served (none while it could not be
128
+ * made), renewed when a story file is added to or removed from what one lists.
129
+ */
130
+ const filesModules = new Map();
189
131
  // what each pattern's module was served with: an edit of a story file that changes its list reloads the pages
190
132
  const served = servedPatterns();
191
133
  /** The groups' "Used in" modules, renewed when a source file changes: any edit may change what reaches what. */
@@ -194,6 +136,8 @@ export const forgebookPlugin = (options = {}) => {
194
136
  const docsModules = new Set();
195
137
  /** The groups' search indexes: any edit may change what a component's docs say. */
196
138
  const searchModules = new Set();
139
+ /** What a client may read of a dev server. */
140
+ const access = devServerAccess();
197
141
  /** The errors the open frames report, for `forgebook errors` and the agents. */
198
142
  const live = createLiveErrors();
199
143
  /** Where the frames run: the dev server's second port once it listens, the configuration's choice in a build. */
@@ -212,8 +156,6 @@ export const forgebookPlugin = (options = {}) => {
212
156
  return { code: configModule(config, await framesPlacement()), moduleType: "js" };
213
157
  return generatedModule(id, config, command);
214
158
  };
215
- /** The dev server; none in a build. */
216
- let devServer;
217
159
  /** The files followed one by one, so an edit reaches the dev server at once. */
218
160
  const watching = currentCloseWatch();
219
161
  /** Whether a dev server runs, or a build: tools ask only the former. */
@@ -223,7 +165,8 @@ export const forgebookPlugin = (options = {}) => {
223
165
  const search = searchIndexes({
224
166
  extract: (storyFile) => extractor().extract(storyFile),
225
167
  isServing,
226
- track: (id, isPages) => (isPages ? docsModules : searchModules).add(id)
168
+ track: (id, isPages) => (isPages ? docsModules : searchModules).add(id),
169
+ isKept: access.isKept
227
170
  });
228
171
  // a build's docs, code and pages of each group's story files, read while the build loads the stories
229
172
  const texts = groupTexts();
@@ -258,6 +201,9 @@ export const forgebookPlugin = (options = {}) => {
258
201
  // build and would hold every other answer of the dev server meanwhile
259
202
  let docs;
260
203
  const extractor = () => {
204
+ // a catalogue that reads no component docs never starts the worker: its programs are most of the server's memory
205
+ if (config !== undefined && !readsComponentDocs(config.lists))
206
+ return NO_DOCS_EXTRACTOR;
261
207
  const framework = config?.framework ?? options.framework;
262
208
  if (framework === undefined)
263
209
  throw new Error("Forgebook has no framework: add forgebook.config.ts, or give the plugin `framework`");
@@ -294,21 +240,23 @@ export const forgebookPlugin = (options = {}) => {
294
240
  const storiesModule = async (id, watch, pack, startTexts) => {
295
241
  if (id.startsWith(USAGE_ID_PREFIX)) {
296
242
  usageModules.add(id);
297
- return usageModule(id, watch, extractor().usage, indexCache(), pack);
243
+ return usageModule(id, watch, extractor().usage, indexCache(), pack, access.isKept);
298
244
  }
299
245
  // what each story reaches is for the dev server's tools only: a build ships none of it
300
246
  if (id.startsWith(REACH_ID_PREFIX)) {
301
247
  usageModules.add(id);
302
248
  return isServing()
303
- ? reachModule(id, watch, extractor().usage, indexCache())
249
+ ? reachModule(id, watch, extractor().usage, indexCache(), access.isKept)
304
250
  : { code: "export default [];", moduleType: "js" };
305
251
  }
306
252
  if (!id.startsWith(FILES_PREFIX))
307
253
  return undefined;
308
- filesModules.add(id);
309
- const hasUsage = config?.lists.usedIn !== false;
254
+ filesModules.set(id, undefined);
255
+ const lists = config?.lists ?? {};
256
+ const hasUsage = offersUsedIn(lists);
310
257
  // the worker starts while Vite loads the pages, unless the disk keeps the docs; a test run asks for no page
311
- const { files, code } = patternModule(id, watch, { hasUsage, hasReach: hasUsage && isServing() }, indexCache(), isTestRun ? undefined : extractor().warm, startTexts);
258
+ const { files, code } = patternModule(id, watch, { hasDocs: readsComponentDocs(lists), hasUsage, hasReach: hasUsage && isServing() }, indexCache(), isTestRun ? undefined : extractor().warm, startTexts, access.isKept);
259
+ filesModules.set(id, new Set(files.map((file) => file.file)));
312
260
  if (!isRedrawing())
313
261
  return { code, moduleType: "js" };
314
262
  served.keep(id, files);
@@ -333,9 +281,9 @@ export const forgebookPlugin = (options = {}) => {
333
281
  * @param file - the file.
334
282
  */
335
283
  const modulesListing = (file) => {
336
- // the patterns' modules list the story files; the groups' "Used in" read them
284
+ // the groups' modules list the story files, their "Used in" and search indexes read them
337
285
  if (STORY_FILE.test(file))
338
- return [...filesModules, ...usageModules, ...searchModules];
286
+ return listingRenewals(filesModules, file, [...usageModules, ...searchModules], access.isKept);
339
287
  return DOCS_PAGE_FILE.test(file) && docsModules.size > 0 ? [...docsModules] : undefined;
340
288
  };
341
289
  /**
@@ -356,7 +304,7 @@ export const forgebookPlugin = (options = {}) => {
356
304
  }
357
305
  if (id.startsWith(DOCS_PAGES_PREFIX))
358
306
  docsModules.add(id);
359
- const docsPages = await docsModuleOf(id, watch, isServing(), config?.plugins ?? []);
307
+ const docsPages = await docsModuleOf(id, watch, isServing(), config?.plugins ?? [], access.isKept);
360
308
  if (docsPages !== undefined)
361
309
  return docsPages;
362
310
  if (!TEXT_QUERIES.some((query) => id.endsWith(query)))
@@ -380,7 +328,13 @@ export const forgebookPlugin = (options = {}) => {
380
328
  const pack = emit === undefined ? inlineJson : packedJsonOf(emit);
381
329
  const componentFiles = (config?.framework ?? options.framework)?.componentFiles;
382
330
  const docsOf = async (storyFile) => docsValue(await search.docsOf(storyFile), watch);
383
- const sources = { docsOf, watch, resolveImport, componentFiles };
331
+ const hasDocs = config === undefined || readsComponentDocs(config.lists);
332
+ const sources = {
333
+ docsOf: hasDocs ? docsOf : undefined,
334
+ watch,
335
+ resolveImport,
336
+ componentFiles
337
+ };
384
338
  // a build's browser files only: a dev server answers each text from its query, and no texts module
385
339
  const startTexts = emit === undefined ? undefined : texts.starter(id, sources);
386
340
  return ((emit === undefined ? undefined : await texts.moduleOf(id, sources, emit)) ??
@@ -410,13 +364,23 @@ export const forgebookPlugin = (options = {}) => {
410
364
  };
411
365
  },
412
366
  resolveId(source, importer) {
367
+ const isModule = access.isModule(importer);
413
368
  const virtual = virtualId(source);
414
- if (virtual !== undefined)
369
+ if (virtual !== undefined) {
370
+ if (isModule)
371
+ access.noteImported(virtual);
415
372
  return virtual;
373
+ }
416
374
  const generated = config === undefined ? undefined : generatedId(source, config.dir);
417
375
  if (generated !== undefined)
418
376
  return generated;
419
- return importer === undefined ? null : patternId(source, VITE_PATHS.dirname(importer));
377
+ if (!access.isModule(importer))
378
+ return null;
379
+ // the importer's file: its id may carry whatever query an address gave it
380
+ const pattern = patternId(source, VITE_PATHS.dirname(importer.split("?")[0] ?? importer));
381
+ if (pattern !== null)
382
+ access.noteImported(pattern);
383
+ return pattern;
420
384
  },
421
385
  // a warning and not a failure: a theme a reader can still use must not stop a catalogue from building
422
386
  configResolved: (resolved) => {
@@ -428,7 +392,10 @@ export const forgebookPlugin = (options = {}) => {
428
392
  resolved.logger.warn(`Forgebook's shell theme: ${problem}`);
429
393
  },
430
394
  configureServer: (server) => {
431
- devServer = server;
395
+ access.serve(server, config === undefined ? undefined : pageModulePath(config.dir, AGENT_SOURCES_FILE));
396
+ server.middlewares.use((request, _response, next) => {
397
+ access.settle(request.url ?? "/").then(() => next(), next);
398
+ });
432
399
  // no person waits for a page in a test run
433
400
  watching.serve(isTestRun ? undefined : server);
434
401
  server.httpServer?.on("close", () => void docs?.close());
@@ -469,7 +436,7 @@ export const forgebookPlugin = (options = {}) => {
469
436
  .catch(next);
470
437
  });
471
438
  // folders outside the configuration's are watched only when named; a story file added or removed anywhere the
472
- // server watches (its own folder, a story file it loaded) renews the lists either way
439
+ // server watches (its own folder, a story file it loaded) renews the lists that change either way
473
440
  const watched = [...(config?.watch ?? []), ...(options.watch ?? [])];
474
441
  if (watched.length > 0)
475
442
  server.watcher.add(watched);
@@ -488,7 +455,8 @@ export const forgebookPlugin = (options = {}) => {
488
455
  // axe's script, for the frames' Accessibility card: loaded by the frame, never imported; the browser's build only
489
456
  if (this.environment.name !== "client")
490
457
  return;
491
- const axe = config === undefined ? undefined : axeBuildAsset(PACKAGE_DIR);
458
+ // a catalogue that checks no accessibility ships none of it
459
+ const axe = config === undefined || !checksA11y(config.lists) ? undefined : axeBuildAsset(PACKAGE_DIR);
492
460
  if (axe !== undefined)
493
461
  this.emitFile({ type: "asset", fileName: axe.fileName, source: axe.source });
494
462
  },
@@ -501,13 +469,14 @@ export const forgebookPlugin = (options = {}) => {
501
469
  await docs?.close();
502
470
  },
503
471
  ...storyFileHotHooks(isRedrawing, served.changedBy, watching),
472
+ ...VITEST_HOOKS,
504
473
  async load(id) {
505
474
  const generated = await pagesModule(id);
506
475
  if (generated !== undefined)
507
476
  return generated;
508
- // an answer, not an error: an error would raise Vite's overlay on every reader's open page
509
- if (!isReadable(id, devServer))
510
- return { code: "export default null;", moduleType: "js" };
477
+ const refusal = access.refusal(id);
478
+ if (refusal !== undefined)
479
+ return refusal;
511
480
  // in Vite's form, the text Vite's module graph knows a file by: a Node path on Windows (`C:\app\x.md`) is another
512
481
  const watch = (file) => this.addWatchFile(VITE_PATHS.normalize(file));
513
482
  const resolveImport = async (specifier, importer) => {
@@ -45,6 +45,20 @@ export const hostOf = (request) => headerOf(request, "host") ?? headerOf(request
45
45
  * @param url - the request's address.
46
46
  */
47
47
  const isProxied = (proxy, url) => Object.keys(proxy ?? {}).some((key) => key.startsWith("^") ? new RegExp(key).test(url) : url.startsWith(key));
48
+ /**
49
+ * A request's path as an address parser reads it, which is how the routes behind the guards read it: a guard that read
50
+ * `//x/__forgebook/api/stories` as written would pass what the route answers as `/__forgebook/api/stories`.
51
+ *
52
+ * @param url - the request's address, as it was sent.
53
+ */
54
+ const parsedPath = (url) => {
55
+ try {
56
+ return new URL(url, "http://parsed.invalid").pathname;
57
+ }
58
+ catch {
59
+ return (url.split("?")[0] ?? url).split("#")[0] ?? "/";
60
+ }
61
+ };
48
62
  /**
49
63
  * What the browser says of a request.
50
64
  *
@@ -54,7 +68,7 @@ const factsOf = (request) => {
54
68
  const url = request.url ?? "/";
55
69
  const queryAt = url.indexOf("?");
56
70
  return {
57
- path: (queryAt === -1 ? url : url.slice(0, queryAt)).split("#")[0] ?? "/",
71
+ path: parsedPath(url),
58
72
  query: queryAt === -1 ? "" : (url.slice(queryAt).split("#")[0] ?? ""),
59
73
  site: headerOf(request, "sec-fetch-site"),
60
74
  mode: headerOf(request, "sec-fetch-mode"),
@@ -83,14 +97,16 @@ const isPublicFile = (publicDir, path) => {
83
97
  return file.startsWith(`${publicDir}${sep}`) && existsSync(file) && statSync(file).isFile();
84
98
  };
85
99
  /**
86
- * Whether a module's code, as Vite served it, names an address: how a worker's file (`?worker_file`) is started.
100
+ * Whether a module the catalogue imports names an address in its code, as a whole string: how a worker's file
101
+ * (`?worker_file`) is started. Only an imported module counts, and only a whole string: an address asked for directly
102
+ * writes its own query into its module's code (its HMR context), and a story's script could plant any address there.
87
103
  *
88
104
  * @param modules - the graph's modules.
89
105
  * @param url - the address, as the graph reads it; its `t=` left out.
90
106
  */
91
107
  const isNamedInCode = (modules, url) => {
92
- const named = url.replace(/[?&]t=\d+/, "");
93
- return modules.some((module) => module.transformResult?.code.includes(named) === true);
108
+ const named = JSON.stringify(url.replace(/[?&]t=\d+/, ""));
109
+ return modules.some((module) => module.importers.size > 0 && module.transformResult?.code.includes(named) === true);
94
110
  };
95
111
  /**
96
112
  * Ends a request the guards turned down, with its status and nothing a page could use.
@@ -204,7 +220,7 @@ export const framesServing = (server, frames, frameHtml, framedPages) => {
204
220
  next();
205
221
  return;
206
222
  }
207
- const answer = catalogueAnswer(factsOf(request), framedPages);
223
+ const answer = catalogueAnswer(factsOf(request), framedPages, (path) => isPublicFile(server.config.publicDir, path));
208
224
  if (answer === "refuse") {
209
225
  turnDown(response, 403);
210
226
  return;
@@ -0,0 +1,23 @@
1
+ import type { ConfigEnv } from "vite";
2
+ import type { ResolvedConfig } from "./configFile.ts";
3
+ /**
4
+ * A module the plugin generates for a catalogue's pages, when the id names one: an entry, the project's own stories'
5
+ * source, or a page's HTML (unless the folder has one of its own). The configuration's data waits for the frames'
6
+ * placement, so the plugin's `load` makes it.
7
+ *
8
+ * @param id - the module's id.
9
+ * @param config - the catalogue's configuration.
10
+ * @param command - whether a dev server runs, or a build: where the frame finds axe's script.
11
+ */
12
+ export declare const generatedModule: (id: string, config: ResolvedConfig, command: ConfigEnv["command"]) => {
13
+ code: string;
14
+ moduleType: "js";
15
+ } | string | undefined;
16
+ /**
17
+ * The id of a module the plugin generates for a catalogue's pages, when an import names one: a page or an entry by
18
+ * its address (`/index.html`, `/.forgebook-shell.ts`) or by its path, a source by its path.
19
+ *
20
+ * @param source - the import's specifier.
21
+ * @param dir - the configuration's folder.
22
+ */
23
+ export declare const generatedId: (source: string, dir: string) => string | undefined;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The modules the plugin generates for a catalogue's pages: the shell's and the frame's entries, the project's own
3
+ * stories' source, the agents' sources and the pages' HTML, and the ids imports name them by.
4
+ *
5
+ * @remarks
6
+ * The configuration's data is not among them: it waits for the frames' placement, which only the plugin knows.
7
+ *
8
+ * @module
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { checksA11y } from "../catalogue.js";
12
+ import { axeAddress } from "./axeScript.js";
13
+ import { builtPlacement } from "./framesConfig.js";
14
+ import { agentSourcesModule, frameEntry, PAGE_ENTRIES, PAGE_FILES, pageHtml, pageModuleOf, pageModulePath, shellEntry, storiesSourceModule } from "./pages.js";
15
+ import { VITE_PATHS } from "./vitePaths.js";
16
+ /** The core's folder, where axe's script is found from. */
17
+ const PACKAGE_DIR = import.meta.dirname;
18
+ /**
19
+ * A module the plugin generates for a catalogue's pages, when the id names one: an entry, the project's own stories'
20
+ * source, or a page's HTML (unless the folder has one of its own). The configuration's data waits for the frames'
21
+ * placement, so the plugin's `load` makes it.
22
+ *
23
+ * @param id - the module's id.
24
+ * @param config - the catalogue's configuration.
25
+ * @param command - whether a dev server runs, or a build: where the frame finds axe's script.
26
+ */
27
+ export const generatedModule = (id, config, command) => {
28
+ const named = pageModuleOf(id, config.dir);
29
+ if (named === undefined)
30
+ return undefined;
31
+ if (named.kind === "entry") {
32
+ const code = named.page === "index.html"
33
+ ? shellEntry(config)
34
+ : frameEntry(config, checksA11y(config.lists) ? axeAddress(command, PACKAGE_DIR) : undefined);
35
+ return { code, moduleType: "js" };
36
+ }
37
+ if (named.kind === "stories-source")
38
+ return { code: storiesSourceModule(config), moduleType: "js" };
39
+ if (named.kind === "agent-sources")
40
+ return { code: agentSourcesModule(config), moduleType: "js" };
41
+ return existsSync(id) ? undefined : pageHtml(named.page, config, builtPlacement(config.frames));
42
+ };
43
+ /**
44
+ * The id of a module the plugin generates for a catalogue's pages, when an import names one: a page or an entry by
45
+ * its address (`/index.html`, `/.forgebook-shell.ts`) or by its path, a source by its path.
46
+ *
47
+ * @param source - the import's specifier.
48
+ * @param dir - the configuration's folder.
49
+ */
50
+ export const generatedId = (source, dir) => {
51
+ const byAddress = PAGE_FILES.find((page) => source === `/${PAGE_ENTRIES[page]}`);
52
+ if (byAddress !== undefined)
53
+ return pageModulePath(dir, PAGE_ENTRIES[byAddress]);
54
+ const pageByAddress = PAGE_FILES.find((page) => source === `/${page}`);
55
+ if (pageByAddress !== undefined)
56
+ return pageModulePath(dir, pageByAddress);
57
+ return pageModuleOf(source, dir) === undefined ? undefined : VITE_PATHS.normalize(source);
58
+ };
@@ -105,10 +105,14 @@ const readIndex = (root, pattern, key) => {
105
105
  };
106
106
  /** The patterns' modules a dev server serves, kept to tell an edit that changes a list. */
107
107
  export const servedPatterns = () => {
108
+ // by group, each story file as served: its key, the pattern that lists it, its index
108
109
  const served = new Map();
109
110
  return {
110
111
  keep: (id, files) => {
111
- served.set(id, new Map(files.map((file) => [file.file, { key: file.key, index: JSON.stringify(file.index) }])));
112
+ served.set(id, new Map(files.map((file) => [
113
+ file.file,
114
+ { key: file.key, pattern: file.pattern, index: JSON.stringify(file.index) }
115
+ ])));
112
116
  },
113
117
  changedBy: (file) => [...served]
114
118
  .filter(([id, files]) => {
@@ -116,8 +120,8 @@ export const servedPatterns = () => {
116
120
  const kept = files.get(VITE_PATHS.normalize(file));
117
121
  if (kept === undefined)
118
122
  return false;
119
- const { root, pattern } = storyFilesGroupOf(id.slice(FILES_PREFIX.length));
120
- const index = readIndex(root, pattern, kept.key);
123
+ const { root } = storyFilesGroupOf(id.slice(FILES_PREFIX.length));
124
+ const index = readIndex(root, kept.pattern, kept.key);
121
125
  return typeof index !== "string" && JSON.stringify(index) !== kept.index;
122
126
  })
123
127
  .map(([id]) => id)
@@ -17,8 +17,8 @@
17
17
  *
18
18
  * A value the index needs (the title, a name, `parameters.controls`, `group`, `height`, `main`, the meta's `alsoIn`,
19
19
  * `related`, `keywords`) is read when written plainly; written by code, it is left to the frame, which reads it when
20
- * the story loads. `touch`, `logic`, `device`, `design.figma` and the meta's `propGroups` are read by the index only
21
- * (`indexedParameters.ts`, `indexFields.ts`), so written by code they are a problem.
20
+ * the story loads. `touch`, `logic`, `device`, `requiredFirst`, `design.figma` and the meta's `propGroups` are read by
21
+ * the index only (`indexedParameters.ts`, `indexFields.ts`), so written by code they are a problem.
22
22
  * A `design.figma` is checked here too, so the pages never carry the checker: one that is not a link to a Figma file is
23
23
  * noted and left out, and the file keeps its stories. A title written by code, or none, is the file's path
24
24
  * (`autoTitle`, as Storybook's auto-title); one written by code is also noted, since the author meant another. A value written plainly but wrongly
@@ -17,8 +17,8 @@
17
17
  *
18
18
  * A value the index needs (the title, a name, `parameters.controls`, `group`, `height`, `main`, the meta's `alsoIn`,
19
19
  * `related`, `keywords`) is read when written plainly; written by code, it is left to the frame, which reads it when
20
- * the story loads. `touch`, `logic`, `device`, `design.figma` and the meta's `propGroups` are read by the index only
21
- * (`indexedParameters.ts`, `indexFields.ts`), so written by code they are a problem.
20
+ * the story loads. `touch`, `logic`, `device`, `requiredFirst`, `design.figma` and the meta's `propGroups` are read by
21
+ * the index only (`indexedParameters.ts`, `indexFields.ts`), so written by code they are a problem.
22
22
  * A `design.figma` is checked here too, so the pages never carry the checker: one that is not a link to a Figma file is
23
23
  * noted and left out, and the file keeps its stories. A title written by code, or none, is the file's path
24
24
  * (`autoTitle`, as Storybook's auto-title); one written by code is also noted, since the author meant another. A value written plainly but wrongly
@@ -1,7 +1,7 @@
1
1
  import type { StoryIndexEntry } from "@forgebook/story-format/source";
2
2
  import { type Fields } from "./indexFields.ts";
3
3
  /** What the index keeps of these parameters: each only when written. */
4
- export type IndexedParameters = Pick<StoryIndexEntry, "isTouch" | "isLogic" | "device">;
4
+ export type IndexedParameters = Pick<StoryIndexEntry, "isTouch" | "isLogic" | "device" | "requiredFirst">;
5
5
  /**
6
6
  * The parameters only the index reads, each only when written, or the first problem.
7
7
  *
@@ -1,4 +1,5 @@
1
1
  import { deviceSettingProblem, isDeviceOrientation } from "../deviceView.js";
2
+ import { REQUIRED_FIRST_LAYOUTS } from "../propGroups.js";
2
3
  import { keyName, parameter, unwrapped } from "./indexFields.js";
3
4
  /** Why `parameters.device` cannot be read as it is written. */
4
5
  const DEVICE_PROBLEM = '`parameters.device` must be written as a plain object (`{ frame: true, model: "iphone-16" }`)';
@@ -87,6 +88,21 @@ const deviceOf = (value) => {
87
88
  const problem = deviceSettingProblem(setting);
88
89
  return problem === undefined ? setting : new Error(`\`parameters.device\`: ${problem}`);
89
90
  };
91
+ /**
92
+ * `parameters.requiredFirst`: `undefined` when none, `"in-groups"`, `"section"` or `false` written plainly, or a
93
+ * problem otherwise.
94
+ *
95
+ * @param value - the parameter's value.
96
+ */
97
+ const requiredFirstOf = (value) => {
98
+ if (value === undefined)
99
+ return undefined;
100
+ const written = value.type === "Literal" ? value.value : undefined;
101
+ const layout = REQUIRED_FIRST_LAYOUTS.find((each) => each === written);
102
+ if (layout !== undefined)
103
+ return layout;
104
+ return new Error('`parameters.requiredFirst` must be written as a plain `"in-groups"`, `"section"` or `false`');
105
+ };
90
106
  /**
91
107
  * The parameters only the index reads, each only when written, or the first problem.
92
108
  *
@@ -102,9 +118,13 @@ export const indexedParameters = (fields) => {
102
118
  const device = deviceOf(parameter(fields, "device"));
103
119
  if (device instanceof Error)
104
120
  return device;
121
+ const requiredFirst = requiredFirstOf(parameter(fields, "requiredFirst"));
122
+ if (requiredFirst instanceof Error)
123
+ return requiredFirst;
105
124
  return {
106
125
  ...(touch === undefined ? {} : { isTouch: touch }),
107
126
  ...(logic === undefined ? {} : { isLogic: logic }),
108
- ...(device === undefined ? {} : { device })
127
+ ...(device === undefined ? {} : { device }),
128
+ ...(requiredFirst === undefined ? {} : { requiredFirst })
109
129
  };
110
130
  };
@@ -6,7 +6,13 @@
6
6
  * @remarks
7
7
  * Vite's CORS lets any page on `localhost` or `127.0.0.1` read the dev server, whatever its port: a frame on a second
8
8
  * port would read the catalogue's agent API and files as before. So the catalogue refuses a request another origin
9
- * sends, unless it is a navigation (a link, an embed), which shows a page but lets nobody read it.
9
+ * sends, unless it opens one of its pages (a link, an embed) or a public file, which shows it but lets nobody read it.
10
+ * No other address is opened for another origin's page: a server's route acts when opened (`/__open-in-editor` opens a
11
+ * file), and a module or a source file has no use as a page.
12
+ *
13
+ * Vite checks `Host` first, on both ports, against `server.allowedHosts`: a page that reaches `127.0.0.1` through its
14
+ * own domain (DNS rebinding) is not told apart here, since it is same-origin for its browser, and over plain HTTP on
15
+ * that domain its browser sends no `Sec-Fetch-*`.
10
16
  *
11
17
  * The frames' origin would otherwise serve everything the catalogue's does, and a story's script would read the
12
18
  * project from there. It serves a script or a `fetch` (whose body a script reads) only for a module that another
@@ -23,7 +29,10 @@
23
29
  */
24
30
  /** What a browser says of a request, from its headers. */
25
31
  export type RequestFacts = {
26
- /** Its path, without the query (`/frame.html`). */
32
+ /**
33
+ * Its path as an address parser reads it, without the query (`/frame.html`): the routes behind the guards read the
34
+ * same, so `//x/__forgebook/api/stories` or `/./__forgebook/api/stories` is the route it would reach.
35
+ */
27
36
  readonly path: string;
28
37
  /** Its query, with its `?` (`?raw`); empty without one. */
29
38
  readonly query: string;
@@ -44,13 +53,14 @@ export declare const FRAME_PAGE_PATH = "/frame.html";
44
53
  export type CatalogueAnswer = "serve" | "refuse" | "to-frames";
45
54
  /**
46
55
  * What the catalogue's origin does with a request: the frame page goes to the frames' origin, another origin's
47
- * request is refused, the rest is served.
56
+ * request is refused unless it opens a page or a public file, the rest is served.
48
57
  *
49
58
  * @param request - what the browser says of it.
50
59
  * @param framedPages - the server's routes that are pages made to be framed by another origin's page (a client's
51
60
  * inline preview), which only show a story and act on nothing.
61
+ * @param isPublic - whether a path names a file of the public folder; asked only of another origin's navigation.
52
62
  */
53
- export declare const catalogueAnswer: (request: RequestFacts, framedPages: ReadonlySet<string>) => CatalogueAnswer;
63
+ export declare const catalogueAnswer: (request: RequestFacts, framedPages: ReadonlySet<string>, isPublic?: (path: string) => boolean) => CatalogueAnswer;
54
64
  /** What the frames' origin does with a request: `ask-graph` serves it when another module imports it. */
55
65
  export type FramesAnswer = "serve" | "refuse" | "not-found" | "ask-graph";
56
66
  /** What the frames' origin knows of a request's path. */