@rohal12/spindle 0.52.3 → 0.52.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.52.3",
3
+ "version": "0.52.4",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -5,12 +5,20 @@ import { defineMacro } from '../../define-macro';
5
5
  import { checkVariableName } from '../../utils/namespace';
6
6
  import { MacroError } from './MacroError';
7
7
 
8
- function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
9
- const tokens = rawArgs.trim().split(/\s+/);
10
- const name = tokens[0]!.replace(/["']/g, '');
11
- const params = tokens.slice(1).filter((t) => t.startsWith('@'));
12
- for (const param of params) checkVariableName(param.slice(1), param);
13
- return { name, params };
8
+ /**
9
+ * Read the arguments of a {widget} definition: its name (quotes dropped)
10
+ * and its parameters, the words after it that start with one of the
11
+ * characters of `sigils`.
12
+ */
13
+ export function parseWidgetDef(
14
+ rawArgs: string,
15
+ sigils = '@',
16
+ ): { name: string; params: string[] } {
17
+ const [first, ...rest] = rawArgs.trim().split(/\s+/);
18
+ return {
19
+ name: first!.replace(/["']/g, ''),
20
+ params: rest.filter((word) => sigils.includes(word[0]!)),
21
+ };
14
22
  }
15
23
 
16
24
  defineMacro({
@@ -20,7 +28,10 @@ defineMacro({
20
28
  let parsed: ReturnType<typeof parseWidgetDef> | undefined;
21
29
  let error: unknown;
22
30
  try {
23
- parsed = parseWidgetDef(rawArgs);
31
+ // Refuse `@` parameters that no namespace can hold
32
+ const def = parseWidgetDef(rawArgs);
33
+ for (const param of def.params) checkVariableName(param.slice(1), param);
34
+ parsed = def;
24
35
  } catch (err) {
25
36
  error = err;
26
37
  }
package/src/index.tsx CHANGED
@@ -21,6 +21,7 @@ import { tokenize } from './markup/tokenizer';
21
21
  import { buildAST, registerBlockMacro } from './markup/ast';
22
22
  import { registerWidget } from './widgets/widget-registry';
23
23
  import { astContainsChildren } from './widgets/ast-scanner';
24
+ import { parseWidgetDef } from './components/macros/Widget';
24
25
  import { errorMessage } from './utils/error-message';
25
26
  import type { ASTNode } from './markup/ast';
26
27
  import './macros/register-builtins';
@@ -173,14 +174,10 @@ export function boot() {
173
174
  const widgetAST = buildAST(widgetTokens);
174
175
  for (const node of widgetAST) {
175
176
  if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
176
- const tokens2 = node.rawArgs.trim().split(/\s+/);
177
- const widgetName = tokens2[0]!.replace(/["']/g, '');
178
- const params = tokens2
179
- .slice(1)
180
- .filter(
181
- (t) =>
182
- t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
183
- );
177
+ const { name: widgetName, params } = parseWidgetDef(
178
+ node.rawArgs,
179
+ '$_@',
180
+ );
184
181
  const children = node.children as ASTNode[];
185
182
  const isBlock = astContainsChildren(children);
186
183
  try {
@@ -14,7 +14,13 @@
14
14
  * string, so it is reported instead of being run or shown as source.
15
15
  */
16
16
  import { evaluate } from './expression';
17
- import { tokenize } from './markup/tokenizer';
17
+ import {
18
+ SCOPE_SIGILS,
19
+ SIGIL_SCOPES,
20
+ tokenize,
21
+ type VariableScope,
22
+ } from './markup/tokenizer';
23
+ import type { Sigil } from './js-lexer';
18
24
  import { buildAST } from './markup/ast';
19
25
  import type { ASTNode, MacroNode, TextNode } from './markup/ast';
20
26
  import { getMacro, getMacroText, isSubMacro } from './registry';
@@ -116,15 +122,6 @@ function display(value: unknown): string {
116
122
  return value == null ? '' : String(value);
117
123
  }
118
124
 
119
- type VariableKind = 'variable' | 'temporary' | 'local' | 'transient';
120
-
121
- const KIND_SIGILS = {
122
- variable: '$',
123
- temporary: '_',
124
- local: '@',
125
- transient: '%',
126
- } as const;
127
-
128
125
  /**
129
126
  * The value of a variable reference (`name` without its sigil, with an
130
127
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -133,12 +130,12 @@ const KIND_SIGILS = {
133
130
  */
134
131
  function resolveVariable(
135
132
  scope: TextScope,
136
- kind: VariableKind,
133
+ kind: VariableScope,
137
134
  name: string,
138
135
  ): unknown {
139
136
  const parts = name.split('.');
140
137
  const root = parts[0]!;
141
- checkVariableName(root, KIND_SIGILS[kind] + root);
138
+ checkVariableName(root, SCOPE_SIGILS[kind] + root);
142
139
  const store =
143
140
  kind === 'variable'
144
141
  ? scope.variables
@@ -210,12 +207,16 @@ class TextEvaluator {
210
207
  }
211
208
 
212
209
  /** A variable reference, or '' and an error if it can't be one. */
213
- private variable(kind: VariableKind, name: string, scope: TextScope): string {
210
+ private variable(
211
+ kind: VariableScope,
212
+ name: string,
213
+ scope: TextScope,
214
+ ): string {
214
215
  try {
215
216
  return display(resolveVariable(scope, kind, name));
216
217
  } catch (error) {
217
218
  // Reported as passage text reports it: `{$__proto__ error: …}`
218
- this.errors.push({ macro: KIND_SIGILS[kind] + name, error });
219
+ this.errors.push({ macro: SCOPE_SIGILS[kind] + name, error });
219
220
  return '';
220
221
  }
221
222
  }
@@ -341,13 +342,6 @@ export function interpolateText(
341
342
  return renderText(parsed.nodes, scope);
342
343
  }
343
344
 
344
- const SIGIL_KINDS = {
345
- $: 'variable',
346
- _: 'temporary',
347
- '@': 'local',
348
- '%': 'transient',
349
- } as const;
350
-
351
345
  /**
352
346
  * Resolve the sigil references in the value of a code attribute (see
353
347
  * isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
@@ -367,7 +361,7 @@ export function interpolateCode(
367
361
  } else if ('verbatim' in part) {
368
362
  text += part.verbatim;
369
363
  } else if (/^[$_@%][\w.]+$/.test(part.expr)) {
370
- const kind = SIGIL_KINDS[part.expr[0] as keyof typeof SIGIL_KINDS];
364
+ const kind = SIGIL_SCOPES[part.expr[0] as Sigil];
371
365
  try {
372
366
  text += display(resolveVariable(scope, kind, part.expr.slice(1)));
373
367
  } catch (error) {
package/src/js-lexer.ts CHANGED
@@ -145,15 +145,14 @@ function scanRegex(
145
145
  };
146
146
  let i = start + 1;
147
147
  while (i < src.length) {
148
+ // Unterminated at the end of the line: leave the rest to the parser
149
+ const lineEnd = regexLineEnd(src, i);
150
+ if (lineEnd !== -1) return done(lineEnd, false);
148
151
  const c = src.charAt(i);
149
152
  if (c === '\\') {
150
- // An escaped line break ends the line, and the regex, too
151
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
152
153
  i += 2;
153
154
  continue;
154
155
  }
155
- // Unterminated at the end of the line: leave the rest to the parser
156
- if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
157
156
  if (c === '[') {
158
157
  // A class, where `/` does not close
159
158
  i = scanRegexClass(src, i, cache);
@@ -191,16 +190,17 @@ function scanRegexClass(
191
190
  const opens = [open];
192
191
  let i = open + 1;
193
192
  while (i < src.length) {
193
+ const lineEnd = regexLineEnd(src, i);
194
+ if (lineEnd !== -1) {
195
+ i = lineEnd;
196
+ break;
197
+ }
194
198
  const c = src.charAt(i);
195
199
  if (c === '\\') {
196
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
197
- i++;
198
- break;
199
- }
200
200
  i += 2;
201
201
  continue;
202
202
  }
203
- if (c === ']' || LINE_TERMINATOR_RE.test(c)) break;
203
+ if (c === ']') break;
204
204
  if (c === '[' && cache) opens.push(i);
205
205
  i++;
206
206
  }
@@ -209,6 +209,17 @@ function scanRegexClass(
209
209
  return i;
210
210
  }
211
211
 
212
+ /**
213
+ * Index of the line break at `i`, escaped (`\` then a line break) or not,
214
+ * where a regex literal or class ends unterminated; -1 if there is none.
215
+ */
216
+ function regexLineEnd(src: string, i: number): number {
217
+ const c = src.charAt(i);
218
+ if (LINE_TERMINATOR_RE.test(c)) return i;
219
+ if (c === '\\' && LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return i + 1;
220
+ return -1;
221
+ }
222
+
212
223
  /**
213
224
  * The next index from `from` on where `find` matches (-1 for none), for
214
225
  * searches from increasing positions: a search from within the stretch the
package/src/markup/ast.ts CHANGED
@@ -1,40 +1,39 @@
1
- import type { Token } from './tokenizer';
1
+ import {
2
+ withSelectors,
3
+ type HtmlToken,
4
+ type MacroToken,
5
+ type Selectors,
6
+ type Token,
7
+ type VariableScope,
8
+ } from './tokenizer';
2
9
 
3
10
  export interface TextNode {
4
11
  type: 'text';
5
12
  value: string;
6
13
  }
7
14
 
8
- export interface VariableNode {
15
+ export interface VariableNode extends Selectors {
9
16
  type: 'variable';
10
17
  name: string;
11
- scope: 'variable' | 'temporary' | 'local' | 'transient';
12
- className?: string;
13
- id?: string;
18
+ scope: VariableScope;
14
19
  }
15
20
 
16
- export interface ExpressionNode {
21
+ export interface ExpressionNode extends Selectors {
17
22
  type: 'expression';
18
23
  expression: string;
19
- className?: string;
20
- id?: string;
21
24
  }
22
25
 
23
- export interface Branch {
26
+ export interface Branch extends Selectors {
24
27
  rawArgs: string;
25
- className?: string;
26
- id?: string;
27
28
  children: ASTNode[];
28
29
  }
29
30
 
30
- export interface MacroNode {
31
+ export interface MacroNode extends Selectors {
31
32
  type: 'macro';
32
33
  name: string;
33
34
  rawArgs: string;
34
35
  children: ASTNode[];
35
36
  branches?: Branch[];
36
- className?: string;
37
- id?: string;
38
37
  }
39
38
 
40
39
  export interface HtmlNode {
@@ -99,6 +98,24 @@ function quoteArg(value: string): string {
99
98
  return `"${value.replace(/[\\"]/g, '\\$&')}"`;
100
99
  }
101
100
 
101
+ /** A new branch for the macro token opening it, with its selectors. */
102
+ function newBranch(token: MacroToken): Branch {
103
+ return withSelectors<Branch>({ rawArgs: token.rawArgs, children: [] }, token);
104
+ }
105
+
106
+ /** A macro or HTML element, or a token opening or closing one. */
107
+ type Tagged = MacroNode | HtmlNode | MacroToken | HtmlToken;
108
+
109
+ /** The name of a macro or HTML element. */
110
+ function tagName(tagged: Tagged): string {
111
+ return tagged.type === 'html' ? tagged.tag : tagged.name;
112
+ }
113
+
114
+ /** The tag closing a macro or HTML element, as written in markup. */
115
+ function closerOf(tagged: Tagged): string {
116
+ return tagged.type === 'html' ? `</${tagged.tag}>` : `{/${tagged.name}}`;
117
+ }
118
+
102
119
  /**
103
120
  * Build an AST from a token array. Block macros are nested into trees
104
121
  * using a stack. Throws on unclosed or mismatched macros.
@@ -119,6 +136,32 @@ export function buildAST(tokens: Token[]): ASTNode[] {
119
136
  return top.children;
120
137
  }
121
138
 
139
+ /**
140
+ * Pop the node on top of the stack, which the closing `token` (`</b>`,
141
+ * `{/if}`) must close. Names match ignoring case.
142
+ */
143
+ function close(token: MacroToken | HtmlToken) {
144
+ const found = closerOf(token);
145
+ if (stack.length === 0) {
146
+ throw new Error(
147
+ `Unexpected closing ${found} (at character ${token.start})`,
148
+ );
149
+ }
150
+
151
+ const top = stack[stack.length - 1]!;
152
+ if (
153
+ top.node.type !== token.type ||
154
+ tagName(top.node).toLowerCase() !== tagName(token).toLowerCase()
155
+ ) {
156
+ throw new Error(
157
+ `Expected ${closerOf(top.node)} but found ${found} (at character ${token.start})`,
158
+ );
159
+ }
160
+
161
+ stack.pop();
162
+ current().push(top.node);
163
+ }
164
+
122
165
  for (const token of tokens) {
123
166
  switch (token.type) {
124
167
  case 'text':
@@ -127,120 +170,64 @@ export function buildAST(tokens: Token[]): ASTNode[] {
127
170
 
128
171
  case 'link': {
129
172
  const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
130
- const macroNode: MacroNode = {
131
- type: 'macro',
132
- name: 'link',
133
- rawArgs,
134
- children: [],
135
- };
136
- if (token.className) macroNode.className = token.className;
137
- if (token.id) macroNode.id = token.id;
138
- current().push(macroNode);
173
+ current().push(
174
+ withSelectors<MacroNode>(
175
+ { type: 'macro', name: 'link', rawArgs, children: [] },
176
+ token,
177
+ ),
178
+ );
139
179
  break;
140
180
  }
141
181
 
142
- case 'variable': {
143
- const varNode: VariableNode = {
144
- type: 'variable',
145
- name: token.name,
146
- scope: token.scope,
147
- };
148
- if (token.className) varNode.className = token.className;
149
- if (token.id) varNode.id = token.id;
150
- current().push(varNode);
182
+ case 'variable':
183
+ current().push(
184
+ withSelectors<VariableNode>(
185
+ { type: 'variable', name: token.name, scope: token.scope },
186
+ token,
187
+ ),
188
+ );
151
189
  break;
152
- }
153
190
 
154
- case 'expression': {
155
- const exprNode: ExpressionNode = {
156
- type: 'expression',
157
- expression: token.expression,
158
- };
159
- if (token.className) exprNode.className = token.className;
160
- if (token.id) exprNode.id = token.id;
161
- current().push(exprNode);
191
+ case 'expression':
192
+ current().push(
193
+ withSelectors<ExpressionNode>(
194
+ { type: 'expression', expression: token.expression },
195
+ token,
196
+ ),
197
+ );
162
198
  break;
163
- }
164
199
 
165
200
  case 'html': {
166
- if (token.isSelfClose) {
167
- // Self-closing HTML tag (br, hr, img, etc.)
168
- current().push({
169
- type: 'html',
170
- tag: token.tag,
171
- attributes: token.attributes,
172
- children: [],
173
- });
174
- break;
175
- }
176
-
177
- if (token.isClose) {
178
- // Closing HTML tag — pop from stack
179
- if (stack.length === 0) {
180
- throw new Error(
181
- `Unexpected closing </${token.tag}> (at character ${token.start})`,
182
- );
183
- }
184
-
185
- const top = stack[stack.length - 1]!;
186
- if (
187
- top.node.type !== 'html' ||
188
- top.node.tag.toLowerCase() !== token.tag.toLowerCase()
189
- ) {
190
- const expected =
191
- top.node.type === 'html'
192
- ? `</${top.node.tag}>`
193
- : `{/${top.node.name}}`;
194
- throw new Error(
195
- `Expected ${expected} but found </${token.tag}> (at character ${token.start})`,
196
- );
197
- }
198
-
199
- stack.pop();
200
- current().push(top.node);
201
- break;
202
- }
203
-
204
- // Opening HTML tag — push onto stack
205
201
  const htmlNode: HtmlNode = {
206
202
  type: 'html',
207
203
  tag: token.tag,
208
204
  attributes: token.attributes,
209
205
  children: [],
210
206
  };
211
- stack.push({ node: htmlNode, start: token.start });
207
+ if (token.isSelfClose) {
208
+ // Self-closing HTML tag (br, hr, img, etc.)
209
+ current().push(htmlNode);
210
+ } else if (token.isClose) {
211
+ // Closing HTML tag — pop from stack
212
+ close(token);
213
+ } else {
214
+ // Opening HTML tag — push onto stack
215
+ stack.push({ node: htmlNode, start: token.start });
216
+ }
212
217
  break;
213
218
  }
214
219
 
215
220
  case 'macro': {
216
- // Normalize macro name to lowercase — registration is lowercase,
217
- // but the tokenizer preserves original casing from passage markup.
218
- const name = token.name.toLowerCase();
219
-
220
221
  if (token.isClose) {
221
222
  // Closing tag — pop from stack
222
- if (stack.length === 0) {
223
- throw new Error(
224
- `Unexpected closing {/${token.name}} (at character ${token.start})`,
225
- );
226
- }
227
-
228
- const top = stack[stack.length - 1]!;
229
- if (top.node.type !== 'macro' || top.node.name !== name) {
230
- const expected =
231
- top.node.type === 'macro'
232
- ? `{/${top.node.name}}`
233
- : `</${top.node.tag}>`;
234
- throw new Error(
235
- `Expected ${expected} but found {/${token.name}} (at character ${token.start})`,
236
- );
237
- }
238
-
239
- stack.pop();
240
- current().push(top.node);
223
+ close(token);
241
224
  break;
242
225
  }
243
226
 
227
+ // Normalize macro name to lowercase — registration is lowercase,
228
+ // but the tokenizer preserves original casing from passage markup.
229
+ const name = token.name.toLowerCase();
230
+
244
231
  // Handle branch macros (elseif/else, case/default, next)
245
232
  const expectedParent = Object.prototype.hasOwnProperty.call(
246
233
  BRANCH_PARENT,
@@ -261,55 +248,29 @@ export function buildAST(tokens: Token[]): ASTNode[] {
261
248
  );
262
249
  }
263
250
 
264
- const branch: Branch = {
265
- rawArgs: token.rawArgs,
266
- children: [],
267
- };
268
- if (token.className) branch.className = token.className;
269
- if (token.id) branch.id = token.id;
270
- topNode.branches!.push(branch);
251
+ topNode.branches!.push(newBranch(token));
271
252
  break;
272
253
  }
273
254
 
274
- // Block macro — push onto stack
275
- if (BLOCK_MACROS.has(name)) {
276
- const node: MacroNode = {
277
- type: 'macro',
278
- name,
279
- rawArgs: token.rawArgs,
280
- children: [],
281
- };
282
-
283
- // Branching blocks: className/id goes on the first branch, not the node
284
- if (BRANCHING_BLOCK_MACROS.has(name)) {
285
- const firstBranch: Branch = {
286
- rawArgs: token.rawArgs,
287
- children: [],
288
- };
289
- if (token.className) firstBranch.className = token.className;
290
- if (token.id) firstBranch.id = token.id;
291
- node.branches = [firstBranch];
292
- } else {
293
- if (token.className) node.className = token.className;
294
- if (token.id) node.id = token.id;
295
- }
255
+ const node: MacroNode = {
256
+ type: 'macro',
257
+ name,
258
+ rawArgs: token.rawArgs,
259
+ children: [],
260
+ };
296
261
 
297
- stack.push({ node, start: token.start });
262
+ if (!BLOCK_MACROS.has(name)) {
263
+ // Self-closing macro (set, print, etc.)
264
+ current().push(withSelectors(node, token));
298
265
  break;
299
266
  }
300
267
 
301
- // Self-closing macro (set, print, etc.)
302
- {
303
- const macroNode: MacroNode = {
304
- type: 'macro',
305
- name,
306
- rawArgs: token.rawArgs,
307
- children: [],
308
- };
309
- if (token.className) macroNode.className = token.className;
310
- if (token.id) macroNode.id = token.id;
311
- current().push(macroNode);
312
- }
268
+ // Block macro — push onto stack. Branching blocks: className/id
269
+ // goes on the first branch, not the node
270
+ if (BRANCHING_BLOCK_MACROS.has(name))
271
+ node.branches = [newBranch(token)];
272
+ else withSelectors(node, token);
273
+ stack.push({ node, start: token.start });
313
274
  break;
314
275
  }
315
276
 
@@ -1,4 +1,4 @@
1
- import { createScanMemo, scanBalancedBrace } from './tokenizer';
1
+ import { createScanMemo, isSigil, scanBalancedBrace } from './tokenizer';
2
2
 
3
3
  /**
4
4
  * Attributes whose value is code with braces of its own, not text: event
@@ -41,11 +41,9 @@ export function splitSigilTemplate(template: string): SigilPart[] {
41
41
  }
42
42
  text += template.slice(i, brace);
43
43
  i = brace + 1;
44
- const sigil = template[i];
45
- const end =
46
- sigil === '$' || sigil === '_' || sigil === '@' || sigil === '%'
47
- ? scanBalancedBrace(template, i, memo)
48
- : -1;
44
+ const end = isSigil(template[i])
45
+ ? scanBalancedBrace(template, i, memo)
46
+ : -1;
49
47
  if (end === -1) {
50
48
  text += '{';
51
49
  continue;