@rohal12/spindle 0.52.9 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +5 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +6484 -2558
  4. package/dist/pkg/story-variables.js +4009 -940
  5. package/dist/pkg/tooling.js +13 -1
  6. package/package.json +6 -1
  7. package/src/class-registry.ts +252 -250
  8. package/src/components/App.tsx +2 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/RuntimeErrors.tsx +46 -0
  12. package/src/components/StoryInterface.tsx +2 -4
  13. package/src/components/macros/Computed.tsx +5 -2
  14. package/src/components/macros/Do.tsx +4 -2
  15. package/src/components/macros/Goto.tsx +13 -3
  16. package/src/components/macros/Include.tsx +2 -3
  17. package/src/components/macros/MacroError.tsx +14 -7
  18. package/src/components/macros/SaveManager.tsx +2 -2
  19. package/src/components/macros/VarDisplay.tsx +1 -1
  20. package/src/components/macros/Widget.tsx +5 -33
  21. package/src/index.tsx +57 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/markup/ast.ts +11 -226
  24. package/src/markup/code-attributes.ts +3 -3
  25. package/src/markup/code-end.ts +121 -0
  26. package/src/markup/parse.ts +118 -0
  27. package/src/markup/render.tsx +1 -1
  28. package/src/markup/spindle.d.peggy.ts +24 -0
  29. package/src/markup/spindle.peggy +463 -0
  30. package/src/markup/tokens.ts +95 -0
  31. package/src/markup/validate.ts +219 -0
  32. package/src/parser.ts +19 -4
  33. package/src/runtime-errors.ts +65 -0
  34. package/src/saves/format.ts +199 -0
  35. package/src/saves/save-manager.ts +45 -63
  36. package/src/saves/types.ts +71 -88
  37. package/src/store.ts +167 -87
  38. package/src/story-init.ts +3 -5
  39. package/src/story-variables.ts +18 -4
  40. package/src/structural.ts +266 -57
  41. package/src/styles.css +45 -0
  42. package/src/tooling.ts +50 -0
  43. package/src/types-drift-check.ts +28 -1
  44. package/src/utils/object-path.ts +13 -12
  45. package/src/utils/stable-key.ts +2 -1
  46. package/src/utils/value-kinds.ts +45 -0
  47. package/src/widgets/widget-def.ts +60 -0
  48. package/types/index.d.ts +32 -8
  49. package/types/tooling.d.ts +42 -0
  50. package/src/markup/tokenizer.ts +0 -1112
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import {
3
+ dismissRuntimeError,
4
+ getRuntimeErrors,
5
+ subscribeRuntimeErrors,
6
+ } from '../runtime-errors';
7
+
8
+ /**
9
+ * The runtime errors shown on the page (see showRuntimeError): one banner
10
+ * each, announced to screen readers (role="alert"), until the player
11
+ * dismisses it. Style them with the `spindle-error-banner` classes.
12
+ */
13
+ export function RuntimeErrors() {
14
+ const [errors, setErrors] = useState(getRuntimeErrors);
15
+ useEffect(() => {
16
+ setErrors(getRuntimeErrors());
17
+ return subscribeRuntimeErrors(() => setErrors(getRuntimeErrors()));
18
+ }, []);
19
+
20
+ if (errors.length === 0) return null;
21
+ return (
22
+ <div class="spindle-error-banners">
23
+ {errors.map((e) => (
24
+ <div
25
+ key={e.id}
26
+ class="spindle-error-banner"
27
+ role="alert"
28
+ >
29
+ <span class="spindle-error-banner-context">{e.context}</span>{' '}
30
+ <span class="spindle-error-banner-message">{e.message}</span>
31
+ {e.count > 1 && (
32
+ <span class="spindle-error-banner-count">{` (×${e.count})`}</span>
33
+ )}
34
+ <button
35
+ type="button"
36
+ class="spindle-error-banner-dismiss"
37
+ aria-label="Dismiss"
38
+ onClick={() => dismissRuntimeError(e.id)}
39
+ >
40
+ ✕
41
+ </button>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -1,7 +1,6 @@
1
1
  import { useMemo } from 'preact/hooks';
2
2
  import { useStoryFields } from '../hooks/use-story-fields';
3
- import { tokenize } from '../markup/tokenizer';
4
- import { buildAST } from '../markup/ast';
3
+ import { parseMarkup } from '../markup/parse';
5
4
  import { renderInlineNodes, NobrContext } from '../markup/render';
6
5
  import { errorMessage } from '../utils/error-message';
7
6
 
@@ -18,8 +17,7 @@ export function StoryInterface() {
18
17
 
19
18
  const rendered = useMemo(() => {
20
19
  try {
21
- const tokens = tokenize(markup);
22
- const ast = buildAST(tokens);
20
+ const ast = parseMarkup(markup);
23
21
  return <>{renderInlineNodes(ast)}</>;
24
22
  } catch (err) {
25
23
  return (
@@ -4,6 +4,7 @@ import { readState } from '../../execute-mutation';
4
4
  import { deepEqual } from '../../structural';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { MacroError, logMacroError } from './MacroError';
7
+ import { currentSourceLocation } from '../../utils/source-location';
7
8
  import { checkVariableName } from '../../utils/namespace';
8
9
 
9
10
  /**
@@ -54,13 +55,15 @@ function computeAndApply(
54
55
  prevRef: { current: unknown },
55
56
  localsUpdate: ((key: string, value: unknown) => void) | null,
56
57
  ): void {
58
+ // Before evaluating: the expression may navigate away from this passage
59
+ const location = currentSourceLocation();
57
60
  let newValue: unknown;
58
61
  try {
59
62
  // In program order, also when mutation code sets this off
60
63
  const { variables, temporary, transient } = readState();
61
64
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
62
65
  } catch (err) {
63
- logMacroError(`computed ${rawArgs}`, err);
66
+ logMacroError(`computed ${rawArgs}`, err, location);
64
67
  return;
65
68
  }
66
69
 
@@ -70,7 +73,7 @@ function computeAndApply(
70
73
  try {
71
74
  localsUpdate!(name, newValue);
72
75
  } catch (err) {
73
- logMacroError(`computed ${rawArgs}`, err);
76
+ logMacroError(`computed ${rawArgs}`, err, location);
74
77
  }
75
78
  } else {
76
79
  const state = useStoryStore.getState();
@@ -5,14 +5,16 @@ defineMacro({
5
5
  name: 'do',
6
6
  block: true,
7
7
  render({ children = [] }, ctx) {
8
- // The tokenizer keeps the body verbatim as a single text node
8
+ // The parser keeps the body verbatim as a single text node
9
9
  const code = ctx.collectText(children);
10
10
 
11
11
  ctx.hooks.useLayoutEffect(() => {
12
+ // Before the code runs: it may navigate away from this passage
13
+ const location = ctx.sourceLocation();
12
14
  try {
13
15
  ctx.mutate(code);
14
16
  } catch (err) {
15
- logMacroError('do', err);
17
+ logMacroError('do', err, location);
16
18
  }
17
19
  }, []);
18
20
 
@@ -1,6 +1,7 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
3
  import { evaluatePassageName } from './macro-args';
4
+ import { logMacroError } from './MacroError';
4
5
 
5
6
  defineMacro({
6
7
  name: 'goto',
@@ -8,9 +9,18 @@ defineMacro({
8
9
  parameters: [{ name: 'passage', type: 'expression', required: true }],
9
10
  render(_props, ctx) {
10
11
  ctx.hooks.useLayoutEffect(() => {
11
- useStoryStore
12
- .getState()
13
- .navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
12
+ // Reported like an error in {do}: thrown out of an effect, it would
13
+ // abort the rest of the render's effects (e.g. the session could not
14
+ // be written because the state holds a function). The location is
15
+ // this passage's, taken before navigating away from it
16
+ const location = ctx.sourceLocation();
17
+ try {
18
+ useStoryStore
19
+ .getState()
20
+ .navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
21
+ } catch (err) {
22
+ logMacroError('goto', err, location);
23
+ }
14
24
  }, []);
15
25
 
16
26
  return null;
@@ -1,7 +1,6 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { useStoryFields } from '../../hooks/use-story-fields';
3
- import { tokenize } from '../../markup/tokenizer';
4
- import { buildAST } from '../../markup/ast';
3
+ import { parseMarkup } from '../../markup/parse';
5
4
  import { NobrContext } from '../../markup/render';
6
5
  import { defineMacro } from '../../define-macro';
7
6
  import { evaluatePassageName } from './macro-args';
@@ -28,7 +27,7 @@ defineMacro({
28
27
  // included passage changes, while re-renders of the same passage keep
29
28
  // its macros mounted (#175).
30
29
  const ast = ctx.hooks.useMemo(
31
- () => (passage ? buildAST(tokenize(passage.content)) : null),
30
+ () => (passage ? parseMarkup(passage.content) : null),
32
31
  [passage],
33
32
  );
34
33
 
@@ -19,12 +19,18 @@ export function MacroError({
19
19
  );
20
20
  }
21
21
 
22
- /** Log an error in the macro `{label}` (its name, or name and arguments). */
23
- export function logMacroError(label: string, error: unknown): void {
24
- console.error(
25
- `spindle: Error in {${label}}${currentSourceLocation()}:`,
26
- error,
27
- );
22
+ /**
23
+ * Log an error in the macro `{label}` (its name, or name and arguments) at
24
+ * `location`, the source location of the macro's passage. Take it before
25
+ * running the code that failed (see currentSourceLocation): code that
26
+ * navigates changes the current passage.
27
+ */
28
+ export function logMacroError(
29
+ label: string,
30
+ error: unknown,
31
+ location: string = currentSourceLocation(),
32
+ ): void {
33
+ console.error(`spindle: Error in {${label}}${location}:`, error);
28
34
  }
29
35
 
30
36
  /**
@@ -38,11 +44,12 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
38
44
  const failure = useRef<{ error: unknown } | null>();
39
45
  if (failure.current === undefined) {
40
46
  failure.current = null;
47
+ const location = currentSourceLocation();
41
48
  try {
42
49
  effect();
43
50
  } catch (error) {
44
51
  failure.current = { error };
45
- logMacroError(`${macro} ${rawArgs}`, error);
52
+ logMacroError(`${macro} ${rawArgs}`, error, location);
46
53
  }
47
54
  }
48
55
 
@@ -17,7 +17,7 @@ import {
17
17
  renameSave,
18
18
  exportSave,
19
19
  importSave,
20
- deserializePayload,
20
+ decodeSavePayload,
21
21
  saveWithHooks,
22
22
  type PlaythroughGroup,
23
23
  } from '../../saves/save-manager';
@@ -160,7 +160,7 @@ export function SaveManagerContent() {
160
160
  // Stored records hold serialized variables; the store expects live
161
161
  // ones. The game moves to the save's playthrough.
162
162
  loadFromPayload(
163
- deserializePayload(save.payload),
163
+ decodeSavePayload(save.payload),
164
164
  undefined,
165
165
  save.meta.playthroughId,
166
166
  );
@@ -3,7 +3,7 @@ import { useContext } from 'preact/hooks';
3
3
  import { LocalsValuesContext } from '../../markup/render';
4
4
  import { useInterpolate } from '../../hooks/use-interpolate';
5
5
  import { ownValue, variableNameError } from '../../utils/namespace';
6
- import { SCOPE_SIGILS } from '../../markup/tokenizer';
6
+ import { SCOPE_SIGILS } from '../../markup/tokens';
7
7
  import type { VariableNode } from '../../markup/ast';
8
8
  import { display, wrapContent } from './display';
9
9
 
@@ -4,39 +4,11 @@ import { registerBlockMacro, type ASTNode } from '../../markup/ast';
4
4
  import { defineMacro } from '../../define-macro';
5
5
  import { checkVariableName } from '../../utils/namespace';
6
6
  import { MacroError } from './MacroError';
7
- import { parseMacroArgs } from './macro-args';
8
- import type { MacroArgs } from '../../registry';
9
-
10
- /** A {widget} definition's name, then its `@` parameters. */
11
- const WIDGET_PARAMETERS = [
12
- { name: 'name', type: 'text', required: true },
13
- { name: 'parameters', type: 'expression' },
14
- ] as const;
15
-
16
- interface WidgetDef {
17
- name: string;
18
- params: string[];
19
- }
20
-
21
- /**
22
- * The widget a {widget} definition's arguments declare: its name and its
23
- * parameters, the words after it that start with `@` (docs/widgets.md).
24
- * Other words, such as `$name`, are not parameters.
25
- */
26
- function widgetDef({
27
- name = '',
28
- parameters = '',
29
- }: MacroArgs<typeof WIDGET_PARAMETERS>): WidgetDef {
30
- return {
31
- name,
32
- params: parameters.split(/\s+/).filter((word) => word.startsWith('@')),
33
- };
34
- }
35
-
36
- /** Read the arguments of a {widget} definition (see widgetDef). */
37
- export function parseWidgetDef(rawArgs: string): WidgetDef {
38
- return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
39
- }
7
+ import {
8
+ WIDGET_PARAMETERS,
9
+ widgetDef,
10
+ type WidgetDef,
11
+ } from '../../widgets/widget-def';
40
12
 
41
13
  /**
42
14
  * Register the widget a definition declares, with its body. Widgets whose
package/src/index.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { render } from 'preact';
2
2
  import { App } from './components/App';
3
- import { parseStoryData } from './parser';
3
+ import { parseStoryData, type StoryData } from './parser';
4
4
  import { useStoryStore, enterRuntimePhase } from './store';
5
5
  import {
6
6
  installStoryAPI,
@@ -16,10 +16,17 @@ import {
16
16
  validatePassages,
17
17
  extractDefaults,
18
18
  } from './story-variables';
19
- import { getMacroRegistry } from './registry';
20
- import { tokenize } from './markup/tokenizer';
21
- import { buildAST, registerBlockMacro } from './markup/ast';
22
- import { parseWidgetDef, registerWidgetDef } from './components/macros/Widget';
19
+ import { getMacro, getMacroRegistry, isSubMacro } from './registry';
20
+ import { getWidget } from './widgets/widget-registry';
21
+ import {
22
+ formatDiagnostic,
23
+ validateMarkup,
24
+ type MarkupPassage,
25
+ } from './markup/validate';
26
+ import { blockWidgetNames, parseWidgetDef } from './widgets/widget-def';
27
+ import { parseMarkup } from './markup/parse';
28
+ import { registerBlockMacro } from './markup/ast';
29
+ import { registerWidgetDef } from './components/macros/Widget';
23
30
  import { errorMessage } from './utils/error-message';
24
31
  import type { ASTNode } from './markup/ast';
25
32
  import './macros/register-builtins';
@@ -45,10 +52,36 @@ function renderErrors(root: HTMLElement, errors: string[]) {
45
52
  root.appendChild(container);
46
53
  }
47
54
 
55
+ /** Show the validation errors instead of the story, and stop booting. */
56
+ function stopWithErrors(errors: string[]): never {
57
+ const root = document.getElementById('root');
58
+ if (root) renderErrors(root, errors);
59
+ throw new Error(
60
+ `spindle: ${errors.length} validation error(s):\n${errors.join('\n')}`,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The markup errors (malformed markup, unknown macros) of the passages for
66
+ * which `only` holds, with the macros and widgets known now.
67
+ */
68
+ function markupErrors(
69
+ storyData: StoryData,
70
+ only: (passage: MarkupPassage) => boolean,
71
+ ): string[] {
72
+ return validateMarkup(storyData.passages.values(), {
73
+ isKnownMacro: (name) =>
74
+ !!getMacro(name) || isSubMacro(name) || !!getWidget(name),
75
+ macroNames: getMacroRegistry().map((m) => m.name),
76
+ only,
77
+ }).map(formatDiagnostic);
78
+ }
79
+
48
80
  /**
49
81
  * Boot Spindle in the current document: parse `<tw-storydata>`, install the
50
- * `Story` API, run author JavaScript, validate `StoryVariables`, run
51
- * `StoryInit`, and render into `#root`. Dispatches `:storyready` when the
82
+ * `Story` API, run author JavaScript, validate `StoryVariables` and
83
+ * `StoryInit`'s markup, run `StoryInit`, validate the other passages' markup
84
+ * (with the macros StoryInit defined), and render into `#root`. Dispatches `:storyready` when the
52
85
  * first passage is shown. Call once per page (module state is global).
53
86
  * The browser story format calls it from `main.tsx`.
54
87
  */
@@ -101,7 +134,18 @@ export function boot() {
101
134
  const storeVarMacros = getMacroRegistry()
102
135
  .filter((m) => m.storeVar)
103
136
  .map((m) => m.name);
137
+ // Pass 1: Register the block widgets as block macros BEFORE any passage
138
+ // is parsed (validation and StoryInit included), so that passages
139
+ // invoking block widgets and widget bodies using other block widgets
140
+ // parse correctly regardless of passage or definition order.
141
+ for (const name of blockWidgetNames(storyData.passages.values())) {
142
+ registerBlockMacro(name);
143
+ }
144
+
104
145
  const errors = validatePassages(storyData.passages, schema, storeVarMacros);
146
+ // StoryInit's markup must be valid before it runs; the other passages are
147
+ // validated once it has run, as it may define macros (see below).
148
+ errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
105
149
 
106
150
  // Parse StoryTransients (optional — no error if missing)
107
151
  let transientDefaults: Record<string, unknown> = {};
@@ -124,13 +168,7 @@ export function boot() {
124
168
  transientDefaults = extractDefaults(transientSchema);
125
169
  }
126
170
 
127
- if (errors.length > 0) {
128
- const root = document.getElementById('root');
129
- if (root) renderErrors(root, errors);
130
- throw new Error(
131
- `spindle: ${errors.length} validation error(s):\n${errors.join('\n')}`,
132
- );
133
- }
171
+ if (errors.length > 0) stopWithErrors(errors);
134
172
 
135
173
  defaults = extractDefaults(schema);
136
174
  setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
@@ -140,36 +178,18 @@ export function boot() {
140
178
  // Enter runtime phase — handlers registered from here on are cleaned on restart
141
179
  enterRuntimePhase();
142
180
 
143
- // Pass 1: Pre-scan StoryInit and all widget passages to discover block
144
- // widgets. Register them as block macros BEFORE any tokenize/buildAST
145
- // calls (StoryInit's included), so that passages invoking block widgets
146
- // and widget bodies using other block widgets parse correctly regardless
147
- // of passage or definition order.
148
- const blockWidgetPattern =
149
- /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
150
- for (const [passageName, passage] of storyData.passages) {
151
- if (passageName === 'StoryInit' || passage.tags.includes('widget')) {
152
- let match;
153
- while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
154
- const name = match[1]!;
155
- const body = match[2]!;
156
- if (/\{@children\}/.test(body)) {
157
- registerBlockMacro(name);
158
- }
159
- }
160
- blockWidgetPattern.lastIndex = 0;
161
- }
162
- }
163
-
164
181
  // Run StoryInit, restore the session if the page was refreshed, and fire
165
182
  // storyinit after all state is settled (defaults + StoryInit + session)
166
183
  initializeStory(loadSession(storyData.ifid));
167
184
 
185
+ // Every other passage's markup, now that StoryInit may have defined macros
186
+ const markup = markupErrors(storyData, (p) => p.name !== 'StoryInit');
187
+ if (markup.length > 0) stopWithErrors(markup);
188
+
168
189
  // Pass 2: Full parse and register widgets from passages tagged "widget"
169
190
  for (const [, passage] of storyData.passages) {
170
191
  if (passage.tags.includes('widget')) {
171
- const widgetTokens = tokenize(passage.content);
172
- const widgetAST = buildAST(widgetTokens);
192
+ const widgetAST = parseMarkup(passage.content);
173
193
  for (const node of widgetAST) {
174
194
  if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
175
195
  // Read and registered as the {widget} macro does
@@ -3,7 +3,7 @@
3
3
  * and macro labels resolve the inline markup of passage text (variables,
4
4
  * expressions, macros, widgets) to a string.
5
5
  *
6
- * Such a value is parsed with the passage tokenizer in text mode, so `{…}`
6
+ * Such a value is parsed with the passage parser in text mode, so `{…}`
7
7
  * means the same as in passage text, and its AST is evaluated directly to a
8
8
  * string rather than rendered: the result is ready during the render that
9
9
  * needs it (so it goes through the usual attribute handling, boolean
@@ -17,11 +17,10 @@ import { evaluate } from './expression';
17
17
  import {
18
18
  SCOPE_SIGILS,
19
19
  SIGIL_SCOPES,
20
- tokenize,
21
20
  type VariableScope,
22
- } from './markup/tokenizer';
21
+ } from './markup/tokens';
23
22
  import type { Sigil } from './js-lexer';
24
- import { buildAST } from './markup/ast';
23
+ import { parseMarkup } from './markup/parse';
25
24
  import type { ASTNode, MacroNode, TextNode } from './markup/ast';
26
25
  import { getMacro, getMacroText, isSubMacro } from './registry';
27
26
  import type { MacroTextContext } from './registry';
@@ -76,7 +75,7 @@ export function parseText(template: string): ParsedText {
76
75
  let parsed = parseCache.get(template);
77
76
  if (parsed === undefined) {
78
77
  try {
79
- parsed = { nodes: buildAST(tokenize(template, { text: true })) };
78
+ parsed = { nodes: parseMarkup(template, { text: true }) };
80
79
  } catch (error) {
81
80
  parsed = { error };
82
81
  }