@rohal12/spindle 0.59.11 → 0.59.13

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.
@@ -11082,7 +11082,8 @@ var VALID_VAR_TYPES = new Set([
11082
11082
  ]);
11083
11083
  function inferSchema(value) {
11084
11084
  if (Array.isArray(value)) return { type: "array" };
11085
- if (value !== null && typeof value === "object") {
11085
+ if (value === null) return { type: "null" };
11086
+ if (typeof value === "object") {
11086
11087
  const fields = /* @__PURE__ */ new Map();
11087
11088
  for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
11088
11089
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.11",
3
+ "version": "0.59.13",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -5,7 +5,7 @@ import { deepEqualStrict } from '../../structural';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { MacroError, logMacroError } from './MacroError';
7
7
  import { currentSourceLocation } from '../../utils/source-location';
8
- import { checkVariableName } from '../../utils/namespace';
8
+ import { checkVariableName, ownValue } from '../../utils/namespace';
9
9
 
10
10
  /**
11
11
  * The previous output before the first successful evaluation. Distinct from
@@ -39,7 +39,7 @@ function assignmentArgs(
39
39
  /**
40
40
  * Evaluate `expr` against the current values — the store's state and the
41
41
  * enclosing locals scope's live values (`getLocals`) — and write the result
42
- * to the target if it differs from the previous output (always, the first
42
+ * to the target if it differs from the previous output or the target's value (always, the first
43
43
  * time). Reading live values rather than the render's snapshot means the
44
44
  * first computation sees a local a preceding {set} just assigned (the scope's
45
45
  * context value only catches up on its re-render), and the first computation
@@ -53,22 +53,39 @@ function computeAndApply(
53
53
  getLocals: () => Record<string, unknown>,
54
54
  rawArgs: string,
55
55
  prevRef: { current: unknown },
56
+ lastNavigation: { current: number },
56
57
  localsUpdate: ((key: string, value: unknown) => void) | null,
57
58
  ): void {
58
59
  // Before evaluating: the expression may navigate away from this passage
59
60
  const location = currentSourceLocation();
60
61
  let newValue: unknown;
62
+ let current: unknown = prevRef.current;
63
+ let navigated = false;
64
+ const { navigationId } = useStoryStore.getState();
61
65
  try {
62
66
  // In program order, also when mutation code sets this off
63
67
  const { variables, temporary, transient } = readState();
68
+ // A navigation (or restored session) resets the temporary namespace,
69
+ // which a persistent {computed} survives: the target is then gone though
70
+ // the result is unchanged, and is written again. Between navigations a
71
+ // target the story removed or set itself stays as it is.
72
+ if (!isLocal) {
73
+ current = ownValue(isTemp ? temporary : variables, name);
74
+ navigated = navigationId !== lastNavigation.current;
75
+ }
64
76
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
65
77
  } catch (err) {
66
78
  logMacroError(`computed ${rawArgs}`, err, location);
67
79
  return;
68
80
  }
69
81
 
82
+ lastNavigation.current = navigationId;
83
+
70
84
  // Strict: a change of only the aliases inside the result is a change
71
- if (!deepEqualStrict(prevRef.current, newValue)) {
85
+ if (
86
+ !deepEqualStrict(prevRef.current, newValue) ||
87
+ (navigated && !deepEqualStrict(current, newValue))
88
+ ) {
72
89
  prevRef.current = newValue;
73
90
  if (isLocal) {
74
91
  try {
@@ -93,8 +110,6 @@ defineMacro({
93
110
  { name: 'expression', type: 'expression', required: true },
94
111
  ],
95
112
  render({ rawArgs }, ctx) {
96
- const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
97
-
98
113
  let target: string;
99
114
  let expr: string;
100
115
  try {
@@ -113,6 +128,8 @@ defineMacro({
113
128
  const localsUpdate = isLocal ? ctx.update : null;
114
129
 
115
130
  const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
131
+ // The navigation the target was last checked in
132
+ const lastNavigation = ctx.hooks.useRef(-1);
116
133
 
117
134
  const compute = () =>
118
135
  computeAndApply(
@@ -123,6 +140,7 @@ defineMacro({
123
140
  ctx.getValues,
124
141
  rawArgs,
125
142
  prevOutput,
143
+ lastNavigation,
126
144
  localsUpdate,
127
145
  );
128
146
 
@@ -133,13 +151,9 @@ defineMacro({
133
151
  }
134
152
 
135
153
  // The merged values only decide when to recompute; computeAndApply reads
136
- // the current ones.
137
- ctx.hooks.useLayoutEffect(compute, [
138
- mergedVars,
139
- mergedTemps,
140
- mergedLocals,
141
- mergedTrans,
142
- ]);
154
+ // the current ones. The tuple is renewed with them and with the render
155
+ // counts, which rendered() / hasRendered() read.
156
+ ctx.hooks.useLayoutEffect(compute, [ctx.merged]);
143
157
 
144
158
  return null;
145
159
  },
@@ -3,8 +3,10 @@ import { LocalsValuesContext } from '../../markup/render';
3
3
  import { defineMacro } from '../../define-macro';
4
4
  import { MacroError } from './MacroError';
5
5
  import { stableKey } from '../../utils/stable-key';
6
+ import { controlEditVersion, editedItem } from '../../utils/control-edits';
6
7
  import type { ASTNode } from '../../markup/ast';
7
8
  import { checkVariableName } from '../../utils/namespace';
9
+ import { readState } from '../../execute-mutation';
8
10
  import { LocalsScope } from './locals-scope';
9
11
 
10
12
  /**
@@ -76,6 +78,14 @@ function ForIteration({
76
78
  );
77
79
  }
78
80
 
81
+ /** What {for} remembers of its iterations between renders. */
82
+ interface IterationKeys {
83
+ contents: string[];
84
+ generations: number[];
85
+ next: number;
86
+ edits: number;
87
+ }
88
+
79
89
  defineMacro({
80
90
  name: 'for',
81
91
  block: true,
@@ -88,6 +98,12 @@ defineMacro({
88
98
  ],
89
99
  render({ rawArgs, children = [] }, ctx) {
90
100
  const parentValues = useContext(LocalsValuesContext);
101
+ const keys = ctx.hooks.useRef<IterationKeys>({
102
+ contents: [],
103
+ generations: [],
104
+ next: 0,
105
+ edits: controlEditVersion(),
106
+ }).current;
91
107
 
92
108
  let loop: ReturnType<typeof loopArgs>;
93
109
  let list: unknown[];
@@ -112,9 +128,29 @@ defineMacro({
112
128
  }
113
129
 
114
130
  const { itemVar, indexVar } = loop;
131
+ // An iteration is remounted when its item's contents change, so mount-only
132
+ // macros run again for the new item (#45) -- unless the change is a
133
+ // reader editing the item through a control: remounting would drop the
134
+ // control's focus after the first keystroke.
135
+ const since = keys.edits;
136
+ const { variables } = readState();
137
+ keys.edits = controlEditVersion();
138
+ const generations = list.map((item, i) => {
139
+ const contents = stableKey(item);
140
+ if (
141
+ keys.generations[i] === undefined ||
142
+ (contents !== keys.contents[i] &&
143
+ !editedItem(since, variables, list, item, i))
144
+ ) {
145
+ keys.generations[i] = keys.next++;
146
+ }
147
+ keys.contents[i] = contents;
148
+ return keys.generations[i]!;
149
+ });
150
+ keys.contents.length = keys.generations.length = list.length;
115
151
  const content = list.map((item, i) => (
116
152
  <ForIteration
117
- key={`${i}-${stableKey(item)}`}
153
+ key={`${i}-${generations[i]}`}
118
154
  parentValues={parentValues}
119
155
  itemVar={itemVar}
120
156
  itemValue={item}
@@ -49,7 +49,10 @@ defineMacro({
49
49
  const containerRef = useRef<HTMLSpanElement>(null);
50
50
  const innerRef = useRef<HTMLSpanElement>(null);
51
51
  const [clip, setClip] = useState<string | undefined>();
52
- const [totalChars, setTotalChars] = useState(0);
52
+ // Null until the content has been measured: zero characters is then a
53
+ // result (nothing to animate), not a pending measurement.
54
+ const [measured, setMeasured] = useState<number | null>(null);
55
+ const totalChars = measured ?? 0;
53
56
  const [visibleChars, setVisibleChars] = useState(0);
54
57
  const visibleCharsRef = useRef(0);
55
58
  visibleCharsRef.current = visibleChars;
@@ -59,7 +62,7 @@ defineMacro({
59
62
  useEffect(() => {
60
63
  if (containerRef.current) {
61
64
  const text = containerRef.current.textContent || '';
62
- setTotalChars(text.length);
65
+ setMeasured(text.length);
63
66
  }
64
67
  });
65
68
 
@@ -81,7 +84,8 @@ defineMacro({
81
84
  return () => clearInterval(timer);
82
85
  }, [totalChars, speed]);
83
86
 
84
- const done = visibleChars >= totalChars && totalChars > 0;
87
+ // Empty text can still become reactive text later, which restarts typing
88
+ const done = measured !== null && visibleChars >= totalChars;
85
89
 
86
90
  // Clip to the characters revealed so far, as laid out: remeasured when
87
91
  // the count changes and when the width the text wraps in does.
@@ -117,7 +121,7 @@ defineMacro({
117
121
  class="macro-type-inner"
118
122
  style={{
119
123
  display: 'inline',
120
- visibility: totalChars === 0 ? 'hidden' : 'visible',
124
+ visibility: measured === null ? 'hidden' : 'visible',
121
125
  clipPath: clip,
122
126
  }}
123
127
  >
@@ -1,6 +1,4 @@
1
- import { registerWidget } from '../../widgets/widget-registry';
2
- import { astContainsChildren } from '../../widgets/ast-scanner';
3
- import { registerBlockMacro, type ASTNode } from '../../markup/ast';
1
+ import { registerWidgetDef } from '../../widgets/register-widget-def';
4
2
  import { defineMacro } from '../../define-macro';
5
3
  import { checkVariableName } from '../../utils/namespace';
6
4
  import { MacroError } from './MacroError';
@@ -10,22 +8,6 @@ import {
10
8
  type WidgetDef,
11
9
  } from '../../widgets/widget-def';
12
10
 
13
- /**
14
- * Register the widget a definition declares, with its body. Widgets whose
15
- * body renders {@children} take a closing tag, so they are registered as
16
- * block macros too: passages parsed later nest their content. A parameter
17
- * that no namespace can hold throws (see registerWidget), registering
18
- * nothing.
19
- */
20
- export function registerWidgetDef(
21
- { name, params }: WidgetDef,
22
- body: ASTNode[],
23
- ): void {
24
- const isBlock = astContainsChildren(body);
25
- registerWidget(name, body, params, isBlock);
26
- if (isBlock) registerBlockMacro(name);
27
- }
28
-
29
11
  defineMacro({
30
12
  name: 'widget',
31
13
  block: true,
@@ -46,16 +46,9 @@ function WidgetContent({
46
46
 
47
47
  const childrenValue = invocationChildren?.length ? invocationChildren : null;
48
48
 
49
- // Parameterized widgets always get their own local scope, even when invoked
50
- // without arguments: missing parameters shadow outer locals as undefined.
51
- if (params.length === 0) {
52
- return (
53
- <WidgetChildrenContext.Provider value={childrenValue}>
54
- {renderNodes(body, renderOptions)}
55
- </WidgetChildrenContext.Provider>
56
- );
57
- }
58
-
49
+ // Every widget gets its own local scope, even when it declares no
50
+ // parameters or is invoked without arguments: missing parameters shadow
51
+ // outer locals as undefined, and the body's own locals stay in the widget.
59
52
  const argExprs = rawArgs ? splitArgs(rawArgs) : [];
60
53
  const values: unknown[] = [];
61
54
 
@@ -10,6 +10,7 @@ import {
10
10
  useMemo,
11
11
  } from 'preact/hooks';
12
12
  import { useInterpolate } from './hooks/use-interpolate';
13
+ import { noteControlEdit } from './utils/control-edits';
13
14
  import { useMergedLocals } from './hooks/use-merged-locals';
14
15
  import { useRenderOptions } from './hooks/use-render-options';
15
16
  import {
@@ -228,6 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
228
229
  // In program order, also when mutation code performs the input
229
230
  ctx.getValue = () => getByPath(readState().variables, segments);
230
231
  ctx.setValue = (value: unknown) => {
232
+ noteControlEdit(segments);
231
233
  // The commit mutation code uses, which keeps the references among
232
234
  // variables: a write to a draft copies only the path written, and
233
235
  // other variables sharing the object would keep the old one.
@@ -138,6 +138,32 @@ export function frozenCopy<T>(value: T): T {
138
138
  return freeze(cloneValue(value), true);
139
139
  }
140
140
 
141
+ /** Whether `value` and everything it holds is frozen. */
142
+ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
143
+ if (typeof value !== 'object' || value === null || seen.has(value)) {
144
+ return true;
145
+ }
146
+ // Leaves a freeze cannot lock (typed array elements) and that a copy would
147
+ // detach from what they share (a buffer): handed out as they are
148
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
149
+ if (!Object.isFrozen(value)) return false;
150
+ seen.add(value);
151
+ return Reflect.ownKeys(value).every((key) => {
152
+ const desc = Object.getOwnPropertyDescriptor(value, key);
153
+ return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
154
+ });
155
+ }
156
+
157
+ /**
158
+ * A value read from the store, made safe to hand out: the store holds plain
159
+ * values frozen, but not registered class instances, whose methods could
160
+ * change the recorded history through the live object. Such a value is
161
+ * returned as a frozen copy, like a read inside mutation code.
162
+ */
163
+ export function readOnlyValue<T>(value: T): T {
164
+ return deeplyFrozen(value) ? value : frozenCopy(value);
165
+ }
166
+
141
167
  /**
142
168
  * Apply a write that reached the store while mutations execute to their
143
169
  * `work` and `base` copies (see MutationScope), so the code sees it and
@@ -9,8 +9,9 @@ const FOCUSABLE = [
9
9
  'select:not([disabled])',
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
- '[contenteditable="true"]',
13
- '[tabindex]:not([tabindex="-1"])',
12
+ 'summary',
13
+ '[contenteditable]',
14
+ '[tabindex]',
14
15
  ].join(',');
15
16
 
16
17
  /**
@@ -27,15 +28,76 @@ function isRendered(el: HTMLElement): boolean {
27
28
  return true;
28
29
  }
29
30
 
31
+ /** The `tabindex` of `el` as an integer, or null when absent or invalid. */
32
+ function tabIndexAttr(el: HTMLElement): number | null {
33
+ const raw = el.getAttribute('tabindex');
34
+ if (raw === null || !/^\s*[+-]?\d+\s*$/.test(raw)) return null;
35
+ return parseInt(raw, 10);
36
+ }
37
+
38
+ /**
39
+ * Whether the tab key can reach `el`: a negative tabindex only allows
40
+ * scripted focus, and of a radio group the browser tabs to the checked
41
+ * member only (the first one when none is checked).
42
+ */
43
+ function isTabStop(el: HTMLElement): boolean {
44
+ const tabIndex = tabIndexAttr(el);
45
+ if (tabIndex !== null && tabIndex < 0) return false;
46
+ // Only an editing host takes focus, not `contenteditable="false"` or
47
+ // content nested in a host
48
+ if (
49
+ el.hasAttribute('contenteditable') &&
50
+ !el.matches('a[href], button, input, select, textarea, iframe, summary') &&
51
+ (!el.isContentEditable || el.parentElement?.isContentEditable)
52
+ ) {
53
+ return false;
54
+ }
55
+ // Only a details element's first summary child is its toggle
56
+ if (el.localName === 'summary') {
57
+ const details = el.parentElement;
58
+ if (
59
+ details?.localName !== 'details' ||
60
+ Array.from(details.children).find((c) => c.localName === 'summary') !== el
61
+ ) {
62
+ return tabIndex !== null;
63
+ }
64
+ }
65
+ if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
66
+ const group = Array.from(
67
+ (
68
+ el.form ?? (el.getRootNode() as ParentNode)
69
+ ).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
70
+ ).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
71
+ const stop = group.find((r) => r.checked) ?? group[0];
72
+ return el === stop;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * The elements of `root` in the order Tab visits them: positive tabindexes
79
+ * first, ascending, then the others in document order.
80
+ */
30
81
  function focusables(root: HTMLElement): HTMLElement[] {
31
- return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
82
+ const stops = Array.from(
83
+ root.querySelectorAll<HTMLElement>(FOCUSABLE),
84
+ ).filter(
32
85
  (el) =>
33
86
  !el.hidden &&
34
87
  !el.closest('[hidden], [inert]') &&
35
88
  // Also covers controls disabled by a <fieldset disabled> ancestor
36
89
  !el.matches(':disabled') &&
37
- isRendered(el),
90
+ isRendered(el) &&
91
+ isTabStop(el),
38
92
  );
93
+ const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
94
+ // Array.prototype.sort is stable: equal tabindexes keep document order
95
+ return [
96
+ ...stops
97
+ .filter((el) => positive(el) > 0)
98
+ .sort((a, b) => positive(a) - positive(b)),
99
+ ...stops.filter((el) => positive(el) === 0),
100
+ ];
39
101
  }
40
102
 
41
103
  interface ModalEntry {
package/src/index.tsx CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  import { blockWidgetNames, parseWidgetDef } from './widgets/widget-def';
28
28
  import { parseMarkup } from './markup/parse';
29
29
  import { registerBlockMacro } from './markup/ast';
30
- import { registerWidgetDef } from './components/macros/Widget';
30
+ import { registerWidgetDefinitions } from './widgets/register-widget-def';
31
31
  import { errorMessage } from './utils/error-message';
32
32
  import type { ASTNode } from './markup/ast';
33
33
  import './macros/register-builtins';
@@ -193,6 +193,14 @@ export function boot() {
193
193
  // Enter runtime phase — handlers registered from here on are cleaned on restart
194
194
  enterRuntimePhase();
195
195
 
196
+ // Pass 2: Full parse and register widgets from passages tagged "widget",
197
+ // before StoryInit runs so that it can invoke them
198
+ for (const [, passage] of storyData.passages) {
199
+ if (passage.tags.includes('widget')) {
200
+ registerWidgetDefinitions(parseMarkup(passage.content), passage.name);
201
+ }
202
+ }
203
+
196
204
  // Run StoryInit, restore the session if the page was refreshed, and fire
197
205
  // storyinit after all state is settled (defaults + StoryInit + session)
198
206
  initializeStory(loadSession(storyData.ifid));
@@ -205,27 +213,6 @@ export function boot() {
205
213
  ];
206
214
  if (markup.length > 0) stopWithErrors(markup);
207
215
 
208
- // Pass 2: Full parse and register widgets from passages tagged "widget"
209
- for (const [, passage] of storyData.passages) {
210
- if (passage.tags.includes('widget')) {
211
- const widgetAST = parseMarkup(passage.content);
212
- for (const node of widgetAST) {
213
- if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
214
- // Read and registered as the {widget} macro does
215
- const def = parseWidgetDef(node.rawArgs);
216
- try {
217
- registerWidgetDef(def, node.children as ASTNode[]);
218
- } catch (err) {
219
- // As the {widget} macro refuses it: the others still register
220
- console.error(
221
- `spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
222
- );
223
- }
224
- }
225
- }
226
- }
227
- }
228
-
229
216
  // Reset action ID counters on every navigation (the passage remounts even
230
217
  // when its name is unchanged). Controls that stay mounted, e.g. in
231
218
  // StoryInterface, keep their IDs: allocation skips IDs still registered.
@@ -52,7 +52,7 @@ export const InlineContext = createContext(false);
52
52
  /**
53
53
  * True while rendering inside an element whose content is not markdown: SVG
54
54
  * (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
55
- * and `<textarea>`, whose text (indentation, `#`, `*`, ...) is literal.
55
+ * `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
56
56
  * Macro and widget bodies read it so their content stays literal too.
57
57
  */
58
58
  export const RawTextContext = createContext(false);
@@ -66,7 +66,7 @@ const SvgContext = createContext(false);
66
66
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
67
67
 
68
68
  /** Elements whose content is literal text, not markdown. */
69
- const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
69
+ const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
70
70
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
71
71
 
72
72
  /**
@@ -348,7 +348,11 @@ function convertDomNode(
348
348
  return null;
349
349
  }
350
350
 
351
- /** Inline elements where block-level markdown (lists, headings) is invalid. */
351
+ /**
352
+ * Elements whose content is phrasing content only, so block-level markdown
353
+ * (lists, headings, paragraphs) is invalid in them: inline elements, and
354
+ * block elements such as headings and buttons that only take inline children.
355
+ */
352
356
  const INLINE_ELEMENTS = new Set([
353
357
  'a',
354
358
  'abbr',
@@ -356,17 +360,27 @@ const INLINE_ELEMENTS = new Set([
356
360
  'bdi',
357
361
  'bdo',
358
362
  'br',
363
+ 'button',
359
364
  'cite',
360
365
  'code',
361
366
  'data',
362
367
  'dfn',
363
368
  'em',
364
369
  'i',
370
+ 'h1',
371
+ 'h2',
372
+ 'h3',
373
+ 'h4',
374
+ 'h5',
375
+ 'h6',
365
376
  'kbd',
366
377
  'label',
378
+ 'legend',
367
379
  'mark',
368
380
  'meter',
381
+ 'option',
369
382
  'output',
383
+ 'p',
370
384
  'progress',
371
385
  'q',
372
386
  'rp',
@@ -378,6 +392,7 @@ const INLINE_ELEMENTS = new Set([
378
392
  'span',
379
393
  'strong',
380
394
  'sub',
395
+ 'summary',
381
396
  'sup',
382
397
  'time',
383
398
  'u',
@@ -618,6 +633,15 @@ function resolveAttributeValue(
618
633
  return result.text;
619
634
  }
620
635
 
636
+ /** The index of the summary element opening `nodes`, or -1 when none does. */
637
+ function leadingSummaryIndex(nodes: ASTNode[]): number {
638
+ const at = nodes.findIndex((n) => n.type !== 'text' || n.value.trim() !== '');
639
+ const first = nodes[at];
640
+ return first?.type === 'html' && first.tag.toLowerCase() === 'summary'
641
+ ? at
642
+ : -1;
643
+ }
644
+
621
645
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
622
646
  const scope = useTextScope();
623
647
  const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
@@ -644,7 +668,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
644
668
  if (inRaw || isRawRoot) {
645
669
  children = renderInlineNodes(node.children);
646
670
  } else {
647
- children = renderNodes(node.children, { nobr, locals, inline: isInline });
671
+ // A details element's summary stays its direct child: it is rendered
672
+ // apart from the markdown of the rest of the content.
673
+ const summaryAt =
674
+ tag === 'details' ? leadingSummaryIndex(node.children) : -1;
675
+ const body = (nodes: ASTNode[]) =>
676
+ renderNodes(nodes, { nobr, locals, inline: isInline });
677
+ children =
678
+ summaryAt === -1 ? (
679
+ body(node.children)
680
+ ) : (
681
+ <>
682
+ {body(node.children.slice(0, summaryAt))}
683
+ {renderSingleNode(node.children[summaryAt]!)}
684
+ {body(node.children.slice(summaryAt + 1))}
685
+ </>
686
+ );
648
687
  if (isInline !== parentInline) {
649
688
  children = (
650
689
  <InlineContext.Provider value={isInline}>
@@ -36,7 +36,16 @@ export function setSaveTitlePassage(content: string): void {
36
36
  saveTitlePassageContent = content;
37
37
  }
38
38
 
39
+ /**
40
+ * The title of a save. The SaveTitle passage and title generators may draw
41
+ * random numbers; like the save hooks they must not advance the story's PRNG
42
+ * (see withoutDraws()).
43
+ */
39
44
  function generateTitle(payload: SavePayload): string {
45
+ return withoutDraws(() => generateTitleNow(payload));
46
+ }
47
+
48
+ function generateTitleNow(payload: SavePayload): string {
40
49
  // SaveTitle passage takes precedence
41
50
  if (saveTitlePassageContent) {
42
51
  try {
package/src/story-api.ts CHANGED
@@ -23,6 +23,7 @@ import { getBackendType } from './saves/storage';
23
23
  import { registerClass } from './class-registry';
24
24
  import {
25
25
  frozenCopy,
26
+ readOnlyValue,
26
27
  getActiveMutationScope,
27
28
  mutateState,
28
29
  } from './execute-mutation';
@@ -286,7 +287,7 @@ function createStoryAPI(): StoryAPI {
286
287
  const value = key.includes('.')
287
288
  ? getByPath(namespace, key.split('.'))
288
289
  : ownValue(namespace, key);
289
- return scope ? frozenCopy(value) : value;
290
+ return scope ? frozenCopy(value) : readOnlyValue(value);
290
291
  },
291
292
 
292
293
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
package/src/story-init.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { h, render } from 'preact';
2
2
  import { useStoryStore, recordStoryInitState, missingPassage } from './store';
3
3
  import { parseMarkup } from './markup/parse';
4
+ import { registerWidgetDefinitions } from './widgets/register-widget-def';
4
5
  import { renderNodes } from './markup/render';
5
6
  import { setSaveTitlePassage } from './saves/save-manager';
6
7
  import { emit } from './event-emitter';
@@ -35,6 +36,9 @@ export function executeStoryInit() {
35
36
  const storyInit = state.storyData.passages.get('StoryInit');
36
37
  if (storyInit) {
37
38
  const ast = parseMarkup(storyInit.content);
39
+ // Its own widgets are usable from the passage itself: the macros render
40
+ // before the {widget} definitions' effects run.
41
+ registerWidgetDefinitions(ast, 'StoryInit');
38
42
 
39
43
  // Mount into a persistent hidden container. It stays mounted until the
40
44
  // next execution (restart) — this lets async effects (useEffect,
@@ -32,7 +32,13 @@ function tokensOf(text: string, textMode: boolean): Token[] {
32
32
  }
33
33
  }
34
34
 
35
- export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
35
+ export type VarType =
36
+ | 'number'
37
+ | 'string'
38
+ | 'boolean'
39
+ | 'array'
40
+ | 'object'
41
+ | 'null';
36
42
 
37
43
  export interface FieldSchema {
38
44
  type: VarType;
@@ -66,7 +72,9 @@ function inferSchema(value: unknown): FieldSchema {
66
72
  if (Array.isArray(value)) {
67
73
  return { type: 'array' };
68
74
  }
69
- if (value !== null && typeof value === 'object') {
75
+ // A default of null means "nothing yet"; the value may later be anything
76
+ if (value === null) return { type: 'null' };
77
+ if (typeof value === 'object') {
70
78
  const fields = new Map<string, FieldSchema>();
71
79
  for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
72
80
  fields.set(key, inferSchema(val));
@@ -156,6 +164,8 @@ function validateRef(
156
164
  // Arrays have built-in methods/properties (push, find, length, etc.)
157
165
  // so any field access on an array is allowed.
158
166
  if (current.type === 'array') return null;
167
+ // A null default may later hold a value of any shape
168
+ if (current.type === 'null') return null;
159
169
 
160
170
  // Primitives expose their wrapper's built-ins (length, toUpperCase,
161
171
  // toFixed, etc.). Keep validating past properties of primitive type.