forgebook 0.4.0 → 0.4.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 (70) hide show
  1. package/README.md +83 -3
  2. package/dist/bin/forgebook/agentText.js +7 -0
  3. package/dist/bin/forgebook/guide/commands.md +5 -0
  4. package/dist/bin/forgebook/guide/stories.md +7 -2
  5. package/dist/bin/forgebook/verifyBase.js +10 -2
  6. package/dist/bin/forgebook/verifyVerdict.js +27 -1
  7. package/dist/src/argControl.d.ts +51 -3
  8. package/dist/src/argTypes.js +4 -2
  9. package/dist/src/componentDocs.d.ts +12 -0
  10. package/dist/src/componentDocs.js +5 -1
  11. package/dist/src/controlCode.d.ts +27 -0
  12. package/dist/src/controlCode.js +41 -0
  13. package/dist/src/frame/controlValues.d.ts +39 -0
  14. package/dist/src/frame/controlValues.js +143 -0
  15. package/dist/src/frame/mountFrame.d.ts +3 -0
  16. package/dist/src/frame/mountFrame.js +8 -10
  17. package/dist/src/frame/panelControls.d.ts +67 -0
  18. package/dist/src/frame/panelControls.js +110 -0
  19. package/dist/src/shell/AuthoredHome.js +3 -1
  20. package/dist/src/shell/CodeTab.d.ts +10 -5
  21. package/dist/src/shell/CodeTab.js +44 -1
  22. package/dist/src/shell/ComponentPage.d.ts +5 -5
  23. package/dist/src/shell/ComponentPage.js +4 -2
  24. package/dist/src/shell/CustomControl.js +14 -21
  25. package/dist/src/shell/HomePage.js +5 -3
  26. package/dist/src/shell/PageWidth.d.ts +29 -0
  27. package/dist/src/shell/PageWidth.js +18 -0
  28. package/dist/src/shell/PropRows.d.ts +2 -12
  29. package/dist/src/shell/PropRows.js +6 -7
  30. package/dist/src/shell/PropsTab.js +2 -3
  31. package/dist/src/shell/Shell.js +5 -2
  32. package/dist/src/shell/StoryPicker.d.ts +4 -2
  33. package/dist/src/shell/StoryPicker.js +3 -3
  34. package/dist/src/shell/Switch.d.ts +29 -0
  35. package/dist/src/shell/Switch.js +11 -0
  36. package/dist/src/shell/customControls.d.ts +7 -0
  37. package/dist/src/shell/customControls.js +12 -1
  38. package/dist/src/shell/dockTabs.js +2 -1
  39. package/dist/src/shell/frameOrder.d.ts +35 -0
  40. package/dist/src/shell/frameOrder.js +45 -0
  41. package/dist/src/shell/laterStrings.d.ts +1 -0
  42. package/dist/src/shell/laterStrings.js +1 -0
  43. package/dist/src/shell/shell.css +36 -9
  44. package/dist/src/shell/useFrameQueue.d.ts +8 -3
  45. package/dist/src/shell/useFrameQueue.js +42 -12
  46. package/dist/src/shell/useScrollSettled.d.ts +18 -0
  47. package/dist/src/shell/useScrollSettled.js +56 -0
  48. package/dist/src/shell/valueEditors/ValueField.js +2 -1
  49. package/dist/src/storyCheck.d.ts +14 -0
  50. package/dist/src/storyCheck.js +16 -0
  51. package/dist/src/storyCodeText.d.ts +8 -2
  52. package/dist/src/storyCodeText.js +8 -5
  53. package/dist/src/storyControls.d.ts +3 -0
  54. package/dist/src/vite/agent.types.d.ts +8 -1
  55. package/dist/src/vite/agentRoutes.js +38 -10
  56. package/dist/src/vite/agentStoryCode.d.ts +3 -0
  57. package/dist/src/vite/agentStoryCode.js +8 -2
  58. package/dist/src/vite/argControlsConfig.d.ts +30 -0
  59. package/dist/src/vite/argControlsConfig.js +176 -0
  60. package/dist/src/vite/catalogueConfig.js +6 -1
  61. package/dist/src/vite/config.d.ts +38 -3
  62. package/dist/src/vite/configFile.d.ts +3 -2
  63. package/dist/src/vite/configFile.js +6 -21
  64. package/dist/src/vite/configKeys.js +2 -1
  65. package/dist/src/vite/forgebookPlugin.js +3 -1
  66. package/dist/src/vite/pages.js +12 -1
  67. package/dist/src/vite/propDocs.js +58 -3
  68. package/dist/src/vite/typedControls.d.ts +31 -0
  69. package/dist/src/vite/typedControls.js +66 -0
  70. package/package.json +2 -2
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A catalogue's custom controls, its own and its presets', checked and merged: what the pages load and what the props
3
+ * docs are matched against.
4
+ *
5
+ * @remarks
6
+ * A preset's control runs in the catalogue's page as the catalogue's own do, so it passes the same checks: a name a story
7
+ * can write that no control of the panel has, and a module named as a path or a package's specifier, never an address
8
+ * (`https:`, `data:`) that would load code from elsewhere. A preset names its files by absolute path, as its preview: a
9
+ * relative path would be read from wherever the catalogue is; nor can it take every prop of a keyword type (`string`). The catalogue's
10
+ * registration of a name replaces a preset's, and `false` removes it; a later preset's replaces an earlier one's. The
11
+ * order kept is the precedence among controls that match the same prop: the catalogue's first, then the presets' in
12
+ * their order.
13
+ *
14
+ * @module
15
+ */
16
+ import { resolve } from "node:path";
17
+ import { CONTROLS } from "../storyArgTypes.js";
18
+ /** The keys a control's option may have. */
19
+ const OPTION_KEYS = ["module", "frame", "matches"];
20
+ /** The keys a match may have. */
21
+ const MATCH_KEYS = ["typeName", "propName"];
22
+ /** The type names TypeScript writes as keywords: a preset that takes one by type alone would take most of a catalogue. */
23
+ const KEYWORD_TYPES = [
24
+ "string",
25
+ "number",
26
+ "boolean",
27
+ "bigint",
28
+ "symbol",
29
+ "object",
30
+ "unknown",
31
+ "any",
32
+ "null",
33
+ "undefined",
34
+ "never",
35
+ "void"
36
+ ];
37
+ /** An address with a scheme (`https:`, `data:`), which would load code from elsewhere; a Windows drive's letter is none. */
38
+ const SCHEME = /^[A-Za-z][\w+.-]+:/;
39
+ /** Whether a value is an object that is not a list. */
40
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
41
+ /**
42
+ * A key of an object no version reads, when it has one.
43
+ *
44
+ * @param value - the object.
45
+ * @param known - its keys.
46
+ */
47
+ const unknownKey = (value, known) => Object.keys(value).find((key) => !known.includes(key));
48
+ /**
49
+ * One field of a match, checked: a text, or none.
50
+ *
51
+ * @param match - the match.
52
+ * @param key - the field.
53
+ * @param where - where it is written, for a problem.
54
+ */
55
+ const matchField = (match, key, where) => {
56
+ const value = Reflect.get(match, key);
57
+ if (value === undefined)
58
+ return undefined;
59
+ if (typeof value !== "string" || value === "")
60
+ throw new Error(`${where}: a match's ${key} is no name`);
61
+ return value;
62
+ };
63
+ /**
64
+ * The matches a control's option writes, checked.
65
+ *
66
+ * @param value - its `matches`.
67
+ * @param where - where it is written, for a problem.
68
+ */
69
+ const matchesOf = (value, where) => {
70
+ if (value === undefined)
71
+ return [];
72
+ return (Array.isArray(value) ? value : [value]).map((match) => {
73
+ if (!isObject(match))
74
+ throw new Error(`${where}: a match is an object ({ typeName } or { propName })`);
75
+ const key = unknownKey(match, MATCH_KEYS);
76
+ if (key !== undefined)
77
+ throw new Error(`${where}: a match has no key "${key}"; it takes typeName, propName`);
78
+ const typeName = matchField(match, "typeName", where);
79
+ const propName = matchField(match, "propName", where);
80
+ if (typeName === undefined && propName === undefined)
81
+ throw new Error(`${where}: a match names a typeName, a propName, or both`);
82
+ return {
83
+ ...(typeName === undefined ? {} : { typeName }),
84
+ ...(propName === undefined ? {} : { propName })
85
+ };
86
+ });
87
+ };
88
+ /**
89
+ * One control, checked and resolved; `false` removes a preset's control of its name.
90
+ *
91
+ * @param name - its name.
92
+ * @param value - what the list writes for it.
93
+ * @param where - where the list is, for a problem.
94
+ * @param place - places a module it names.
95
+ */
96
+ const resolvedControl = (name, value, where, place) => {
97
+ const at = `${where}: "${name}"`;
98
+ if (Object.hasOwn(CONTROLS, name))
99
+ throw new Error(`${at} is a control of the panel already`);
100
+ if (!/^[A-Za-z][\w-]*$/.test(name))
101
+ throw new Error(`${at} is no name a control can have`);
102
+ if (value === false)
103
+ return false;
104
+ if (!isObject(value))
105
+ return { module: place(value, at), matches: [] };
106
+ const key = unknownKey(value, OPTION_KEYS);
107
+ if (key !== undefined)
108
+ throw new Error(`${at} has no key "${key}"; it takes ${OPTION_KEYS.join(", ")}`);
109
+ const frame = Reflect.get(value, "frame");
110
+ return {
111
+ module: place(Reflect.get(value, "module"), at),
112
+ ...(frame === undefined ? {} : { frame: place(frame, `${at}: its frame`) }),
113
+ matches: matchesOf(Reflect.get(value, "matches"), at)
114
+ };
115
+ };
116
+ /**
117
+ * A list's controls, checked and resolved, in its order.
118
+ *
119
+ * @param controls - the list.
120
+ * @param where - where it is, for a problem.
121
+ * @param place - places a module it names.
122
+ */
123
+ const listed = (controls, where, place) => {
124
+ if (controls !== undefined && !isObject(controls))
125
+ throw new Error(`${where} is an object of controls by name`);
126
+ return Object.entries(controls ?? {}).map(([name, value]) => [
127
+ name,
128
+ resolvedControl(name, value, where, place)
129
+ ]);
130
+ };
131
+ /**
132
+ * The catalogue's custom controls and its presets', checked, merged, in their precedence.
133
+ *
134
+ * @param config - the configuration, its presets included.
135
+ * @param dir - its folder.
136
+ * @param paths - the path functions.
137
+ * @throws when a name, a module or a match cannot be used.
138
+ */
139
+ export const resolvedArgControls = (config, dir, paths) => {
140
+ const file = resolve(dir, "forgebook.config");
141
+ const named = (module, where) => {
142
+ if (typeof module !== "string" || module === "")
143
+ throw new Error(`${where} names no module, as a path or a package's specifier`);
144
+ if (SCHEME.test(module))
145
+ throw new Error(`${where} names an address, not a path or a package's specifier`);
146
+ return module;
147
+ };
148
+ const own = listed(config.argControls, `${file}: argControls`, (module, where) => {
149
+ const path = named(module, where);
150
+ return path.startsWith(".") || paths.isAbsolute(path) ? paths.absolute(path) : path;
151
+ });
152
+ // a later preset's control replaces an earlier one's of the same name, where the first was
153
+ const fromPresets = new Map();
154
+ for (const preset of config.presets ?? []) {
155
+ const where = `${file}: its preset "${preset.name}": argControls`;
156
+ for (const [name, control] of listed(preset.argControls, where, (module, at) => {
157
+ const path = named(module, at);
158
+ if (path.startsWith("."))
159
+ throw new Error(`${at}: a preset names a module by absolute path or package`);
160
+ return paths.isAbsolute(path) ? paths.normalize(path) : path;
161
+ })) {
162
+ if (control === false)
163
+ throw new Error(`${where}: "${name}": only a catalogue removes a control`);
164
+ const keyword = control.matches.find((match) => match.propName === undefined && KEYWORD_TYPES.includes(match.typeName ?? ""));
165
+ if (keyword !== undefined)
166
+ throw new Error(`${where}: "${name}": a preset takes no "${keyword.typeName}" prop by its type alone`);
167
+ fromPresets.set(name, control);
168
+ }
169
+ }
170
+ // a removal that removes nothing is a misspelt name
171
+ const removing = own.find(([name, control]) => control === false && !fromPresets.has(name));
172
+ if (removing !== undefined)
173
+ throw new Error(`${file}: argControls: "${removing[0]}" is no preset's control to remove`);
174
+ const ownNames = new Set(own.map(([name]) => name));
175
+ return Object.fromEntries([...own, ...[...fromPresets].filter(([name]) => !ownNames.has(name))].flatMap(([name, control]) => (control === false ? [] : [[name, control]])));
176
+ };
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * What Forgebook's plugin sets in Vite's configuration for a catalogue: its folder as the root, what to pre-bundle, the
3
- * pages as the build's inputs, the build's script names, and the dev server's reads kept to each origin.
3
+ * pages as the build's inputs, the build's script names, no single-page fallback, and the dev server's reads kept to
4
+ * each origin.
4
5
  *
5
6
  * @module
6
7
  */
@@ -68,6 +69,10 @@ export const catalogueConfig = (config, userConfig, findings = {}) => ({
68
69
  : { output: { chunkFileNames: "assets/[hash].js", hashCharacters: "base36" } })
69
70
  }
70
71
  },
72
+ // the plugin serves the catalogue's pages itself, so the only page a single-page fallback could answer with is the
73
+ // app's own `index.html`: a story's missing image would get it, and the server would load the whole app its scripts
74
+ // reach, pre-bundle again what the stories never import, and reload every frame open (unless the app chose its own)
75
+ ...(userConfig.appType === undefined ? { appType: "mpa" } : {}),
71
76
  // Vite lets any localhost page read the server: neither origin needs another's reads, and the guards
72
77
  // would be the only wall left between the frames and the catalogue (unless the app chose its own)
73
78
  ...(config.frames.kind === "isolated" && userConfig.server?.cors === undefined
@@ -32,6 +32,40 @@ export type ForgebookPreset = CatalogueLists & {
32
32
  readonly head?: string;
33
33
  /** The shell's look, under the catalogue's: its theme, and a style sheet by absolute path. */
34
34
  readonly shell?: ShellOptions;
35
+ /**
36
+ * Custom controls of the props panel it brings to every catalogue that lists it, by name, each module by absolute path
37
+ * or a package's specifier: a catalogue's registration of the same name replaces it. They run in the catalogue's page
38
+ * with a plugin's trust, as the catalogue's own do.
39
+ */
40
+ readonly argControls?: Readonly<Record<string, string | ArgControlOption>>;
41
+ };
42
+ /**
43
+ * Which props a custom control takes without a story naming it: each field given must hold, so `{ typeName, propName }`
44
+ * takes only that prop of that type.
45
+ */
46
+ export type ArgControlMatch = {
47
+ /**
48
+ * The type's name as the props are read: as the source writes it (`IconComponent`), or any alias it is declared
49
+ * through (`type ButtonIcon = IconComponent`), without `undefined` and `null`; a union only when every member goes by it.
50
+ */
51
+ readonly typeName?: string;
52
+ /** The prop's name (`icon`). */
53
+ readonly propName?: string;
54
+ };
55
+ /** A custom control with more than its module: its frame module, and the props it takes on its own. */
56
+ export type ArgControlOption = {
57
+ /** The module the panel draws it with, exporting `mount` and `schema`. */
58
+ readonly module: string;
59
+ /**
60
+ * A module of the stories' frames exporting `toStoryValue(value, argType)`, which turns the panel's JSON value into
61
+ * what the story receives (a name into an icon); none gives the story the JSON value.
62
+ */
63
+ readonly frame?: string;
64
+ /**
65
+ * The props it is the control of in every story that names no control for them, by type or by name; one or a list,
66
+ * of which any may match. A match by name wins over one by type, then the catalogue's controls over its presets'.
67
+ */
68
+ readonly matches?: ArgControlMatch | readonly ArgControlMatch[];
35
69
  };
36
70
  /** A tokens file: its path from the configuration's folder, with its theme when it is a theme's. */
37
71
  export type TokensFile = StoryTokensFile;
@@ -82,10 +116,11 @@ export type ForgebookConfig = CatalogueLists & {
82
116
  readonly tokens?: readonly TokensFile[];
83
117
  /**
84
118
  * Custom controls of the props panel, by the name a story's `argTypes` use (`{ rating: "./controls/rating.ts" }`): each a
85
- * module, by path from the configuration's folder or a package's specifier, exporting `mount` and `schema`. It loads when
86
- * a story names it, and runs in the shell's page with a plugin's trust.
119
+ * module, by path from the configuration's folder or a package's specifier, exporting `mount` and `schema`, or an
120
+ * `ArgControlOption` with a frame module and the props it matches. It loads when a story's panel shows it, and runs in
121
+ * the shell's page with a plugin's trust. A name its presets register is replaced; `false` removes theirs.
87
122
  */
88
- readonly argControls?: Readonly<Record<string, string>>;
123
+ readonly argControls?: Readonly<Record<string, string | ArgControlOption | false>>;
89
124
  /** Its preview's path from the configuration's folder; `forgebook.preview.ts` (or `.tsx`) beside it when there. */
90
125
  readonly preview?: string;
91
126
  /** Tags for every frame's `<head>` (a font's link); none by default. */
@@ -15,6 +15,7 @@ import type { ForgebookPlugin } from "../plugin.ts";
15
15
  import { type ViewSwitch } from "../switches.ts";
16
16
  import type { FrameworkAdapter } from "./adapter.ts";
17
17
  import { type ResolvedAppBuild } from "./appBuildOption.ts";
18
+ import { type ResolvedArgControls } from "./argControlsConfig.ts";
18
19
  import type { BuildOptions, ForgebookConfig } from "./config.ts";
19
20
  import { type ResolvedFrames } from "./framesConfig.ts";
20
21
  import { type ResolvedRefs } from "./refsConfig.ts";
@@ -85,8 +86,8 @@ export type ResolvedConfig = {
85
86
  readonly tokens: readonly ResolvedTokensFile[];
86
87
  /** Whether the configuration says `tokens: []`: no system at all, so the application's own files are not read for one. */
87
88
  readonly isAppTokensOff?: boolean;
88
- /** The custom controls' modules by name: a path absolute, a package's specifier as written. */
89
- readonly argControls: Readonly<Record<string, string>>;
89
+ /** The custom controls by name, the catalogue's and its presets', in their precedence. */
90
+ readonly argControls: ResolvedArgControls;
90
91
  /** The folders the dev server watches for new story files, absolute. */
91
92
  readonly watch: readonly string[];
92
93
  /** The lists (widths, backgrounds, story groups…), the presets' and the catalogue's merged. */
@@ -26,10 +26,10 @@ import { catalogueSettings, playProblem } from "../catalogue.js";
26
26
  import { deviceSettingProblem } from "../deviceView.js";
27
27
  import { REQUIRED_FIRST_LAYOUTS } from "../propGroups.js";
28
28
  import { frameThemeCss, mergedThemes, shellThemeCss, shellThemeProblems, themeValueProblem } from "../shellTheme.js";
29
- import { CONTROLS } from "../storyArgTypes.js";
30
29
  import { mergedStatuses, statusesProblem } from "../storyStatuses.js";
31
30
  import { catalogueSwitches, switchesProblem } from "../switches.js";
32
31
  import { appBuildProblem, resolvedAppBuild } from "./appBuildOption.js";
32
+ import { resolvedArgControls } from "./argControlsConfig.js";
33
33
  import { saysNoTokens, unknownKeysProblem } from "./configKeys.js";
34
34
  import { configLoadMessage } from "./configLoadError.js";
35
35
  import { framesProblem, resolvedFrames } from "./framesConfig.js";
@@ -443,7 +443,11 @@ export const resolveConfig = (config, fileDir, paths = VITE_PATHS) => {
443
443
  },
444
444
  tokens,
445
445
  isAppTokensOff: saysNoTokens(config),
446
- argControls: resolvedArgControls(config.argControls, dir, absolute, paths.isAbsolute),
446
+ argControls: resolvedArgControls(config, dir, {
447
+ absolute,
448
+ isAbsolute: paths.isAbsolute,
449
+ normalize: paths.normalize
450
+ }),
447
451
  watch: (config.watch ?? []).map(absolute),
448
452
  lists,
449
453
  hasPlayTools: hasPlayToolsIn(dir),
@@ -454,25 +458,6 @@ export const resolveConfig = (config, fileDir, paths = VITE_PATHS) => {
454
458
  appBuild: resolvedAppBuild(config.appBuild)
455
459
  };
456
460
  };
457
- /**
458
- * The custom controls' modules, each checked: a name a story can write, that no control of the panel already has.
459
- *
460
- * @param controls - what the configuration writes.
461
- * @param dir - the configuration's folder.
462
- * @param absolute - a path from that folder, made absolute.
463
- * @param isAbsolutePath - whether a path is one already.
464
- * @throws when a name or a module cannot be used.
465
- */
466
- const resolvedArgControls = (controls, dir, absolute, isAbsolutePath) => Object.fromEntries(Object.entries(controls ?? {}).map(([name, module]) => {
467
- const where = `${resolve(dir, "forgebook.config")}: argControls`;
468
- if (Object.hasOwn(CONTROLS, name))
469
- throw new Error(`${where}: "${name}" is a control of the panel already`);
470
- if (!/^[A-Za-z][\w-]*$/.test(name))
471
- throw new Error(`${where}: "${name}" is no name a control can have`);
472
- if (typeof module !== "string" || module === "")
473
- throw new Error(`${where}: "${name}" names no module, as a path or a package's specifier`);
474
- return [name, module.startsWith(".") || isAbsolutePath(module) ? absolute(module) : module];
475
- }));
476
461
  /**
477
462
  * Loads a configuration file and resolves it.
478
463
  *
@@ -77,7 +77,8 @@ const PRESET_KEYS = {
77
77
  languages: true,
78
78
  preview: true,
79
79
  head: true,
80
- shell: true
80
+ shell: true,
81
+ argControls: true
81
82
  };
82
83
  const BUILD_KEYS = { readableNames: true };
83
84
  const APP_BUILD_KEYS = {
@@ -53,6 +53,7 @@ import { scriptProblems } from "./scriptProblems.js";
53
53
  import { isKeptIndex } from "./storyFilesModule.js";
54
54
  import { tokensModule } from "./storylessRoutes.js";
55
55
  import { DOCS_QUERY, RENEWED_QUERIES, TEXT_QUERIES } from "./textQueries.js";
56
+ import { withTypedControls } from "./typedControls.js";
56
57
  import { VITE_PATHS } from "./vitePaths.js";
57
58
  /** This module's folder, in the core's package: the core's own dependencies resolve from here, not the project's. */
58
59
  const PACKAGE_DIR = import.meta.dirname;
@@ -131,7 +132,8 @@ export const forgebookPlugin = (options = {}) => {
131
132
  const isServing = () => command === "serve";
132
133
  // the search's indexes, a build reading each component's docs once for its page and its index
133
134
  const search = searchIndexes({
134
- extract: (storyFile) => extractor().extract(storyFile),
135
+ // the catalogue's controls by type are written into the docs here, so every reader of them sees the same panel
136
+ extract: async (storyFile) => withTypedControls(await extractor().extract(storyFile), config?.argControls ?? {}),
135
137
  isServing,
136
138
  track: (id, isPages) => (isPages ? docsModules : searchModules).add(id),
137
139
  isKept: access.isKept
@@ -365,10 +365,20 @@ const argControlLines = (config) => {
365
365
  ? []
366
366
  : [
367
367
  " argControls: {",
368
- ...controls.map(([name, module]) => ` ${codeLiteral(name)}: () => import(${codeLiteral(module)}),`),
368
+ ...controls.map(([name, { module }]) => ` ${codeLiteral(name)}: () => import(${codeLiteral(module)}),`),
369
369
  " },"
370
370
  ];
371
371
  };
372
+ /**
373
+ * The frame's list of the custom controls' frame modules, each with the import that loads it when the shell first gives
374
+ * the story a value of that control; nothing for a catalogue whose controls have none.
375
+ *
376
+ * @param config - the configuration.
377
+ */
378
+ const argControlFrameLines = (config) => {
379
+ const frames = Object.entries(config.argControls).flatMap(([name, { frame }]) => frame === undefined ? [] : [` ${codeLiteral(name)}: () => import(${codeLiteral(frame)}),`]);
380
+ return frames.length === 0 ? [] : [" argControls: {", ...frames, " },"];
381
+ };
372
382
  /**
373
383
  * The shell's entry: the shell mounted with the configuration's data, the logo, the sources and the code writer.
374
384
  *
@@ -482,6 +492,7 @@ export const frameEntry = (config, axe) => {
482
492
  " lists: config.lists,",
483
493
  " frames: config.frames,",
484
494
  ...(config.hasPlayTools ? [playToolsLoader()] : []),
495
+ ...argControlFrameLines(config),
485
496
  ...(frames.length === 0
486
497
  ? []
487
498
  : [
@@ -216,6 +216,57 @@ const declaredMembers = (node) => {
216
216
  const bare = bareType(node);
217
217
  return (ts.isUnionTypeNode(bare) ? bare.types : [bare]).filter((member) => !isNothing(member));
218
218
  };
219
+ /**
220
+ * The names a type node goes by: a plain reference's name as written, the name it was declared with (an import may
221
+ * rename it), and the names of the aliases it is declared through (`type ButtonIcon = IconComponent`); for a union, the
222
+ * names every member goes by, without `undefined`, `null` and `never`. A generic's reference (`ComponentType<Props>`) and a
223
+ * type parameter name no type of their own, so they end the walk.
224
+ *
225
+ * @param node - the type node.
226
+ * @param checker - the type checker, to follow a name to its declaration.
227
+ * @param seen - the nodes already walked, so a recursive alias ends.
228
+ */
229
+ const typeNamesOf = (node, checker, seen) => {
230
+ const bare = bareType(node);
231
+ if (seen.has(bare))
232
+ return [];
233
+ const walked = new Set([...seen, bare]);
234
+ if (ts.isUnionTypeNode(bare))
235
+ return sharedNames(declaredMembers(bare), checker, walked);
236
+ if (!ts.isTypeReferenceNode(bare) || bare.typeArguments !== undefined)
237
+ return [];
238
+ const symbol = declaredSymbol(checker.getSymbolAtLocation(bare.typeName), checker);
239
+ if (symbol === undefined || symbol.flags & ts.SymbolFlags.TypeParameter)
240
+ return [];
241
+ // a default export's symbol is named `default`: its declaration keeps the name it was written with
242
+ const declared = ts.getNameOfDeclaration(symbol.declarations?.[0])?.getText() ?? bare.typeName.getText();
243
+ const alias = symbol.declarations?.find(ts.isTypeAliasDeclaration);
244
+ const through = alias !== undefined && !alias.typeParameters ? typeNamesOf(alias.type, checker, walked) : [];
245
+ return [...new Set([bare.typeName.getText(), declared, ...through])];
246
+ };
247
+ /**
248
+ * The names every one of several type nodes goes by, in the first one's order.
249
+ *
250
+ * @param nodes - the type nodes: a union's members, or a prop's declarations.
251
+ * @param checker - the type checker.
252
+ * @param seen - the nodes already walked.
253
+ */
254
+ const sharedNames = (nodes, checker, seen) => {
255
+ const [first, ...others] = nodes.map((node) => typeNamesOf(node, checker, seen));
256
+ return (first ?? []).filter((name) => others.every((names) => names.includes(name)));
257
+ };
258
+ /**
259
+ * The names a prop's declared types all go by: what a custom control's `matches` reads. A branch of the props where the
260
+ * prop is `never` takes no value, so it names no type; a method names none.
261
+ *
262
+ * @param declared - the prop's declared types.
263
+ * @param checker - the program's checker.
264
+ */
265
+ const writtenTypeNames = (declared, checker) => {
266
+ const taking = declared.filter((node) => ts.isMethodSignature(node) || declaredMembers(node).length > 0);
267
+ const nodes = taking.flatMap((node) => (ts.isMethodSignature(node) ? [] : [node]));
268
+ return nodes.length === taking.length ? sharedNames(nodes, checker, new Set()) : [];
269
+ };
219
270
  /**
220
271
  * A prop's declared type nodes: a property's type (in a type or a class) or a method's whole signature, once per
221
272
  * declaration. A prop of props written as alternatives (`{ indicator?: never } | { indicator?: "dot" | "spinner" }`)
@@ -398,12 +449,16 @@ export const propDocs = (symbol, checker, node, reading = {}) => {
398
449
  const canTakeText = kind === "other" &&
399
450
  [type, ...variantTypes].every((each) => checker.isTypeAssignableTo(checker.getStringType(), each));
400
451
  const notes = propTypeNotes(declared, type, checker, generic);
452
+ const typeText = choices === undefined
453
+ ? (writtenText(declared, generic, checker) ?? checker.typeToString(type))
454
+ : choiceTypeText(choices);
455
+ const typeNames = writtenTypeNames(declared, checker);
401
456
  return {
402
457
  name,
403
458
  kind,
404
- type: choices === undefined
405
- ? (writtenText(declared, generic, checker) ?? checker.typeToString(type))
406
- : choiceTypeText(choices),
459
+ type: typeText,
460
+ // only when they say more than the type's text
461
+ ...(typeNames.some((each) => each !== typeText) ? { typeNames } : {}),
407
462
  description: summaryOf(symbol, checker),
408
463
  isRequired: (symbol.flags & ts.SymbolFlags.Optional) === 0,
409
464
  ...(defaultValue === undefined ? {} : { defaultValue }),
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The custom controls a catalogue gives props by their type or their name (a control's `matches`), written into a
3
+ * component's docs as the plugin serves them: the panel, the address's check and the agents then read the control from
4
+ * the prop, as if every story had named it. Pure.
5
+ *
6
+ * @remarks
7
+ * Done on the server, where the docs are made, so the shell's first load carries no matching: a prop's `customControl`
8
+ * is read as a story's `argTypes` control is. Only props the component has get one, so no story ever gains a prop. A
9
+ * story whose `argTypes` name a control for the prop keeps its own (`argTypes.ts`). Among the controls that match a prop,
10
+ * a match by name wins over a match by type alone, then the first in the catalogue's order.
11
+ *
12
+ * @module
13
+ */
14
+ import type { PropDocs } from "../componentDocs.ts";
15
+ import type { StoryComponentResult } from "./adapter.ts";
16
+ import type { ResolvedArgControls } from "./argControlsConfig.ts";
17
+ /**
18
+ * The control a catalogue gives a prop by its type or its name, when one does.
19
+ *
20
+ * @param prop - the prop, as its docs read it.
21
+ * @param controls - the catalogue's custom controls, in their precedence.
22
+ * @returns the control's name; none when no match fits, or for an event.
23
+ */
24
+ export declare const matchedControlOf: (prop: Pick<PropDocs, "name" | "kind" | "type" | "typeNames">, controls: ResolvedArgControls) => string | undefined;
25
+ /**
26
+ * A story file's component docs with the controls the catalogue gives its props; as it is when no control matches.
27
+ *
28
+ * @param result - what the framework read of the story file.
29
+ * @param controls - the catalogue's custom controls.
30
+ */
31
+ export declare const withTypedControls: (result: StoryComponentResult, controls: ResolvedArgControls) => StoryComponentResult;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * How well a match fits a prop: 2 when it names the prop, 1 when it names only its type, 0 when it does not fit.
3
+ *
4
+ * @param match - the match.
5
+ * @param prop - the prop.
6
+ */
7
+ const fitOf = (match, prop) => {
8
+ const names = prop.typeNames ?? [prop.type];
9
+ if (match.typeName !== undefined && !names.includes(match.typeName))
10
+ return 0;
11
+ if (match.propName !== undefined)
12
+ return match.propName === prop.name ? 2 : 0;
13
+ return 1;
14
+ };
15
+ /**
16
+ * The control a catalogue gives a prop by its type or its name, when one does.
17
+ *
18
+ * @param prop - the prop, as its docs read it.
19
+ * @param controls - the catalogue's custom controls, in their precedence.
20
+ * @returns the control's name; none when no match fits, or for an event.
21
+ */
22
+ export const matchedControlOf = (prop, controls) => {
23
+ if (prop.kind === "event")
24
+ return undefined;
25
+ let best;
26
+ for (const [name, control] of Object.entries(controls)) {
27
+ const fit = Math.max(0, ...control.matches.map((match) => fitOf(match, prop)));
28
+ // the first of equal fits keeps its place: the catalogue's controls come before its presets'
29
+ if (fit > (best?.fit ?? 0))
30
+ best = { name, fit };
31
+ }
32
+ return best?.name;
33
+ };
34
+ /**
35
+ * A prop with the control a catalogue gives it: the control decides what its value is, so the type's shape, text,
36
+ * choices and tokens no longer draw nor check it, and it is no longer a choice a matrix can take, as for a control a
37
+ * story names.
38
+ *
39
+ * @param prop - the prop.
40
+ * @param controls - the catalogue's custom controls.
41
+ */
42
+ const withMatchedControl = (prop, controls) => {
43
+ const customControl = matchedControlOf(prop, controls);
44
+ if (customControl === undefined)
45
+ return prop;
46
+ const { shape: _shape, canTakeText: _canTakeText, choices: _choices, token: _token, ...rest } = prop;
47
+ return { ...rest, kind: "other", customControl };
48
+ };
49
+ /**
50
+ * A story file's component docs with the controls the catalogue gives its props; as it is when no control matches.
51
+ *
52
+ * @param result - what the framework read of the story file.
53
+ * @param controls - the catalogue's custom controls.
54
+ */
55
+ export const withTypedControls = (result, controls) => {
56
+ // a story file whose meta names no component has no props to match
57
+ if (typeof result === "string" || !("file" in result))
58
+ return result;
59
+ if (!Object.values(controls).some((control) => control.matches.length > 0))
60
+ return result;
61
+ const { docs } = result;
62
+ return {
63
+ ...result,
64
+ docs: { ...docs, props: docs.props.map((prop) => withMatchedControl(prop, controls)) }
65
+ };
66
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgebook",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",
@@ -265,7 +265,7 @@
265
265
  "marked": "18.1.0",
266
266
  "typescript-6": "npm:typescript@6.0.3",
267
267
  "vite": "^8.3.4",
268
- "@forgebook/story-format": "0.3.0"
268
+ "@forgebook/story-format": "0.3.1"
269
269
  },
270
270
  "peerDependencies": {
271
271
  "@testing-library/dom": "^10.4.0",