forgebook 0.2.0 → 0.3.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 (94) hide show
  1. package/README.md +112 -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 +51 -0
  66. package/dist/src/vite/fileAccess.js +109 -0
  67. package/dist/src/vite/forgebookPlugin.d.ts +1 -1
  68. package/dist/src/vite/forgebookPlugin.js +61 -96
  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 +9 -2
  82. package/dist/src/vite/pluginIds.js +21 -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 +2 -2
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The modules that answer a `?forgebook-docs-pages` import: every page a pattern matches as one module with each page's
2
+ * The modules that answer a group of docs pages, a `?forgebook-docs-pages` import's pattern or a catalogue's `docs`
3
+ * list (`DOCS_PAGES_SPECIFIER_PREFIX`): every page the group lists as one module with each page's
3
4
  * index, and the loader of the page itself, and each page's own module.
4
5
  *
5
6
  * @remarks
@@ -15,7 +16,7 @@ import { codeLiteral } from "../codeLiteral.js";
15
16
  import { readLinkedFile } from "./docPageLinks.js";
16
17
  import { compiledDocPage, compiledHome, docPageIndex } from "./docPageMarkdown.js";
17
18
  import { pluginFences } from "./pluginConfig.js";
18
- import { PAGES_SEARCH_PREFIX, storyFileKeys, storyFilesGroupText } from "./storyFilesModule.js";
19
+ import { EVERY_FILE, PAGES_SEARCH_PREFIX, storyFileKeys, storyFilesGroupText } from "./storyFilesModule.js";
19
20
  import { DOCS_PAGE_QUERY } from "./textQueries.js";
20
21
  import { VITE_PATHS } from "./vitePaths.js";
21
22
  /** Reads a linked page, watching it: its title makes the link's address, so a new title renews the page that links. */
@@ -26,15 +27,16 @@ const watchedRead = (watch) => (file) => {
26
27
  return text;
27
28
  };
28
29
  /**
29
- * Every page a pattern matches, indexed.
30
+ * Every page a group's patterns list, indexed.
30
31
  *
31
32
  * @param root - the declaring module's folder.
32
- * @param pattern - the pattern.
33
+ * @param patterns - the patterns, `!` ones included (`listedFiles`).
33
34
  * @param watch - adds a file whose edit renews the module that read it.
34
35
  * @param fences - the fences the catalogue's plugins draw, which are not code; none by default.
36
+ * @param isKept - whether a listed page may be read; every one by default.
35
37
  */
36
- export const indexedDocPages = (root, pattern, watch, fences = []) => {
37
- return storyFileKeys(root, [pattern]).map((key) => {
38
+ export const indexedDocPages = (root, patterns, watch, fences = [], isKept = EVERY_FILE) => {
39
+ return storyFileKeys(root, patterns, isKept).map((key) => {
38
40
  const file = resolve(root, key);
39
41
  watch(file);
40
42
  return { key, file, index: docPageIndex(file, readFileSync(file, "utf8"), watchedRead(watch), fences) };
@@ -43,20 +45,21 @@ export const indexedDocPages = (root, pattern, watch, fences = []) => {
43
45
  /** An object literal of a key per page, each value written by `value`. */
44
46
  const objectOf = (keys, value) => `{${keys.map((key) => `${codeLiteral(key)}: ${value(key)}`).join(",\n")}}`;
45
47
  /**
46
- * The code of the module for one pattern.
48
+ * The code of the module for one group.
47
49
  *
48
50
  * @param root - the declaring module's folder.
49
- * @param pattern - the pattern before `?forgebook-docs-pages`.
51
+ * @param patterns - the group's patterns.
50
52
  * @param watch - adds a file whose edit renews the module (each page: its index is in the module).
51
53
  * @param isServing - whether a dev server runs, whose tools read a page's Markdown.
52
54
  * @param plugins - the catalogue's plugins, whose fences a page may draw; none by default.
55
+ * @param isKept - whether a listed page may be read; every one by default.
53
56
  */
54
- export const docPagesModule = (root, pattern, watch, isServing, plugins = []) => {
55
- const pages = indexedDocPages(root, pattern, watch, pluginFences(plugins));
57
+ export const docPagesModule = (root, patterns, watch, isServing, plugins = [], isKept = EVERY_FILE) => {
58
+ const pages = indexedDocPages(root, patterns, watch, pluginFences(plugins), isKept);
56
59
  const keys = pages.map((page) => page.key);
57
60
  const index = Object.fromEntries(pages.map((page) => [page.key, page.index]));
58
61
  const page = (key) => codeLiteral(`${VITE_PATHS.resolve(root, key)}${DOCS_PAGE_QUERY}`);
59
- const search = codeLiteral(`${PAGES_SEARCH_PREFIX}${storyFilesGroupText(root, pattern)}`);
62
+ const search = codeLiteral(`${PAGES_SEARCH_PREFIX}${storyFilesGroupText(root, patterns)}`);
60
63
  return [
61
64
  "export default {",
62
65
  `index: ${codeLiteral(index)},`,
@@ -101,6 +101,12 @@ export type DocsExtractorOptions = {
101
101
  * looks keeps them, renewed after each edit rather than built again (seconds, in a large monorepo).
102
102
  */
103
103
  export declare const IDLE_MS: number;
104
+ /**
105
+ * The extractor of a catalogue that reads no component docs (`componentDocs: false`): it starts no worker and builds no
106
+ * TypeScript program, answers every story file as one whose meta names no component, and every group's usage with why
107
+ * there is none.
108
+ */
109
+ export declare const NO_DOCS_EXTRACTOR: DocsExtractor;
104
110
  /**
105
111
  * A docs extractor with its worker, started on its first request.
106
112
  *
@@ -62,6 +62,19 @@ const codeStampOf = (docsModule) => {
62
62
  return undefined;
63
63
  }
64
64
  };
65
+ /**
66
+ * The extractor of a catalogue that reads no component docs (`componentDocs: false`): it starts no worker and builds no
67
+ * TypeScript program, answers every story file as one whose meta names no component, and every group's usage with why
68
+ * there is none.
69
+ */
70
+ export const NO_DOCS_EXTRACTOR = {
71
+ extract: () => Promise.resolve({ docs: { name: null } }),
72
+ usage: () => Promise.resolve("The catalogue reads no component docs (componentDocs: false)"),
73
+ warm: () => undefined,
74
+ changed: () => undefined,
75
+ listed: () => undefined,
76
+ close: () => Promise.resolve()
77
+ };
65
78
  /**
66
79
  * A docs extractor with its worker, started on its first request.
67
80
  *
@@ -0,0 +1,51 @@
1
+ import { type ResolvedConfig, type ViteDevServer } from "vite";
2
+ import type { IsFileKept } from "./storyFilesModule.ts";
3
+ /**
4
+ * Whether Vite refuses a listed file whatever `server.fs.allow` says: `deny`, or a path it will not trust (a `:` in it).
5
+ * Vite offers no check of this step alone, so this asks its whole check with an `allow` that covers the file.
6
+ *
7
+ * @param config - the dev server's configuration.
8
+ * @param file - the file, absolute in Vite's form.
9
+ */
10
+ export declare const isFileRefused: (config: ResolvedConfig, file: string) => boolean;
11
+ /**
12
+ * Whether a dev server may read the file a query's id names. An id Vite resolved is normalised; one with a `..` came
13
+ * from an address unresolved, and Vite's check reads a path as written, so `/project/../secret` would pass for a file
14
+ * of `/project`.
15
+ *
16
+ * @param config - the dev server's configuration.
17
+ * @param id - the module's id, the file and then its query.
18
+ */
19
+ export declare const isQueriedFileReadable: (config: ResolvedConfig, id: string) => boolean;
20
+ /** What a plugin's dev server lets a client read: `devServerAccess` makes it. */
21
+ export type DevServerAccess = {
22
+ /** Starts on a dev server; until then (and in a build, which has none) nothing is refused. */
23
+ readonly serve: (server: ViteDevServer) => void;
24
+ /**
25
+ * Whether a resolver's importer is a module, not the stand-in Vite gives an address asked for directly.
26
+ *
27
+ * @param importer - the importer the resolver was given.
28
+ */
29
+ readonly isModule: (importer: string | undefined) => importer is string;
30
+ /**
31
+ * Notes that a module imports a group, whose id is now served.
32
+ *
33
+ * @param id - the group module's id.
34
+ */
35
+ readonly noteImported: (id: string) => void;
36
+ /** Whether a file a group lists may be read; says so once when not, as nothing else would tell why it is missing. */
37
+ readonly isKept: IsFileKept;
38
+ /**
39
+ * Whether a module is answered with nothing: a group no module imported, or a file `server.fs` refuses. Asked of every
40
+ * id, since an address can name an id directly, past the plugin's `resolveId`.
41
+ *
42
+ * @param id - the module's id.
43
+ */
44
+ readonly isRefused: (id: string) => boolean;
45
+ };
46
+ /**
47
+ * What a plugin's dev server lets a client read.
48
+ *
49
+ * @param renew - renews modules Vite keeps: a group refused before its first import would otherwise stay empty.
50
+ */
51
+ export declare const devServerAccess: (renew: (server: ViteDevServer, ids: Iterable<string>) => void) => DevServerAccess;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What a dev server's plugin may read for a client, by Vite's own `server.fs` and by what the project imports: a
3
+ * plugin's answer never passes through Vite's check, so each read an address can name asks here first.
4
+ *
5
+ * @remarks
6
+ * Vite's rule is two steps. The first refuses a file whatever else holds (`server.fs.deny`, a path it will not trust).
7
+ * Past it, a file is served when `allow` covers it or a module of the project imports it. A group of story files or
8
+ * docs pages is what a module of the project imports, so it keeps to the first step alone: a catalogue's `../src` may be
9
+ * outside `allow`, and Vite serves its files all the same. A query names one file in the address, so it asks both steps:
10
+ * Vite has recorded the file by then if a module imports it.
11
+ *
12
+ * A group's id holds its patterns, so an id alone must never choose what is read: any address could name a folder to
13
+ * list and read, and the imports the group's module writes would make Vite serve every file listed, past `allow`.
14
+ *
15
+ * @module
16
+ */
17
+ import { realpathSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { isFileLoadingAllowed } from "vite";
20
+ import { isGroupId } from "./pluginIds.js";
21
+ import { CODE_QUERY, DOCS_PAGE_QUERY, DOCS_QUERY, HOME_QUERY, MARKDOWN_QUERY, PAGE_QUERY } from "./textQueries.js";
22
+ import { VITE_PATHS } from "./vitePaths.js";
23
+ /** The queries the plugin answers with a file's text, so each read keeps to what the dev server may serve. */
24
+ const FILE_QUERIES = [
25
+ DOCS_QUERY,
26
+ CODE_QUERY,
27
+ PAGE_QUERY,
28
+ MARKDOWN_QUERY,
29
+ DOCS_PAGE_QUERY,
30
+ HOME_QUERY
31
+ ];
32
+ /**
33
+ * A listed file's real path, in Vite's form. Vite refuses a Windows short name (`RUNNER~1`), which could dodge `deny`,
34
+ * and a system's temporary folder is often written with one: the long name lets the file be judged by what it is. So
35
+ * does a link's target, which is the file that would be read.
36
+ *
37
+ * @param file - the file, absolute.
38
+ */
39
+ const realFile = (file) => {
40
+ try {
41
+ return VITE_PATHS.normalize(realpathSync.native(file));
42
+ }
43
+ catch {
44
+ return file;
45
+ }
46
+ };
47
+ /**
48
+ * Whether Vite refuses a listed file whatever `server.fs.allow` says: `deny`, or a path it will not trust (a `:` in it).
49
+ * Vite offers no check of this step alone, so this asks its whole check with an `allow` that covers the file.
50
+ *
51
+ * @param config - the dev server's configuration.
52
+ * @param file - the file, absolute in Vite's form.
53
+ */
54
+ export const isFileRefused = (config, file) => {
55
+ const real = realFile(file);
56
+ const fs = { ...config.server.fs, allow: [VITE_PATHS.dirname(real)] };
57
+ return !isFileLoadingAllowed({ ...config, server: { ...config.server, fs } }, real);
58
+ };
59
+ /**
60
+ * Whether a dev server may read the file a query's id names. An id Vite resolved is normalised; one with a `..` came
61
+ * from an address unresolved, and Vite's check reads a path as written, so `/project/../secret` would pass for a file
62
+ * of `/project`.
63
+ *
64
+ * @param config - the dev server's configuration.
65
+ * @param id - the module's id, the file and then its query.
66
+ */
67
+ export const isQueriedFileReadable = (config, id) => {
68
+ const file = id.slice(0, id.lastIndexOf("?"));
69
+ return VITE_PATHS.normalize(file) === file && isFileLoadingAllowed(config, file);
70
+ };
71
+ /**
72
+ * What a plugin's dev server lets a client read.
73
+ *
74
+ * @param renew - renews modules Vite keeps: a group refused before its first import would otherwise stay empty.
75
+ */
76
+ export const devServerAccess = (renew) => {
77
+ let devServer;
78
+ let addressImporter;
79
+ const imported = new Set();
80
+ const refused = new Set();
81
+ return {
82
+ serve: (server) => {
83
+ devServer = server;
84
+ addressImporter = join(server.config.root, "index.html");
85
+ },
86
+ isModule: (importer) => importer !== undefined && importer !== addressImporter,
87
+ noteImported: (id) => {
88
+ imported.add(id);
89
+ if (refused.delete(id) && devServer !== undefined)
90
+ renew(devServer, [id]);
91
+ },
92
+ isKept: (file) => {
93
+ if (devServer === undefined || !isFileRefused(devServer.config, file))
94
+ return true;
95
+ devServer.config.logger.warnOnce(`Forgebook leaves out ${file}: Vite's server.fs refuses it`);
96
+ return false;
97
+ },
98
+ isRefused: (id) => {
99
+ if (devServer === undefined)
100
+ return false;
101
+ if (!isGroupId(id))
102
+ return (FILE_QUERIES.some((query) => id.endsWith(query)) && !isQueriedFileReadable(devServer.config, id));
103
+ if (imported.has(id))
104
+ return false;
105
+ refused.add(id);
106
+ return true;
107
+ }
108
+ };
109
+ };
@@ -1,4 +1,4 @@
1
- import { type Plugin } from "vite";
1
+ import type { Plugin } from "vite";
2
2
  import type { FrameworkAdapter } from "./adapter.ts";
3
3
  import { type ResolvedConfig } from "./configFile.ts";
4
4
  /** The plugin's options. */
@@ -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 } 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(invalidateEverywhere);
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,7 @@ 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);
432
396
  // no person waits for a page in a test run
433
397
  watching.serve(isTestRun ? undefined : server);
434
398
  server.httpServer?.on("close", () => void docs?.close());
@@ -469,7 +433,7 @@ export const forgebookPlugin = (options = {}) => {
469
433
  .catch(next);
470
434
  });
471
435
  // 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
436
+ // server watches (its own folder, a story file it loaded) renews the lists that change either way
473
437
  const watched = [...(config?.watch ?? []), ...(options.watch ?? [])];
474
438
  if (watched.length > 0)
475
439
  server.watcher.add(watched);
@@ -488,7 +452,8 @@ export const forgebookPlugin = (options = {}) => {
488
452
  // axe's script, for the frames' Accessibility card: loaded by the frame, never imported; the browser's build only
489
453
  if (this.environment.name !== "client")
490
454
  return;
491
- const axe = config === undefined ? undefined : axeBuildAsset(PACKAGE_DIR);
455
+ // a catalogue that checks no accessibility ships none of it
456
+ const axe = config === undefined || !checksA11y(config.lists) ? undefined : axeBuildAsset(PACKAGE_DIR);
492
457
  if (axe !== undefined)
493
458
  this.emitFile({ type: "asset", fileName: axe.fileName, source: axe.source });
494
459
  },
@@ -506,7 +471,7 @@ export const forgebookPlugin = (options = {}) => {
506
471
  if (generated !== undefined)
507
472
  return generated;
508
473
  // an answer, not an error: an error would raise Vite's overlay on every reader's open page
509
- if (!isReadable(id, devServer))
474
+ if (access.isRefused(id))
510
475
  return { code: "export default null;", moduleType: "js" };
511
476
  // 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
477
  const watch = (file) => this.addWatchFile(VITE_PATHS.normalize(file));
@@ -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;