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
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Which frames of a page of many stories may load: those near the visible part of the page, a few at a time, so the
3
- * browser is never asked for thousands of modules at once.
2
+ * Which frames of a page of many stories may load: those near the visible part of the page, nearest first, a few at a
3
+ * time, so the browser is never asked for thousands of modules at once.
4
4
  *
5
5
  * @remarks
6
6
  * In development every frame loads its sources' modules one by one (a few hundred requests); twenty frames at once
7
7
  * exhaust the browser ("insufficient resources") and stories stay blank. A frame loads once its story is near the
8
8
  * visible part of the scrolling page and fewer than `MAX_LOADING` frames are still loading; once loaded it stays.
9
9
  *
10
+ * The reader may be anywhere on the page, so a free place goes to the waiting story nearest the visible part, not to
11
+ * the first one in the page's order (`frameOrder.ts`); and no frame starts while the page scrolls fast
12
+ * (`useScrollSettled.ts`), so the stories a scroll flies past never hold a place the stories where it stops need. A
13
+ * frame already loading finishes wherever the page goes.
14
+ *
10
15
  * @module
11
16
  */
12
17
  import { type RefObject } from "react";
@@ -21,7 +26,7 @@ export type FrameQueue = {
21
26
  readonly observerOf: (storyId: string, place?: number) => (element: HTMLElement | null) => void;
22
27
  /** Records a story whose frame finished loading. */
23
28
  readonly markReady: (storyId: string) => void;
24
- /** Loads every frame of the page, wherever it is, still a few at a time. */
29
+ /** Loads every frame of the page, wherever it is, still a few at a time and nearest first. */
25
30
  readonly loadAll: () => void;
26
31
  };
27
32
  /**
@@ -1,19 +1,44 @@
1
1
  /**
2
- * Which frames of a page of many stories may load: those near the visible part of the page, a few at a time, so the
3
- * browser is never asked for thousands of modules at once.
2
+ * Which frames of a page of many stories may load: those near the visible part of the page, nearest first, a few at a
3
+ * time, so the browser is never asked for thousands of modules at once.
4
4
  *
5
5
  * @remarks
6
6
  * In development every frame loads its sources' modules one by one (a few hundred requests); twenty frames at once
7
7
  * exhaust the browser ("insufficient resources") and stories stay blank. A frame loads once its story is near the
8
8
  * visible part of the scrolling page and fewer than `MAX_LOADING` frames are still loading; once loaded it stays.
9
9
  *
10
+ * The reader may be anywhere on the page, so a free place goes to the waiting story nearest the visible part, not to
11
+ * the first one in the page's order (`frameOrder.ts`); and no frame starts while the page scrolls fast
12
+ * (`useScrollSettled.ts`), so the stories a scroll flies past never hold a place the stories where it stops need. A
13
+ * frame already loading finishes wherever the page goes.
14
+ *
10
15
  * @module
11
16
  */
12
17
  import { useCallback, useEffect, useRef, useState } from "react";
18
+ import { distanceFromView, nearestFrames } from "./frameOrder.js";
19
+ import { useScrollSettled } from "./useScrollSettled.js";
13
20
  /** The most frames loading at once. */
14
21
  const MAX_LOADING = 2;
15
22
  /** How far beyond the visible part a story counts as near, as an IntersectionObserver margin. */
16
23
  const NEAR_MARGIN = "600px 0px";
24
+ /**
25
+ * How far each waiting story is from the visible part of the page: its nearest place, for a story shown twice.
26
+ *
27
+ * @param waiting - the stories waiting for their frame.
28
+ * @param elements - the elements that hold the page's frames, with their story.
29
+ * @param root - the element that scrolls the page; the window when there is none.
30
+ */
31
+ const distancesOf = (waiting, elements, root) => {
32
+ const view = root?.getBoundingClientRect() ?? { top: 0, bottom: window.innerHeight };
33
+ const distances = new Map();
34
+ for (const [element, storyId] of elements) {
35
+ if (!waiting.has(storyId))
36
+ continue;
37
+ const distance = distanceFromView(element.getBoundingClientRect(), view);
38
+ distances.set(storyId, Math.min(distance, distances.get(storyId) ?? distance));
39
+ }
40
+ return distances;
41
+ };
17
42
  /**
18
43
  * The queue of a page's frames.
19
44
  *
@@ -30,6 +55,7 @@ export const useFrameQueue = (storyIds, root) => {
30
55
  const observerRef = useRef(undefined);
31
56
  const elements = useRef(new Map());
32
57
  const observers = useRef(new Map());
58
+ const isSettled = useScrollSettled(root);
33
59
  useEffect(() => {
34
60
  const observer = new IntersectionObserver((entries) => setNear((current) => {
35
61
  const next = new Set(current);
@@ -46,17 +72,21 @@ export const useFrameQueue = (storyIds, root) => {
46
72
  observerRef.current = observer;
47
73
  return () => observer.disconnect();
48
74
  }, [root]);
49
- // a few more frames whenever a story comes near or a frame finishes loading; a frame the page no longer lists never
50
- // finishes, and must not hold a place
75
+ // a few more frames whenever a story comes near, a frame finishes loading or the page stops flying; a frame the page
76
+ // no longer lists never finishes, and must not hold a place
51
77
  useEffect(() => {
52
- setMounted((current) => {
53
- const nearIds = new Set([...near].map((element) => elements.current.get(element)));
54
- const loading = [...current].filter((id) => storyIds.includes(id) && !ready.has(id)).length;
55
- const waiting = storyIds.filter((id) => (isEveryStoryNear || nearIds.has(id)) && !current.has(id));
56
- const next = waiting.slice(0, Math.max(0, MAX_LOADING - loading));
57
- return next.length === 0 ? current : new Set([...current, ...next]);
58
- });
59
- }, [storyIds, near, ready, isEveryStoryNear]);
78
+ if (!isSettled)
79
+ return;
80
+ const free = MAX_LOADING - [...mounted].filter((id) => storyIds.includes(id) && !ready.has(id)).length;
81
+ if (free <= 0)
82
+ return;
83
+ const nearIds = new Set([...near].map((element) => elements.current.get(element)));
84
+ const waiting = storyIds.filter((id) => (isEveryStoryNear || nearIds.has(id)) && !mounted.has(id));
85
+ if (waiting.length === 0)
86
+ return;
87
+ const next = nearestFrames(waiting, distancesOf(new Set(waiting), elements.current, root.current), free);
88
+ setMounted(new Set([...mounted, ...next]));
89
+ }, [storyIds, near, ready, isEveryStoryNear, isSettled, mounted, root]);
60
90
  const observerOf = useCallback((storyId, place = 0) => {
61
91
  const key = `${storyId}\n${place}`;
62
92
  const known = observers.current.get(key);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Whether a scrolling page has stopped flying past its content, for the frame queue (`useFrameQueue`).
3
+ *
4
+ * @remarks
5
+ * A fast scroll to the end of a long page passes over stories the reader never looks at: a frame started for one of
6
+ * them takes a loading place from the stories where the scroll stops. The page counts as unsettled while it scrolls
7
+ * faster than `FAST_SCROLL`, and settles `SETTLE_MS` after its last fast step; a slow scroll, a reader's, stays
8
+ * settled, so stories keep loading as they come near.
9
+ *
10
+ * @module
11
+ */
12
+ import { type RefObject } from "react";
13
+ /**
14
+ * Whether the page is settled: true unless it scrolled fast in the last `SETTLE_MS`.
15
+ *
16
+ * @param root - the element that scrolls the page; the hook reads nothing when it is not there.
17
+ */
18
+ export declare const useScrollSettled: (root: RefObject<HTMLElement | null>) => boolean;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Whether a scrolling page has stopped flying past its content, for the frame queue (`useFrameQueue`).
3
+ *
4
+ * @remarks
5
+ * A fast scroll to the end of a long page passes over stories the reader never looks at: a frame started for one of
6
+ * them takes a loading place from the stories where the scroll stops. The page counts as unsettled while it scrolls
7
+ * faster than `FAST_SCROLL`, and settles `SETTLE_MS` after its last fast step; a slow scroll, a reader's, stays
8
+ * settled, so stories keep loading as they come near.
9
+ *
10
+ * @module
11
+ */
12
+ import { useEffect, useState } from "react";
13
+ /** The scroll speed above which the page flies past its stories, in CSS px per millisecond: 2000 px a second. */
14
+ const FAST_SCROLL = 2;
15
+ /** How long after its last fast step the page counts as settled, in milliseconds. */
16
+ const SETTLE_MS = 150;
17
+ /**
18
+ * The longest time read between two scroll events, in milliseconds: a jump after a pause (the End key, a link to a
19
+ * story further down) counts as fast, not as a long slow step.
20
+ */
21
+ const LONGEST_STEP_MS = 100;
22
+ /**
23
+ * Whether the page is settled: true unless it scrolled fast in the last `SETTLE_MS`.
24
+ *
25
+ * @param root - the element that scrolls the page; the hook reads nothing when it is not there.
26
+ */
27
+ export const useScrollSettled = (root) => {
28
+ const [isSettled, setIsSettled] = useState(true);
29
+ useEffect(() => {
30
+ const element = root.current;
31
+ if (element === null)
32
+ return undefined;
33
+ let lastTop = element.scrollTop;
34
+ let lastTime = performance.now();
35
+ let timer;
36
+ const onScroll = () => {
37
+ const now = performance.now();
38
+ const step = Math.min(Math.max(now - lastTime, 1), LONGEST_STEP_MS);
39
+ const speed = Math.abs(element.scrollTop - lastTop) / step;
40
+ lastTop = element.scrollTop;
41
+ lastTime = now;
42
+ if (speed <= FAST_SCROLL)
43
+ return;
44
+ setIsSettled(false);
45
+ clearTimeout(timer);
46
+ timer = setTimeout(() => setIsSettled(true), SETTLE_MS);
47
+ };
48
+ element.addEventListener("scroll", onScroll, { passive: true });
49
+ return () => {
50
+ element.removeEventListener("scroll", onScroll);
51
+ clearTimeout(timer);
52
+ setIsSettled(true);
53
+ };
54
+ }, [root]);
55
+ return isSettled;
56
+ };
@@ -10,6 +10,7 @@ import { useState } from "react";
10
10
  import { isArgList, isArgObject, isDateArg, isNamedArg } from "../../messages.js";
11
11
  import { isValueOfShape } from "../../shapeValues.js";
12
12
  import { MOST_PLAIN_OPTIONS } from "../PropFields.js";
13
+ import { Switch } from "../Switch.js";
13
14
  import { STRINGS } from "../strings.js";
14
15
  import { EDITOR_STRINGS } from "./editorStrings.js";
15
16
  import { JsonEditor } from "./JsonEditor.js";
@@ -134,7 +135,7 @@ export const ValueField = (props) => {
134
135
  case "date":
135
136
  return _jsx(DateLeaf, { ...props });
136
137
  case "boolean":
137
- return (_jsx("input", { type: "checkbox", "aria-label": props.label, "aria-describedby": props.describedBy, checked: shape.only ?? props.value === true, disabled: shape.only !== undefined, onChange: (event) => props.onChange(event.target.checked) }));
138
+ return (_jsx(Switch, { label: props.label, isOn: shape.only ?? props.value === true, describedBy: props.describedBy, disabled: shape.only !== undefined, onChange: props.onChange }));
138
139
  case "choice":
139
140
  return _jsx(ChoiceLeaf, { ...props, shape: shape });
140
141
  case "named":
@@ -28,6 +28,18 @@ export type StoryProblem = {
28
28
  /** What is wrong, as the catalogue's tests say it. */
29
29
  readonly message: string;
30
30
  };
31
+ /**
32
+ * Something a check found that does not fail the story: a static file it asks for (an image, a font) that answers 404.
33
+ * The story still runs and a person sees what is missing, so it is said, not counted.
34
+ */
35
+ export type StoryWarning = {
36
+ /** What it is. */
37
+ readonly kind: "missing-file";
38
+ /** The file's path, with its query: the same in every run. */
39
+ readonly path: string;
40
+ /** What it means, in a sentence. */
41
+ readonly message: string;
42
+ };
31
43
  /** A story to check: its id, and the height its scene declares. */
32
44
  export type StoryCheckStory = {
33
45
  /** The story's id. */
@@ -91,6 +103,8 @@ export type StoryCheckResult = {
91
103
  readonly frame: string;
92
104
  /** The problems; none when it passes. */
93
105
  readonly problems: readonly StoryProblem[];
106
+ /** What was found that does not fail it: its missing static files. Absent when there is none. */
107
+ readonly warnings?: readonly StoryWarning[];
94
108
  /**
95
109
  * Why the story's checks did not run: its frame gave up waiting for what it loads, or cannot tell (status `pending`),
96
110
  * so no check saw the story in its final state. Absent when they ran; present, the story has not passed, whatever
@@ -31,6 +31,19 @@ const isProblem = (value) => typeof value === "object" &&
31
31
  KINDS.some((kind) => kind === value.kind) &&
32
32
  "message" in value &&
33
33
  typeof value.message === "string";
34
+ /**
35
+ * Whether a value is a warning.
36
+ *
37
+ * @param value - the value.
38
+ */
39
+ const isWarning = (value) => typeof value === "object" &&
40
+ value !== null &&
41
+ "kind" in value &&
42
+ value.kind === "missing-file" &&
43
+ "path" in value &&
44
+ typeof value.path === "string" &&
45
+ "message" in value &&
46
+ typeof value.message === "string";
34
47
  /**
35
48
  * Whether a value is a result.
36
49
  *
@@ -47,6 +60,9 @@ const isResult = (value) => typeof value === "object" &&
47
60
  "problems" in value &&
48
61
  Array.isArray(value.problems) &&
49
62
  value.problems.every(isProblem) &&
63
+ (!("warnings" in value) ||
64
+ value.warnings === undefined ||
65
+ (Array.isArray(value.warnings) && value.warnings.every(isWarning))) &&
50
66
  (!("notVerified" in value) || value.notVerified === undefined || typeof value.notVerified === "string") &&
51
67
  (!("isNotAwaited" in value) || value.isNotAwaited === undefined || typeof value.isNotAwaited === "boolean");
52
68
  /**
@@ -24,6 +24,11 @@ export type StoryCodeSource = {
24
24
  readonly args: PanelArgs;
25
25
  /** The args the panel or an agent set, written from their values; none by default. */
26
26
  readonly edited?: readonly string[];
27
+ /**
28
+ * The code a custom control writes some values as, by arg (`controlCode.ts`): used where the file writes no code of
29
+ * its own for the arg, or the arg was set; none by default.
30
+ */
31
+ readonly argCode?: Readonly<Record<string, string>>;
27
32
  };
28
33
  /**
29
34
  * The story's render as written, when it has one.
@@ -32,11 +37,12 @@ export type StoryCodeSource = {
32
37
  */
33
38
  export declare const renderCodeOf: ({ code, exportName }: StoryCodeSource) => StoryCode | undefined;
34
39
  /**
35
- * The args the story's file writes as code, but those the panel or an agent set.
40
+ * The args written as code: those the story's file writes, but those the panel or an agent set, over those a control
41
+ * writes.
36
42
  *
37
43
  * @param source - the file's code, the story and what was set.
38
44
  */
39
- export declare const writtenArgsOf: ({ code, exportName, edited }: StoryCodeSource) => Readonly<Record<string, string>>;
45
+ export declare const writtenArgsOf: ({ code, exportName, edited, argCode }: StoryCodeSource) => Readonly<Record<string, string>>;
40
46
  /**
41
47
  * The story's code as text, as copied.
42
48
  *
@@ -8,15 +8,18 @@ export const renderCodeOf = ({ code, exportName }) => {
8
8
  return entry !== undefined && "text" in entry ? entry : undefined;
9
9
  };
10
10
  /**
11
- * The args the story's file writes as code, but those the panel or an agent set.
11
+ * The args written as code: those the story's file writes, but those the panel or an agent set, over those a control
12
+ * writes.
12
13
  *
13
14
  * @param source - the file's code, the story and what was set.
14
15
  */
15
- export const writtenArgsOf = ({ code, exportName, edited = [] }) => {
16
+ export const writtenArgsOf = ({ code, exportName, edited = [], argCode = {} }) => {
16
17
  const entry = code[exportName];
17
- if (entry === undefined || !("args" in entry))
18
- return {};
19
- return Object.fromEntries(Object.entries(entry.args).filter(([name]) => !edited.includes(name)));
18
+ const own = entry === undefined || !("args" in entry) ? {} : entry.args;
19
+ return {
20
+ ...argCode,
21
+ ...Object.fromEntries(Object.entries(own).filter(([name]) => !edited.includes(name)))
22
+ };
20
23
  };
21
24
  /**
22
25
  * The story's code as text, as copied.
@@ -10,6 +10,7 @@ import { type AddressTexts } from "./addressTexts.ts";
10
10
  import type { ValueSchema } from "./argControl.ts";
11
11
  import { type PanelArgTypes, type PanelControl, type TokenRequest } from "./argTypes.ts";
12
12
  import type { PropDocs } from "./componentDocs.ts";
13
+ import type { ControlCoders } from "./controlCode.ts";
13
14
  import { type ArgValue, type PanelArgs } from "./messages.ts";
14
15
  import type { NamedValues } from "./namedValues.ts";
15
16
  import { type ValueShape } from "./valueShape.ts";
@@ -88,6 +89,8 @@ export declare const checkedSetArgs: (text: string, declaration: StoryDeclaratio
88
89
  export type CustomControlsRead = {
89
90
  /** The schemas, by control. */
90
91
  readonly schemas: CustomSchemas;
92
+ /** What writes each control's values as code, by control, those whose module could be read. */
93
+ readonly coders: ControlCoders;
91
94
  /** Why a control could not be read, by control. */
92
95
  readonly problems: Readonly<Record<string, string>>;
93
96
  };
@@ -16,7 +16,7 @@ import type { LiveError } from "../liveErrors.ts";
16
16
  import type { PluginCondition } from "../plugin.ts";
17
17
  import type { SearchHitKind, SearchReason, SearchResult } from "../search.ts";
18
18
  import type { SearchCorrection } from "../searchTypos.ts";
19
- import type { StoryCheckResult, StoryDiffStatus } from "../storyCheck.ts";
19
+ import type { StoryCheckResult, StoryDiffStatus, StoryWarning } from "../storyCheck.ts";
20
20
  import type { SetArgs, StoryControl } from "../storyControls.ts";
21
21
  import type { SwitchControlKind, SwitchPreview, SwitchRole } from "../switches.ts";
22
22
  import type { CodeAdvice, CodeProblem, CodeRule, ExcusedValue } from "./checkCode.ts";
@@ -469,6 +469,11 @@ export type VerifyFailure = {
469
469
  /** The links that open the story; those of a server started for the run would be dead by now. */
470
470
  readonly links?: StoryLinks;
471
471
  };
472
+ /** A static file the change's stories asked for that is not there: said, never counted against the change. */
473
+ export type VerifyWarning = StoryWarning & {
474
+ /** The stories that asked for it, each once. */
475
+ readonly stories: readonly string[];
476
+ };
472
477
  /** The one answer to whether a change is verified. */
473
478
  export type VerifyDocument = {
474
479
  /** The shape's version: this shape is not the first `verify` answered. */
@@ -499,6 +504,8 @@ export type VerifyDocument = {
499
504
  readonly failures: readonly VerifyFailure[];
500
505
  /** The pairs no check saw in their final state: their frame gave up on what the story loads. None of them passed. */
501
506
  readonly notVerified: readonly VerifyNotVerified[];
507
+ /** What the stories found that does not fail the change, one per file: static files that answer 404. */
508
+ readonly warnings: readonly VerifyWarning[];
502
509
  /** What the change adds, by `check-code`. */
503
510
  readonly code: {
504
511
  /** What was checked. */
@@ -1,5 +1,6 @@
1
1
  import { createIdResolver } from "vite";
2
2
  import { offersUsedIn, readsComponentDocs } from "../catalogue.js";
3
+ import { isComponentDocs } from "../componentDocs.js";
3
4
  import { mergedPreviews, NO_PREVIEW, readPreview } from "../frame/storyModule.js";
4
5
  import { sourceFiles } from "../sourceFiles.js";
5
6
  import { catalogueStatuses } from "../storyStatuses.js";
@@ -165,15 +166,16 @@ export const storyAnswer = async (context) => {
165
166
  // the story's own file: its module, and the docs of the component its meta names, which its args are for
166
167
  const file = location.story.file;
167
168
  const storyModule = await settled(files.stories[file]);
169
+ const docs = await settled(files.docs[file]);
168
170
  const parts = {
169
171
  module: storyModule,
170
- docs: await settled(files.docs[file]),
172
+ docs,
171
173
  preview: loaded.preview,
172
174
  namedValues: source.namedValues,
173
175
  setArgs: params.get("args") ?? undefined,
174
176
  customControls: context.server === undefined
175
177
  ? undefined
176
- : await customControlsRead(customControlNames(storyModule, location), config, context.server),
178
+ : await customControlsRead([...customControlNames(storyModule, location), ...typedControlNames(docs)], config, context.server),
177
179
  addressTexts: config.framework.addressTexts
178
180
  };
179
181
  const code = context.server === undefined
@@ -181,7 +183,8 @@ export const storyAnswer = async (context) => {
181
183
  : {
182
184
  code: await settled(files.code[file]),
183
185
  writer: await codeWriterOf(config, context.server),
184
- namedValues: source.namedValues
186
+ namedValues: source.namedValues,
187
+ coders: parts.customControls?.coders ?? {}
185
188
  };
186
189
  const index = offersUsedIn(config.lists) ? await sourceUsage(source) : undefined;
187
190
  // a usage that cannot be read leaves "Used in" out, never the story
@@ -219,18 +222,39 @@ const codeWriterOf = async (config, server) => {
219
222
  return undefined;
220
223
  }
221
224
  };
225
+ /**
226
+ * The controls the catalogue gives a story's props by type or name, as its component's docs say: their modules load for
227
+ * the story's document too.
228
+ *
229
+ * @param docs - the docs, as the dev server loaded them.
230
+ */
231
+ const typedControlNames = (docs) => "value" in docs && isComponentDocs(docs.value)
232
+ ? docs.value.props.flatMap((prop) => prop.customControl ?? [])
233
+ : [];
222
234
  /** How long a control's module may take to load for an answer: one that hangs must not hold every answer with it. */
223
235
  const CONTROL_LOAD_MS = 5000;
224
- /** A control's schema, loaded on the server; a text says why there is none. */
225
- const controlSchemaOf = async (module, server) => {
236
+ /** A control's module as the server reads it: its schema and what writes its values as code; a text says why not. */
237
+ const controlReadOf = async (module, server) => {
226
238
  try {
227
239
  const id = await server.resolveSource(module);
228
240
  const timeout = new Promise((_resolve, reject) => {
229
241
  setTimeout(() => reject(new Error(`no answer in ${CONTROL_LOAD_MS} ms`)), CONTROL_LOAD_MS).unref();
230
242
  });
231
243
  const loaded = await Promise.race([server.load(id ?? module), timeout]);
232
- const schema = typeof loaded === "object" && loaded !== null && "schema" in loaded ? loaded.schema : undefined;
233
- return isValueSchema(schema) ? schema : "its module exports no schema Forgebook can read";
244
+ const isModule = typeof loaded === "object" && loaded !== null;
245
+ const schema = isModule && "schema" in loaded ? loaded.schema : undefined;
246
+ const toCode = isModule && "toCode" in loaded ? loaded.toCode : undefined;
247
+ if (!isValueSchema(schema))
248
+ return "its module exports no schema Forgebook can read";
249
+ return {
250
+ schema,
251
+ toCode: typeof toCode === "function"
252
+ ? (value, argType) => {
253
+ const code = toCode(value, argType);
254
+ return typeof code === "string" ? code : undefined;
255
+ }
256
+ : undefined
257
+ };
234
258
  }
235
259
  catch (error) {
236
260
  return `its module cannot be read on the server: ${error instanceof Error ? error.message : String(error)}`;
@@ -246,10 +270,14 @@ const controlSchemaOf = async (module, server) => {
246
270
  * @param server - what the answers ask of the dev server.
247
271
  */
248
272
  const customControlsRead = async (names, config, server) => {
249
- const known = names.filter((name) => Object.hasOwn(config.argControls, name));
250
- const read = await Promise.all(known.map(async (name) => [name, await controlSchemaOf(config.argControls[name] ?? "", server)]));
273
+ const known = names.flatMap((name) => {
274
+ const control = Object.hasOwn(config.argControls, name) ? config.argControls[name] : undefined;
275
+ return control === undefined ? [] : [[name, control.module]];
276
+ });
277
+ const read = await Promise.all(known.map(async ([name, module]) => [name, await controlReadOf(module, server)]));
251
278
  return {
252
- schemas: Object.fromEntries(read.flatMap(([name, each]) => (typeof each === "string" ? [] : [[name, each]]))),
279
+ schemas: Object.fromEntries(read.flatMap(([name, each]) => (typeof each === "string" ? [] : [[name, each.schema]]))),
280
+ coders: Object.fromEntries(read.flatMap(([name, each]) => (typeof each === "string" ? [] : [[name, each.toCode]]))),
253
281
  problems: Object.fromEntries(read.flatMap(([name, each]) => (typeof each === "string" ? [[name, each]] : [])))
254
282
  };
255
283
  };
@@ -1,4 +1,5 @@
1
1
  import { type ComponentDocs } from "../componentDocs.ts";
2
+ import { type ControlCoders } from "../controlCode.ts";
2
3
  import type { StoryCodeWriter } from "../frame/renderer.ts";
3
4
  import { type LoadedPreview } from "../frame/storyModule.ts";
4
5
  import type { ArgValue } from "../messages.ts";
@@ -17,6 +18,8 @@ export type AgentCodeParts = {
17
18
  readonly writer: StoryCodeWriter | undefined;
18
19
  /** The source's named values, as the frame writes them by name. */
19
20
  readonly namedValues: NamedValues | undefined;
21
+ /** What writes each custom control's values as code, by control; none writes them as JSON. */
22
+ readonly coders?: ControlCoders;
20
23
  };
21
24
  /** The story the code is of, as the story's answer read it. */
22
25
  export type AgentCodeStory = {
@@ -1,6 +1,9 @@
1
+ import { panelProps } from "../argTypes.js";
1
2
  import { codeNameOf } from "../componentDocs.js";
3
+ import { controlCodeOf } from "../controlCode.js";
2
4
  import { panelArgsOf } from "../frame/panelValues.js";
3
5
  import { readStory } from "../frame/storyModule.js";
6
+ import { panelArgTypesOf } from "../storyArgTypes.js";
4
7
  import { storyCodeText } from "../storyCodeText.js";
5
8
  /** Whether a module's answer is a story file's code, by export name. */
6
9
  const isStoryFileCode = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !("error" in value);
@@ -15,12 +18,15 @@ export const agentStoryCode = (parts, { location, module, preview, accepted = {}
15
18
  const value = "value" in parts.code ? parts.code.value : undefined;
16
19
  if (typeof loaded === "string" || !isStoryFileCode(value) || parts.writer === undefined)
17
20
  return undefined;
21
+ const args = { ...panelArgsOf(loaded.args, parts.namedValues), ...accepted };
22
+ const props = panelProps(docs?.props ?? [], panelArgTypesOf(loaded.argTypes, []), args);
18
23
  return storyCodeText({
19
24
  code: value,
20
25
  exportName: location.story.exportName,
21
26
  componentName: docs === undefined ? location.component.name : codeNameOf(docs),
22
- args: { ...panelArgsOf(loaded.args, parts.namedValues), ...accepted },
23
- edited: Object.keys(accepted)
27
+ args,
28
+ edited: Object.keys(accepted),
29
+ argCode: controlCodeOf(props, args, parts.coders ?? {})
24
30
  }, parts.writer);
25
31
  };
26
32
  /**
@@ -0,0 +1,30 @@
1
+ import type { ArgControlMatch, ForgebookConfig } from "./config.ts";
2
+ /** A custom control ready for the pages. */
3
+ export type ResolvedArgControl = {
4
+ /** Its module, drawn in the panel: a path absolute, a package's specifier as written. */
5
+ readonly module: string;
6
+ /** Its frame module, which turns its value into the story's; none when the story receives the JSON value. */
7
+ readonly frame?: string;
8
+ /** The props it takes in every story that names no control for them; empty when only `argTypes` name it. */
9
+ readonly matches: readonly ArgControlMatch[];
10
+ };
11
+ /** The custom controls by name, in their precedence: the catalogue's, then its presets'. */
12
+ export type ResolvedArgControls = Readonly<Record<string, ResolvedArgControl>>;
13
+ /** The path functions the resolution needs, so a test can name another system's. */
14
+ export type ControlPaths = {
15
+ /** A path from the configuration's folder, made absolute in Vite's form. */
16
+ readonly absolute: (path: string) => string;
17
+ /** Whether a path is absolute already. */
18
+ readonly isAbsolute: (path: string) => boolean;
19
+ /** An absolute path in Vite's form. */
20
+ readonly normalize: (path: string) => string;
21
+ };
22
+ /**
23
+ * The catalogue's custom controls and its presets', checked, merged, in their precedence.
24
+ *
25
+ * @param config - the configuration, its presets included.
26
+ * @param dir - its folder.
27
+ * @param paths - the path functions.
28
+ * @throws when a name, a module or a match cannot be used.
29
+ */
30
+ export declare const resolvedArgControls: (config: Pick<ForgebookConfig, "argControls" | "presets">, dir: string, paths: ControlPaths) => ResolvedArgControls;