@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
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 += '{';
@@ -0,0 +1,121 @@
1
+ import { findCodeEnd } from '../js-lexer';
2
+
3
+ /**
4
+ * Where the JavaScript inside markup ends. The grammar (spindle.peggy) leaves
5
+ * this to these two functions, so the JavaScript reader can be swapped (for
6
+ * example for acorn's `parseExpressionAt`) without touching the grammar.
7
+ */
8
+ export interface CodeEnd {
9
+ /**
10
+ * Index of the `}` ending the code that starts at `codeStart` (macro
11
+ * arguments, a `{$…}` expression), or -1 if there is none. Code that is
12
+ * not well-formed JavaScript is read leniently from `lenientStart`.
13
+ */
14
+ closeBrace(input: string, codeStart: number, lenientStart: number): number;
15
+ /**
16
+ * Index of the `{` of the `{/name}` ending the raw JavaScript body (of
17
+ * `{do}`) that starts at `bodyStart`, or -1 if there is none.
18
+ */
19
+ rawBodyEnd(input: string, bodyStart: number, name: string): number;
20
+ }
21
+
22
+ /**
23
+ * A quote directly after a letter/digit is an apostrophe (don't), not a
24
+ * string; after a backslash it is an escaped attribute delimiter (\").
25
+ */
26
+ const NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
27
+
28
+ /**
29
+ * Skip the literal opening at `i`: a '…' or "…" string, which must close on
30
+ * its line, or a `…` template, whose `${…}` parts are read as braces.
31
+ * Returns the index just past its closing quote or backtick, or -1.
32
+ */
33
+ function skipLiteral(input: string, i: number): number {
34
+ const quote = input[i];
35
+ const template = quote === '`';
36
+ for (let j = i + 1; j < input.length; ) {
37
+ const c = input[j];
38
+ if (c === '\\') j += 2;
39
+ else if (c === quote) return j + 1;
40
+ else if (!template && c === '\n') return -1;
41
+ else if (template && c === '$' && input[j + 1] === '{') {
42
+ const close = lenientClose(input, j + 2);
43
+ if (close === -1) return -1;
44
+ j = close + 1;
45
+ } else j++;
46
+ }
47
+ return -1;
48
+ }
49
+
50
+ /**
51
+ * The closing `}` of braces read leniently from `i` (just past a `{`): braces
52
+ * count except inside string and template literals, and a quote that can't
53
+ * start a string (an apostrophe, or one not closed on its line) is text, as
54
+ * is a backtick without a closing one.
55
+ */
56
+ export function lenientClose(input: string, i: number): number {
57
+ // Results per start, for the last input: unclosed nested template
58
+ // literals (`` {$a`${$a`${… ``) would otherwise take exponential time.
59
+ if (memoInput !== input) {
60
+ memoInput = input;
61
+ memo.clear();
62
+ }
63
+ let end = memo.get(i);
64
+ if (end === undefined) {
65
+ end = scanLenient(input, i);
66
+ memo.set(i, end);
67
+ }
68
+ return end;
69
+ }
70
+
71
+ let memoInput = '';
72
+ const memo = new Map<number, number>();
73
+
74
+ function scanLenient(input: string, i: number): number {
75
+ let depth = 0;
76
+ while (i < input.length) {
77
+ const c = input[i]!;
78
+ if (c === '{') {
79
+ depth++;
80
+ i++;
81
+ } else if (c === '}') {
82
+ if (depth === 0) return i;
83
+ depth--;
84
+ i++;
85
+ } else if (
86
+ c === '`' ||
87
+ ((c === '"' || c === "'") &&
88
+ !(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]!)))
89
+ ) {
90
+ const close = skipLiteral(input, i);
91
+ i = close === -1 ? i + 1 : close;
92
+ } else {
93
+ i++;
94
+ }
95
+ }
96
+ return -1;
97
+ }
98
+
99
+ /** The JavaScript reader Spindle uses today: js-lexer, then a lenient scan. */
100
+ export const defaultCodeEnd: CodeEnd = {
101
+ closeBrace(input, codeStart, lenientStart) {
102
+ const end = findCodeEnd(input, codeStart);
103
+ return end !== -1 ? end : lenientClose(input, lenientStart);
104
+ },
105
+ rawBodyEnd(input, bodyStart, name) {
106
+ const closer = `\\{/${name}\\s*\\}`;
107
+ const first = new RegExp(closer, 'gi');
108
+ first.lastIndex = bodyStart;
109
+ const firstAt = first.exec(input)?.index ?? -1;
110
+ if (firstAt === -1) return -1;
111
+ const at = new RegExp(closer, 'iy');
112
+ const end = findCodeEnd(input, bodyStart, {
113
+ goal: 'statements',
114
+ stop: (k) => {
115
+ at.lastIndex = k;
116
+ return at.test(input);
117
+ },
118
+ });
119
+ return end === -1 ? firstAt : end;
120
+ },
121
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Passage markup parser: the Peggy grammar (spindle.peggy) with Spindle's
3
+ * hooks plugged in. It builds the AST directly and reports malformed markup
4
+ * as a MarkupError carrying its line and column.
5
+ */
6
+ import { parse as pegParse } from './spindle.peggy';
7
+ import { isBlockMacro, type ASTNode } from './ast';
8
+ import type { Token } from './tokens';
9
+ import { isCodeAttribute } from './code-attributes';
10
+ import { defaultCodeEnd, type CodeEnd } from './code-end';
11
+
12
+ /** Macros whose body is JavaScript source, kept verbatim. */
13
+ const RAW_BODY_MACROS = new Set(['do']);
14
+
15
+ /** What the grammar leaves to code. */
16
+ export interface MarkupHooks extends CodeEnd {
17
+ /** Whether a macro takes a body closed by `{/name}`. */
18
+ isBlock(name: string): boolean;
19
+ /** Whether a macro's body is JavaScript, kept verbatim (`{do}`). */
20
+ isRaw(name: string): boolean;
21
+ /** Whether an attribute's value is code (`onclick`), not markup. */
22
+ isCodeAttribute(name: string): boolean;
23
+ }
24
+
25
+ const defaultHooks: MarkupHooks = {
26
+ ...defaultCodeEnd,
27
+ isBlock: isBlockMacro,
28
+ isRaw: (name) => RAW_BODY_MACROS.has(name),
29
+ isCodeAttribute,
30
+ };
31
+
32
+ /** Malformed markup, with where it starts (1-based line and column). */
33
+ export class MarkupError extends Error {
34
+ constructor(
35
+ /** What is wrong, without the position. */
36
+ readonly reason: string,
37
+ /** Where it is, from the start of the markup (0-based). */
38
+ readonly offset: number,
39
+ readonly line: number,
40
+ readonly column: number,
41
+ ) {
42
+ super(`${reason} (line ${line}, column ${column})`);
43
+ this.name = 'MarkupError';
44
+ }
45
+ }
46
+
47
+ export interface ParseMarkupOptions {
48
+ /**
49
+ * Text mode, for markup that becomes a string (HTML attribute values,
50
+ * macro labels): only `{…}` markup and brace escapes are recognized, while
51
+ * `[[` and `<` are text. With no markdown to pair up the backslashes of a
52
+ * run before a brace, they are paired up here: `\\{` is one backslash
53
+ * before a live brace, `\\\{` one before a literal one.
54
+ */
55
+ text?: boolean;
56
+ /** Replace some hooks, e.g. which macros take a body. */
57
+ hooks?: Partial<MarkupHooks>;
58
+ }
59
+
60
+ /** 1-based line and column of `offset` in `text`. */
61
+ export function lineColumn(
62
+ text: string,
63
+ offset: number,
64
+ ): { line: number; column: number } {
65
+ let line = 1;
66
+ let lineStart = 0;
67
+ for (let i = text.indexOf('\n'); i !== -1 && i < offset; ) {
68
+ line++;
69
+ lineStart = i + 1;
70
+ i = text.indexOf('\n', lineStart);
71
+ }
72
+ return { line, column: offset - lineStart + 1 };
73
+ }
74
+
75
+ function run(
76
+ source: string,
77
+ startRule: 'Markup' | 'Tokens',
78
+ options: ParseMarkupOptions,
79
+ ): unknown {
80
+ const hooks = options.hooks
81
+ ? { ...defaultHooks, ...options.hooks }
82
+ : defaultHooks;
83
+ try {
84
+ return pegParse(source, { startRule, text: options.text === true, hooks });
85
+ } catch (err) {
86
+ const location = (err as { location?: { start: { offset: number } } })
87
+ .location;
88
+ if (err instanceof Error && err.name === 'SyntaxError' && location) {
89
+ const { offset } = location.start;
90
+ const { line, column } = lineColumn(source, offset);
91
+ throw new MarkupError(err.message, offset, line, column);
92
+ }
93
+ throw err;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Parse markup into its AST: macros with their bodies and branches, HTML
99
+ * elements with their children. Throws a MarkupError for malformed markup.
100
+ */
101
+ export function parseMarkup(
102
+ source: string,
103
+ options: ParseMarkupOptions = {},
104
+ ): ASTNode[] {
105
+ return run(source, 'Markup', options) as ASTNode[];
106
+ }
107
+
108
+ /**
109
+ * The flat tokens of markup, without nesting, so unclosed or mismatched
110
+ * macros and elements are no error here. Throws a MarkupError for a
111
+ * malformed tag, such as an unclosed `{`, `[[` or attribute value.
112
+ */
113
+ export function tokenizeMarkup(
114
+ source: string,
115
+ options: ParseMarkupOptions = {},
116
+ ): Token[] {
117
+ return run(source, 'Tokens', options) as Token[];
118
+ }
@@ -297,7 +297,7 @@ function convertDomNode(
297
297
  }
298
298
 
299
299
  // Convert attributes, as author HTML's (see splitAttributes): markdown
300
- // output holds raw HTML the passage tokenizer didn't take as a tag
300
+ // output holds raw HTML from text nodes (a custom macro's, or comments)
301
301
  const svg = el.namespaceURI === SVG_NAMESPACE;
302
302
  const attributes: Attribute[] = [];
303
303
  let placeholders: Record<string, ASTNode[]> | undefined;
@@ -0,0 +1,24 @@
1
+ // Types of the parser spindle.peggy compiles to (see scripts/peggy.ts).
2
+
3
+ /** A position in the input; only `offset` is used by parse.ts. */
4
+ export interface Location {
5
+ offset: number;
6
+ line: number;
7
+ column: number;
8
+ }
9
+
10
+ /** What the generated parser throws for malformed markup. */
11
+ export declare class SyntaxError extends globalThis.SyntaxError {
12
+ location: { start: Location; end: Location };
13
+ }
14
+
15
+ export interface ParseOptions {
16
+ startRule: 'Markup' | 'Tokens';
17
+ /** Text mode (attribute values, labels). */
18
+ text: boolean;
19
+ /** What the grammar leaves to code (see parse.ts MarkupHooks). */
20
+ hooks: unknown;
21
+ }
22
+
23
+ /** Parse markup: the AST for `Markup`, the tokens for `Tokens`. */
24
+ export declare function parse(input: string, options: ParseOptions): unknown;