@rohal12/spindle 0.53.0 → 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.
@@ -2,7 +2,9 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
- export { parseStoryVariables } from './story-variables.js';
5
+ import { validateStoryMarkup } from './story-variables.js';
6
+
7
+ export { parseStoryVariables, formatDiagnostic } from './story-variables.js';
6
8
 
7
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
10
 
@@ -44,3 +46,13 @@ export function defineMacro(config) {
44
46
  export function getMacroRegistry() {
45
47
  return Array.from(metadata.values());
46
48
  }
49
+
50
+ /**
51
+ * Validate the markup of a story's passages as Spindle does when the story
52
+ * starts: malformed markup and unknown macros (checked against the built-in
53
+ * macros and those registered with defineMacro), with passage, line and
54
+ * column.
55
+ */
56
+ export function validateMarkup(passages) {
57
+ return validateStoryMarkup(passages, metadata.values());
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -94,6 +94,7 @@
94
94
  "js-yaml": "^4.1.1",
95
95
  "jscpd": "5.4.0",
96
96
  "lint-staged": "^16.4.0",
97
+ "peggy": "5.1.0",
97
98
  "playwright": "^1.58.2",
98
99
  "prettier": "^3.8.1",
99
100
  "typescript": "^6.0.2",
@@ -5,8 +5,8 @@ import {
5
5
  useRef,
6
6
  useState,
7
7
  } from 'preact/hooks';
8
- import { tokenize } from '../markup/tokenizer';
9
- import { buildAST, type ASTNode } from '../markup/ast';
8
+ import { parseMarkup } from '../markup/parse';
9
+ import type { ASTNode } from '../markup/ast';
10
10
  import { renderNodes, NobrContext } from '../markup/render';
11
11
  import { useStoryFields } from '../hooks/use-story-fields';
12
12
  import type { Passage as PassageData } from '../parser';
@@ -29,7 +29,7 @@ const astCache = new WeakMap<
29
29
  function parsePassage(passage: PassageData): ASTNode[] {
30
30
  const cached = astCache.get(passage);
31
31
  if (cached && cached.content === passage.content) return cached.ast;
32
- const ast = buildAST(tokenize(passage.content));
32
+ const ast = parseMarkup(passage.content);
33
33
  astCache.set(passage, { content: passage.content, ast });
34
34
  return ast;
35
35
  }
@@ -1,7 +1,6 @@
1
1
  import { createContext } from 'preact';
2
2
  import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
3
- import { tokenize } from '../markup/tokenizer';
4
- import { buildAST } from '../markup/ast';
3
+ import { parseMarkup } from '../markup/parse';
5
4
  import { renderNodes, NobrContext } from '../markup/render';
6
5
  import { useStoryFields } from '../hooks/use-story-fields';
7
6
  import { emitFromRender } from '../event-emitter';
@@ -53,8 +52,7 @@ export function PassageDialog({
53
52
  return <div class="error">Dialog: no content available</div>;
54
53
  }
55
54
  try {
56
- const tokens = tokenize(markup);
57
- const ast = buildAST(tokens);
55
+ const ast = parseMarkup(markup);
58
56
  const nodes = renderNodes(ast, nobr ? { nobr: true } : undefined);
59
57
  return nobr ? (
60
58
  <NobrContext.Provider value={true}>{nodes}</NobrContext.Provider>
@@ -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 (
@@ -5,7 +5,7 @@ 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(() => {
@@ -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
 
@@ -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
  }
package/src/markup/ast.ts CHANGED
@@ -1,11 +1,8 @@
1
- import {
2
- withSelectors,
3
- type HtmlToken,
4
- type MacroToken,
5
- type Selectors,
6
- type Token,
7
- type VariableScope,
8
- } from './tokenizer';
1
+ /**
2
+ * The passage AST (built by parse.ts), and the registry of block macros:
3
+ * those whose body the parser nests up to their `{/name}`.
4
+ */
5
+ import type { Selectors, VariableScope } from './tokens';
9
6
  import { NameSet } from '../utils/macro-names';
10
7
 
11
8
  export interface TextNode {
@@ -69,7 +66,12 @@ const BLOCK_MACROS = new NameSet([
69
66
  'nobr',
70
67
  ]);
71
68
 
72
- /** Register a custom macro as a block macro so the AST builder nests children. */
69
+ /** Whether a macro takes a body closed by `{/name}`. */
70
+ export function isBlockMacro(name: string): boolean {
71
+ return BLOCK_MACROS.has(name);
72
+ }
73
+
74
+ /** Register a custom macro as a block macro so the parser nests children. */
73
75
  export function registerBlockMacro(name: string): void {
74
76
  BLOCK_MACROS.add(name);
75
77
  }
@@ -78,220 +80,3 @@ export function registerBlockMacro(name: string): void {
78
80
  export function unregisterBlockMacro(name: string): void {
79
81
  BLOCK_MACROS.delete(name);
80
82
  }
81
-
82
- /** Map from branch macro name → required parent macro name */
83
- const BRANCH_PARENT: Record<string, string> = {
84
- elseif: 'if',
85
- else: 'if',
86
- case: 'switch',
87
- default: 'switch',
88
- next: 'timed',
89
- };
90
-
91
- /** Block macros that use the branches[] array */
92
- const BRANCHING_BLOCK_MACROS = new Set(['if', 'switch', 'timed']);
93
-
94
- /**
95
- * Quote a bracket-link value as a {link} argument, escaping backslashes and
96
- * double quotes so MacroLink reads the value back unchanged (#200).
97
- */
98
- function quoteArg(value: string): string {
99
- return `"${value.replace(/[\\"]/g, '\\$&')}"`;
100
- }
101
-
102
- /** A new branch for the macro token opening it, with its selectors. */
103
- function newBranch(token: MacroToken): Branch {
104
- return withSelectors<Branch>({ rawArgs: token.rawArgs, children: [] }, token);
105
- }
106
-
107
- /** A macro or HTML element, or a token opening or closing one. */
108
- type Tagged = MacroNode | HtmlNode | MacroToken | HtmlToken;
109
-
110
- /** The name of a macro or HTML element. */
111
- function tagName(tagged: Tagged): string {
112
- return tagged.type === 'html' ? tagged.tag : tagged.name;
113
- }
114
-
115
- /** The tag closing a macro or HTML element, as written in markup. */
116
- function closerOf(tagged: Tagged): string {
117
- return tagged.type === 'html' ? `</${tagged.tag}>` : `{/${tagged.name}}`;
118
- }
119
-
120
- /**
121
- * Build an AST from a token array. Block macros are nested into trees
122
- * using a stack. Throws on unclosed or mismatched macros.
123
- */
124
- export function buildAST(tokens: Token[]): ASTNode[] {
125
- const root: ASTNode[] = [];
126
-
127
- // Stack entries: the node being built and its token start position
128
- const stack: { node: MacroNode | HtmlNode; start: number }[] = [];
129
-
130
- function current(): ASTNode[] {
131
- if (stack.length === 0) return root;
132
- const top = stack[stack.length - 1]!.node;
133
- // For if-blocks, append to the last branch's children
134
- if (top.type === 'macro' && top.branches && top.branches.length > 0) {
135
- return top.branches[top.branches.length - 1]!.children;
136
- }
137
- return top.children;
138
- }
139
-
140
- /**
141
- * Pop the node on top of the stack, which the closing `token` (`</b>`,
142
- * `{/if}`) must close. Names match ignoring case.
143
- */
144
- function close(token: MacroToken | HtmlToken) {
145
- const found = closerOf(token);
146
- if (stack.length === 0) {
147
- throw new Error(
148
- `Unexpected closing ${found} (at character ${token.start})`,
149
- );
150
- }
151
-
152
- const top = stack[stack.length - 1]!;
153
- if (
154
- top.node.type !== token.type ||
155
- tagName(top.node).toLowerCase() !== tagName(token).toLowerCase()
156
- ) {
157
- throw new Error(
158
- `Expected ${closerOf(top.node)} but found ${found} (at character ${token.start})`,
159
- );
160
- }
161
-
162
- stack.pop();
163
- current().push(top.node);
164
- }
165
-
166
- for (const token of tokens) {
167
- switch (token.type) {
168
- case 'text':
169
- current().push({ type: 'text', value: token.value });
170
- break;
171
-
172
- case 'link': {
173
- const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
174
- current().push(
175
- withSelectors<MacroNode>(
176
- { type: 'macro', name: 'link', rawArgs, children: [] },
177
- token,
178
- ),
179
- );
180
- break;
181
- }
182
-
183
- case 'variable':
184
- current().push(
185
- withSelectors<VariableNode>(
186
- { type: 'variable', name: token.name, scope: token.scope },
187
- token,
188
- ),
189
- );
190
- break;
191
-
192
- case 'expression':
193
- current().push(
194
- withSelectors<ExpressionNode>(
195
- { type: 'expression', expression: token.expression },
196
- token,
197
- ),
198
- );
199
- break;
200
-
201
- case 'html': {
202
- const htmlNode: HtmlNode = {
203
- type: 'html',
204
- tag: token.tag,
205
- attributes: token.attributes,
206
- children: [],
207
- };
208
- if (token.isSelfClose) {
209
- // Self-closing HTML tag (br, hr, img, etc.)
210
- current().push(htmlNode);
211
- } else if (token.isClose) {
212
- // Closing HTML tag — pop from stack
213
- close(token);
214
- } else {
215
- // Opening HTML tag — push onto stack
216
- stack.push({ node: htmlNode, start: token.start });
217
- }
218
- break;
219
- }
220
-
221
- case 'macro': {
222
- if (token.isClose) {
223
- // Closing tag — pop from stack
224
- close(token);
225
- break;
226
- }
227
-
228
- // Normalize macro name to lowercase — registration is lowercase,
229
- // but the tokenizer preserves original casing from passage markup.
230
- const name = token.name.toLowerCase();
231
-
232
- // Handle branch macros (elseif/else, case/default, next)
233
- const expectedParent = Object.prototype.hasOwnProperty.call(
234
- BRANCH_PARENT,
235
- name,
236
- )
237
- ? BRANCH_PARENT[name]
238
- : undefined;
239
- if (expectedParent) {
240
- const topNode =
241
- stack.length > 0 ? stack[stack.length - 1]!.node : null;
242
- if (
243
- !topNode ||
244
- topNode.type !== 'macro' ||
245
- topNode.name !== expectedParent
246
- ) {
247
- throw new Error(
248
- `{${token.name}} without matching {${expectedParent}} (at character ${token.start})`,
249
- );
250
- }
251
-
252
- topNode.branches!.push(newBranch(token));
253
- break;
254
- }
255
-
256
- const node: MacroNode = {
257
- type: 'macro',
258
- name,
259
- rawArgs: token.rawArgs,
260
- children: [],
261
- };
262
-
263
- if (!BLOCK_MACROS.has(name)) {
264
- // Self-closing macro (set, print, etc.)
265
- current().push(withSelectors(node, token));
266
- break;
267
- }
268
-
269
- // Block macro — push onto stack. Branching blocks: className/id
270
- // goes on the first branch, not the node
271
- if (BRANCHING_BLOCK_MACROS.has(name))
272
- node.branches = [newBranch(token)];
273
- else withSelectors(node, token);
274
- stack.push({ node, start: token.start });
275
- break;
276
- }
277
-
278
- default: {
279
- const _exhaustive: never = token;
280
- throw new Error(`Unknown token type: ${(_exhaustive as Token).type}`);
281
- }
282
- }
283
- }
284
-
285
- if (stack.length > 0) {
286
- const unclosed = stack[stack.length - 1]!;
287
- const label =
288
- unclosed.node.type === 'html'
289
- ? `<${unclosed.node.tag}>`
290
- : `{${unclosed.node.name}} macro`;
291
- throw new Error(
292
- `Unclosed ${label} (opened at character ${unclosed.start})`,
293
- );
294
- }
295
-
296
- return root;
297
- }
@@ -1,4 +1,5 @@
1
- import { createScanMemo, isSigil, scanBalancedBrace } from './tokenizer';
1
+ import { isSigil } from './tokens';
2
+ import { defaultCodeEnd } from './code-end';
2
3
 
3
4
  /**
4
5
  * Attributes whose value is code with braces of its own, not text: event
@@ -29,7 +30,6 @@ export type SigilPart =
29
30
  */
30
31
  export function splitSigilTemplate(template: string): SigilPart[] {
31
32
  const parts: SigilPart[] = [];
32
- const memo = createScanMemo();
33
33
  let text = '';
34
34
  let i = 0;
35
35
 
@@ -42,7 +42,7 @@ export function splitSigilTemplate(template: string): SigilPart[] {
42
42
  text += template.slice(i, brace);
43
43
  i = brace + 1;
44
44
  const end = isSigil(template[i])
45
- ? scanBalancedBrace(template, i, memo)
45
+ ? defaultCodeEnd.closeBrace(template, i, i)
46
46
  : -1;
47
47
  if (end === -1) {
48
48
  text += '{';