forgebook 0.3.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.
package/README.md CHANGED
@@ -1028,7 +1028,8 @@ its `frame-src`, in the preview's `frameDomains`).
1028
1028
  read. Vite checks `Host`
1029
1029
  first (`server.allowedHosts`, plain HTTP only), which stops a site that reaches `127.0.0.1` through its own domain.
1030
1030
  - **Only the files your project names are listed or read.** A catalogue's `stories` and `docs`, and the groups a module
1031
- of yours imports (`?forgebook-files`), are the only groups the server reads, whatever an address names. A query that
1031
+ of yours imports (`?forgebook-files`), are the only groups the server reads, whatever an address names: any other
1032
+ answers 404. A catalogue's tab left open across a restart gets its groups all the same. A query that
1032
1033
  names one file (`?forgebook-code`, `?forgebook-markdown`) follows Vite's `server.fs`, as `?raw` does.
1033
1034
  - **`server.fs.deny` wins over your globs.** A file it refuses stays out of the catalogue, its search and "Used in", with
1034
1035
  a warning. A file your globs name outside `server.fs.allow` is listed and served, as Vite serves any file a module of
@@ -19,8 +19,13 @@ export declare const isFileRefused: (config: ResolvedConfig, file: string) => bo
19
19
  export declare const isQueriedFileReadable: (config: ResolvedConfig, id: string) => boolean;
20
20
  /** What a plugin's dev server lets a client read: `devServerAccess` makes it. */
21
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;
22
+ /**
23
+ * Starts on a dev server; until then (and in a build, which has none) nothing is refused.
24
+ *
25
+ * @param server - the dev server.
26
+ * @param sources - the module that imports the configuration's sources; none without a configuration.
27
+ */
28
+ readonly serve: (server: ViteDevServer, sources: string | undefined) => void;
24
29
  /**
25
30
  * Whether a resolver's importer is a module, not the stand-in Vite gives an address asked for directly.
26
31
  *
@@ -36,16 +41,35 @@ export type DevServerAccess = {
36
41
  /** Whether a file a group lists may be read; says so once when not, as nothing else would tell why it is missing. */
37
42
  readonly isKept: IsFileKept;
38
43
  /**
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`.
44
+ * Holds a request for a group no import has named until the configuration's sources have been read.
45
+ *
46
+ * @param url - the request's address.
47
+ */
48
+ readonly settle: (url: string) => Promise<void>;
49
+ /**
50
+ * What the plugin's load answers for a file's query `server.fs` refuses: an empty module and not Vite's 404, as Vite
51
+ * would then read a path with a `..` as written, past the check that refused it. Asked of every id, since an address
52
+ * can name an id directly, past the plugin's `resolveId`.
41
53
  *
42
54
  * @param id - the module's id.
55
+ * @returns the module, or none when the client may read the id.
56
+ * @throws for a group no module of the project imports, with Vite's code for a missing file.
43
57
  */
44
- readonly isRefused: (id: string) => boolean;
58
+ readonly refusal: (id: string) => {
59
+ readonly code: string;
60
+ readonly moduleType: "js";
61
+ } | undefined;
45
62
  };
63
+ /** What a plugin's dev server lets a client read. */
64
+ export declare const devServerAccess: () => DevServerAccess;
46
65
  /**
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.
66
+ * The plugin's hook for Vitest (`configureVitest`), whose module cache keeps each module's transform on disk between
67
+ * runs: a module read from it resolves no import, so a group it names would stay refused from the second run on.
50
68
  */
51
- export declare const devServerAccess: (renew: (server: ViteDevServer, ids: Iterable<string>) => void) => DevServerAccess;
69
+ export declare const VITEST_HOOKS: {
70
+ configureVitest: (context: {
71
+ readonly defineCacheKeyGenerator?: (generator: (module: {
72
+ readonly sourceCode: string;
73
+ }) => false | undefined) => void;
74
+ }) => void;
75
+ };
@@ -12,12 +12,23 @@
12
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
13
  * list and read, and the imports the group's module writes would make Vite serve every file listed, past `allow`.
14
14
  *
15
+ * A group is named by a module's import, which the plugin sees when Vite transforms that module. A client may ask before
16
+ * that: a tab left open across a restart holds its modules and asks for a search index first. So a request for a group
17
+ * no import has named waits, before Vite starts on it, for the server to read what the configuration's sources import,
18
+ * the same modules a page would load: inside the group's load the reading could wait for a request that waits for it. A
19
+ * cache of transforms would hide the import for good, and Vitest keeps one on disk between runs: a module that names a
20
+ * group stays out of it.
21
+ *
22
+ * A group no module names answers as a missing file does: Vite answers 404, keeps no module to renew and raises no
23
+ * overlay on the pages open. A module's `export default null` would read as a group, and fail far from here in whatever
24
+ * reads it.
25
+ *
15
26
  * @module
16
27
  */
17
28
  import { realpathSync } from "node:fs";
18
29
  import { join } from "node:path";
19
- import { isFileLoadingAllowed } from "vite";
20
- import { isGroupId } from "./pluginIds.js";
30
+ import { isCSSRequest, isFileLoadingAllowed } from "vite";
31
+ import { isGroupId, namesGroup, namingGroupOf } from "./pluginIds.js";
21
32
  import { CODE_QUERY, DOCS_PAGE_QUERY, DOCS_QUERY, HOME_QUERY, MARKDOWN_QUERY, PAGE_QUERY } from "./textQueries.js";
22
33
  import { VITE_PATHS } from "./vitePaths.js";
23
34
  /** The queries the plugin answers with a file's text, so each read keeps to what the dev server may serve. */
@@ -69,25 +80,81 @@ export const isQueriedFileReadable = (config, id) => {
69
80
  return VITE_PATHS.normalize(file) === file && isFileLoadingAllowed(config, file);
70
81
  };
71
82
  /**
72
- * What a plugin's dev server lets a client read.
83
+ * Whether reading a module can show an import of a group: not a group (reading one waits for this reading) nor a file's
84
+ * query, which reads no import; not a style sheet or a pre-bundled dependency, which import none.
85
+ *
86
+ * @param environment - the environment the module is read in.
87
+ * @param module - the module.
88
+ */
89
+ const mayNameGroup = (environment, module) => {
90
+ const { id, url } = module;
91
+ return (id !== null &&
92
+ !isCSSRequest(url) &&
93
+ !isGroupId(id) &&
94
+ !FILE_QUERIES.some((query) => id.endsWith(query)) &&
95
+ environment.depsOptimizer?.isOptimizedDepUrl(url) !== true);
96
+ };
97
+ /**
98
+ * Reads a module and what it imports, for the imports the plugin sees on the way.
99
+ *
100
+ * @param environment - the environment to read them in.
101
+ * @param entry - the module's address.
102
+ * @returns whether each could be read: one that could not may name a group once it is fixed.
103
+ */
104
+ const readImports = async (environment, entry) => {
105
+ const seen = new Set([entry]);
106
+ const read = async (url) => {
107
+ try {
108
+ await environment.transformRequest(url);
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ const module = await environment.moduleGraph.getModuleByUrl(url);
114
+ const next = [...(module?.importedModules ?? [])].filter((imported) => !seen.has(imported.url) && mayNameGroup(environment, imported));
115
+ for (const imported of next)
116
+ seen.add(imported.url);
117
+ const reads = await Promise.all(next.map((imported) => read(imported.url)));
118
+ return reads.every(Boolean);
119
+ };
120
+ return read(entry);
121
+ };
122
+ /**
123
+ * The id of the group a request's address names, when it names one: any spelling this misses reaches the plugin's load,
124
+ * which refuses it all the same.
73
125
  *
74
- * @param renew - renews modules Vite keeps: a group refused before its first import would otherwise stay empty.
126
+ * @param url - the request's address.
75
127
  */
76
- export const devServerAccess = (renew) => {
128
+ const groupIdOf = (url) => {
129
+ try {
130
+ const path = decodeURI(url.split("?")[0] ?? "");
131
+ const at = path.indexOf("virtual:forgebook-");
132
+ return at === -1 ? undefined : `\0${path.slice(at)}`;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ };
138
+ /** Vite's code for a file it could not load: its server answers 404, with no overlay, and a runner fails with the text. */
139
+ const MISSING_MODULE = "ERR_LOAD_URL";
140
+ /** What a plugin's dev server lets a client read. */
141
+ export const devServerAccess = () => {
77
142
  let devServer;
78
143
  let addressImporter;
144
+ let sourcesEntry;
145
+ /** The reading of the sources: once, as an edit that names a new group is seen when Vite transforms it. */
146
+ let sourcesRead;
79
147
  const imported = new Set();
80
- const refused = new Set();
81
148
  return {
82
- serve: (server) => {
149
+ serve: (server, sources) => {
83
150
  devServer = server;
84
151
  addressImporter = join(server.config.root, "index.html");
152
+ sourcesEntry = sources;
153
+ sourcesRead = undefined;
85
154
  },
86
155
  isModule: (importer) => importer !== undefined && importer !== addressImporter,
87
156
  noteImported: (id) => {
88
- imported.add(id);
89
- if (refused.delete(id) && devServer !== undefined)
90
- renew(devServer, [id]);
157
+ imported.add(namingGroupOf(id));
91
158
  },
92
159
  isKept: (file) => {
93
160
  if (devServer === undefined || !isFileRefused(devServer.config, file))
@@ -95,15 +162,38 @@ export const devServerAccess = (renew) => {
95
162
  devServer.config.logger.warnOnce(`Forgebook leaves out ${file}: Vite's server.fs refuses it`);
96
163
  return false;
97
164
  },
98
- isRefused: (id) => {
165
+ settle: async (url) => {
166
+ const id = groupIdOf(url);
167
+ if (id === undefined || devServer === undefined || sourcesEntry === undefined)
168
+ return;
169
+ if (imported.has(namingGroupOf(id)))
170
+ return;
171
+ sourcesRead ??= readImports(devServer.environments.client, sourcesEntry).then((isRead) => {
172
+ if (!isRead)
173
+ sourcesRead = undefined;
174
+ });
175
+ await sourcesRead;
176
+ },
177
+ refusal: (id) => {
99
178
  if (devServer === undefined)
100
- return false;
179
+ return undefined;
101
180
  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;
181
+ return FILE_QUERIES.some((query) => id.endsWith(query)) &&
182
+ !isQueriedFileReadable(devServer.config, id)
183
+ ? { code: "export default null;", moduleType: "js" }
184
+ : undefined;
185
+ if (imported.has(namingGroupOf(id)))
186
+ return undefined;
187
+ throw Object.assign(new Error(`Forgebook serves a group of files only when a module imports it: no module of the project imports ${id.slice(1)}`), { code: MISSING_MODULE });
107
188
  }
108
189
  };
109
190
  };
191
+ /**
192
+ * The plugin's hook for Vitest (`configureVitest`), whose module cache keeps each module's transform on disk between
193
+ * runs: a module read from it resolves no import, so a group it names would stay refused from the second run on.
194
+ */
195
+ export const VITEST_HOOKS = {
196
+ configureVitest: (context) => {
197
+ context.defineCacheKeyGenerator?.(({ sourceCode }) => (namesGroup(sourceCode) ? false : undefined));
198
+ }
199
+ };
@@ -33,7 +33,7 @@ import { currentCloseWatch } from "./closeWatch.js";
33
33
  import { findConfigFile, loadConfigFile, packageResolver } from "./configFile.js";
34
34
  import { codeStamp, diskCache } from "./diskCache.js";
35
35
  import { createDocsExtractor, NO_DOCS_EXTRACTOR } from "./docsExtractor.js";
36
- import { devServerAccess } from "./fileAccess.js";
36
+ import { devServerAccess, VITEST_HOOKS } from "./fileAccess.js";
37
37
  import { FORCED_STATES_VISITOR } from "./forcedStates.js";
38
38
  import { builtPlacement } from "./framesConfig.js";
39
39
  import { serveFrames } from "./framesServer.js";
@@ -137,7 +137,7 @@ export const forgebookPlugin = (options = {}) => {
137
137
  /** The groups' search indexes: any edit may change what a component's docs say. */
138
138
  const searchModules = new Set();
139
139
  /** What a client may read of a dev server. */
140
- const access = devServerAccess(invalidateEverywhere);
140
+ const access = devServerAccess();
141
141
  /** The errors the open frames report, for `forgebook errors` and the agents. */
142
142
  const live = createLiveErrors();
143
143
  /** Where the frames run: the dev server's second port once it listens, the configuration's choice in a build. */
@@ -392,7 +392,10 @@ export const forgebookPlugin = (options = {}) => {
392
392
  resolved.logger.warn(`Forgebook's shell theme: ${problem}`);
393
393
  },
394
394
  configureServer: (server) => {
395
- access.serve(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
+ });
396
399
  // no person waits for a page in a test run
397
400
  watching.serve(isTestRun ? undefined : server);
398
401
  server.httpServer?.on("close", () => void docs?.close());
@@ -466,13 +469,14 @@ export const forgebookPlugin = (options = {}) => {
466
469
  await docs?.close();
467
470
  },
468
471
  ...storyFileHotHooks(isRedrawing, served.changedBy, watching),
472
+ ...VITEST_HOOKS,
469
473
  async load(id) {
470
474
  const generated = await pagesModule(id);
471
475
  if (generated !== undefined)
472
476
  return generated;
473
- // an answer, not an error: an error would raise Vite's overlay on every reader's open page
474
- if (access.isRefused(id))
475
- return { code: "export default null;", moduleType: "js" };
477
+ const refusal = access.refusal(id);
478
+ if (refusal !== undefined)
479
+ return refusal;
476
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
477
481
  const watch = (file) => this.addWatchFile(VITE_PATHS.normalize(file));
478
482
  const resolveImport = async (specifier, importer) => {
@@ -21,6 +21,21 @@ export declare const virtualId: (source: string) => string | undefined;
21
21
  * @param id - the module's id.
22
22
  */
23
23
  export declare const isGroupId: (id: string) => boolean;
24
+ /**
25
+ * The group a project's module names for a group's module: the story files of their "Used in", reach, search index and
26
+ * texts, the docs pages of their search index. Those read the same files and only their group's module imports them, so
27
+ * a client that already holds the group's module needs no other import to have been seen.
28
+ *
29
+ * @param id - a group's module's id.
30
+ */
31
+ export declare const namingGroupOf: (id: string) => string;
32
+ /**
33
+ * Whether a module's text names a group. Its transform must run for the plugin to see the import, so a cache of
34
+ * transforms must not keep it.
35
+ *
36
+ * @param code - the module's text.
37
+ */
38
+ export declare const namesGroup: (code: string) => boolean;
24
39
  /** The resolved id of the configuration's data module. */
25
40
  export declare const CONFIG_ID = "\0virtual:forgebook/config";
26
41
  /** The resolved id of the design tokens' module. */
@@ -70,6 +70,29 @@ export const virtualId = (source) => {
70
70
  * @param id - the module's id.
71
71
  */
72
72
  export const isGroupId = (id) => VIRTUAL_PREFIXES.some((prefix) => id.startsWith(`\0${prefix}`));
73
+ /** The modules that read a group of story files' own files: named once its story files are. */
74
+ const STORY_FILES_READERS = [USAGE_PREFIX, REACH_PREFIX, SEARCH_PREFIX, TEXTS_PREFIX];
75
+ /**
76
+ * The group a project's module names for a group's module: the story files of their "Used in", reach, search index and
77
+ * texts, the docs pages of their search index. Those read the same files and only their group's module imports them, so
78
+ * a client that already holds the group's module needs no other import to have been seen.
79
+ *
80
+ * @param id - a group's module's id.
81
+ */
82
+ export const namingGroupOf = (id) => {
83
+ const pagesSearch = `\0${PAGES_SEARCH_PREFIX}`;
84
+ if (id.startsWith(pagesSearch))
85
+ return `${DOCS_PAGES_PREFIX}${id.slice(pagesSearch.length)}`;
86
+ const reader = STORY_FILES_READERS.map((prefix) => `\0${prefix}`).find((mark) => id.startsWith(mark));
87
+ return reader === undefined ? id : `${FILES_PREFIX}${id.slice(reader.length)}`;
88
+ };
89
+ /**
90
+ * Whether a module's text names a group. Its transform must run for the plugin to see the import, so a cache of
91
+ * transforms must not keep it.
92
+ *
93
+ * @param code - the module's text.
94
+ */
95
+ export const namesGroup = (code) => [FILES_QUERY, DOCS_PAGES_QUERY, ...VIRTUAL_PREFIXES].some((name) => code.includes(name));
73
96
  /** The resolved id of the configuration's data module. */
74
97
  export const CONFIG_ID = `\0${CONFIG_MODULE}`;
75
98
  /** The resolved id of the design tokens' module. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgebook",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "See every component in every theme, contrast, language and width: CSF stories, one frame per story, and a props table from the components' types.",
5
5
  "keywords": [
6
6
  "react",
@@ -240,7 +240,7 @@
240
240
  "lightningcss": "1.33.0",
241
241
  "marked": "18.1.0",
242
242
  "typescript-6": "npm:typescript@6.0.3",
243
- "@forgebook/story-format": "0.2.1"
243
+ "@forgebook/story-format": "0.2.2"
244
244
  },
245
245
  "peerDependencies": {
246
246
  "@testing-library/dom": "^10.4.0",
@@ -262,8 +262,8 @@
262
262
  "sass-embedded": "1.106.1",
263
263
  "vite": "8.3.5",
264
264
  "vitest": "5.0.5",
265
- "@forgebook/tools": "0.0.0",
266
- "@forgebook/typescript-config": "0.0.0"
265
+ "@forgebook/typescript-config": "0.0.0",
266
+ "@forgebook/tools": "0.0.0"
267
267
  },
268
268
  "peerDependenciesMeta": {
269
269
  "@testing-library/dom": {