@rohal12/spindle 0.53.0 → 0.55.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 (44) hide show
  1. package/README.md +3 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +10058 -2075
  4. package/dist/pkg/macro-registry.json +4 -4
  5. package/dist/pkg/story-variables.js +10197 -1925
  6. package/dist/pkg/tooling.js +15 -1
  7. package/package.json +3 -1
  8. package/src/code-check.ts +314 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Do.tsx +1 -1
  13. package/src/components/macros/Goto.tsx +19 -8
  14. package/src/components/macros/Include.tsx +23 -11
  15. package/src/components/macros/Set.tsx +1 -1
  16. package/src/components/macros/VarDisplay.tsx +1 -1
  17. package/src/components/macros/Widget.tsx +5 -33
  18. package/src/components/macros/macro-args.ts +16 -7
  19. package/src/define-macro.ts +2 -0
  20. package/src/expression.ts +25 -20
  21. package/src/index.tsx +63 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/js-lexer.ts +981 -1342
  24. package/src/markup/ast.ts +11 -226
  25. package/src/markup/code-attributes.ts +3 -3
  26. package/src/markup/code-end.ts +121 -0
  27. package/src/markup/parse.ts +118 -0
  28. package/src/markup/render.tsx +1 -1
  29. package/src/markup/spindle.d.peggy.ts +24 -0
  30. package/src/markup/spindle.peggy +463 -0
  31. package/src/markup/tokens.ts +95 -0
  32. package/src/markup/validate.ts +281 -0
  33. package/src/parser.ts +19 -4
  34. package/src/registry.ts +49 -14
  35. package/src/runtime-errors.ts +10 -0
  36. package/src/store.ts +8 -2
  37. package/src/story-init.ts +3 -5
  38. package/src/story-variables.ts +49 -8
  39. package/src/tooling.ts +58 -0
  40. package/src/types-drift-check.ts +28 -1
  41. package/src/widgets/widget-def.ts +60 -0
  42. package/types/index.d.ts +7 -3
  43. package/types/tooling.d.ts +53 -3
  44. package/src/markup/tokenizer.ts +0 -1112
@@ -34,6 +34,7 @@ import {
34
34
  readBoundVariable,
35
35
  } from './components/macros/macro-args';
36
36
  import {
37
+ checkParameterTypes,
37
38
  registerMacro,
38
39
  registerMacroText,
39
40
  registerSubMacro,
@@ -136,6 +137,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
136
137
  source: 'builtin' | 'user' = 'builtin',
137
138
  ): void {
138
139
  const parameters: readonly ParameterDef[] = config.parameters ?? [];
140
+ checkParameterTypes(config.name, parameters);
139
141
 
140
142
  function Wrapper(props: MacroProps) {
141
143
  // className/id resolved first (interpolate may transform them)
package/src/expression.ts CHANGED
@@ -2,7 +2,7 @@ import type { StoryState } from './store';
2
2
  import { useStoryStore } from './store';
3
3
  import type { Passage } from './parser';
4
4
  import { random, randomInt } from './prng';
5
- import { lexJs, type JsGoal, type Sigil } from './js-lexer';
5
+ import { parseCode, type JsGoal, type Sigil } from './js-lexer';
6
6
  import {
7
7
  EMPTY_NAMESPACE,
8
8
  asNamespace,
@@ -63,29 +63,33 @@ const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
63
63
  * Exported for tests.
64
64
  */
65
65
  export function transform(expr: string, goal: JsGoal = 'expression'): string {
66
+ const key = goal === 'statements' ? 's' + expr : 'e' + expr;
67
+ const cached = transformCache.get(key);
68
+ if (cached !== undefined) return cached;
69
+ const { refs } = parseCode(expr, goal);
66
70
  let result = '';
67
- lexJs(
68
- expr,
69
- {
70
- code(ch) {
71
- result += ch;
72
- },
73
- literal(text) {
74
- result += text;
75
- },
76
- variable(sigil, name) {
77
- const error = variableNameError(name, sigil + name);
78
- if (error) throw new SyntaxError(`spindle: ${error}`);
79
- // `typeof%x` needs a space once `%x` turns into an identifier.
80
- if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
81
- result += `${NAMESPACES[sigil]}["${name}"]`;
82
- },
83
- },
84
- goal,
85
- );
71
+ let at = 0;
72
+ for (const ref of refs) {
73
+ const error = variableNameError(ref.name, ref.sigil + ref.name);
74
+ if (error) throw new SyntaxError(`spindle: ${error}`);
75
+ result += expr.slice(at, ref.start);
76
+ if (ref.shorthand) result += `${ref.name}: `;
77
+ // `typeof%x` needs a space once `%x` turns into an identifier.
78
+ else if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
79
+ result += `${NAMESPACES[ref.sigil]}["${ref.name}"]`;
80
+ at = ref.end;
81
+ }
82
+ result += expr.slice(at);
83
+ if (transformCache.size >= FN_CACHE_MAX) {
84
+ transformCache.delete(transformCache.keys().next().value!);
85
+ }
86
+ transformCache.set(key, result);
86
87
  return result;
87
88
  }
88
89
 
90
+ /** Transformed code by goal and source. */
91
+ const transformCache = new Map<string, string>();
92
+
89
93
  const preamble =
90
94
  'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
91
95
 
@@ -265,6 +269,7 @@ export function execute(code: string, ...namespaces: Namespaces): void {
265
269
  /** Clear the compiled expression cache. Useful for testing and HMR. */
266
270
  export function clearExpressionCache(): void {
267
271
  fnCache.clear();
272
+ transformCache.clear();
268
273
  cachedFns = null;
269
274
  cachedVisitCounts = null;
270
275
  cachedRenderCounts = null;
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,42 @@ 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, syntax errors in
66
+ * code) of the passages for which `only` holds, with the macros and widgets
67
+ * known now.
68
+ */
69
+ function markupErrors(
70
+ storyData: StoryData,
71
+ only: (passage: MarkupPassage) => boolean,
72
+ ): string[] {
73
+ const macros = getMacroRegistry();
74
+ const parameters = new Map(
75
+ macros.map((m) => [m.name.toLowerCase(), m.parameters]),
76
+ );
77
+ return validateMarkup(storyData.passages.values(), {
78
+ isKnownMacro: (name) =>
79
+ !!getMacro(name) || isSubMacro(name) || !!getWidget(name),
80
+ macroNames: macros.map((m) => m.name),
81
+ parametersOf: (name) => parameters.get(name),
82
+ only,
83
+ }).map(formatDiagnostic);
84
+ }
85
+
48
86
  /**
49
87
  * 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
88
+ * `Story` API, run author JavaScript, validate `StoryVariables` and
89
+ * `StoryInit`'s markup, run `StoryInit`, validate the other passages' markup
90
+ * (with the macros StoryInit defined), and render into `#root`. Dispatches `:storyready` when the
52
91
  * first passage is shown. Call once per page (module state is global).
53
92
  * The browser story format calls it from `main.tsx`.
54
93
  */
@@ -101,7 +140,18 @@ export function boot() {
101
140
  const storeVarMacros = getMacroRegistry()
102
141
  .filter((m) => m.storeVar)
103
142
  .map((m) => m.name);
143
+ // Pass 1: Register the block widgets as block macros BEFORE any passage
144
+ // is parsed (validation and StoryInit included), so that passages
145
+ // invoking block widgets and widget bodies using other block widgets
146
+ // parse correctly regardless of passage or definition order.
147
+ for (const name of blockWidgetNames(storyData.passages.values())) {
148
+ registerBlockMacro(name);
149
+ }
150
+
104
151
  const errors = validatePassages(storyData.passages, schema, storeVarMacros);
152
+ // StoryInit's markup must be valid before it runs; the other passages are
153
+ // validated once it has run, as it may define macros (see below).
154
+ errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
105
155
 
106
156
  // Parse StoryTransients (optional — no error if missing)
107
157
  let transientDefaults: Record<string, unknown> = {};
@@ -124,13 +174,7 @@ export function boot() {
124
174
  transientDefaults = extractDefaults(transientSchema);
125
175
  }
126
176
 
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
- }
177
+ if (errors.length > 0) stopWithErrors(errors);
134
178
 
135
179
  defaults = extractDefaults(schema);
136
180
  setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
@@ -140,36 +184,18 @@ export function boot() {
140
184
  // Enter runtime phase — handlers registered from here on are cleaned on restart
141
185
  enterRuntimePhase();
142
186
 
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
187
  // Run StoryInit, restore the session if the page was refreshed, and fire
165
188
  // storyinit after all state is settled (defaults + StoryInit + session)
166
189
  initializeStory(loadSession(storyData.ifid));
167
190
 
191
+ // Every other passage's markup, now that StoryInit may have defined macros
192
+ const markup = markupErrors(storyData, (p) => p.name !== 'StoryInit');
193
+ if (markup.length > 0) stopWithErrors(markup);
194
+
168
195
  // Pass 2: Full parse and register widgets from passages tagged "widget"
169
196
  for (const [, passage] of storyData.passages) {
170
197
  if (passage.tags.includes('widget')) {
171
- const widgetTokens = tokenize(passage.content);
172
- const widgetAST = buildAST(widgetTokens);
198
+ const widgetAST = parseMarkup(passage.content);
173
199
  for (const node of widgetAST) {
174
200
  if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
175
201
  // 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
  }