@rohal12/spindle 0.57.0 → 0.58.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,9 +2,36 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
- import { checkParameterTypes, validateStoryMarkup } from './story-variables.js';
5
+ import {
6
+ checkParameterTypes,
7
+ collectStoryPassageReferences,
8
+ validateStoryMarkup,
9
+ } from './story-variables.js';
6
10
 
7
- export { parseStoryVariables, formatDiagnostic } from './story-variables.js';
11
+ export {
12
+ parseStoryVariables,
13
+ formatDiagnostic,
14
+ findCodeEnd,
15
+ lexJs,
16
+ lexTemplate,
17
+ scanStringLiteral,
18
+ MarkupError,
19
+ parseSelectors,
20
+ tokenizeMarkup,
21
+ tokenizeMarkupTolerant,
22
+ transform,
23
+ passageTarget,
24
+ evaluatePassageName,
25
+ SIGIL_SCOPES,
26
+ isSigil,
27
+ endsWithOperator,
28
+ readQuoted,
29
+ splitArgs,
30
+ splitTopLevel,
31
+ stripLooseQuotes,
32
+ unescapeQuoted,
33
+ splitIncludeFlag,
34
+ } from './story-variables.js';
8
35
 
9
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
37
 
@@ -58,3 +85,13 @@ export function getMacroRegistry() {
58
85
  export function validateMarkup(passages, options) {
59
86
  return validateStoryMarkup(passages, metadata.values(), options);
60
87
  }
88
+
89
+ /**
90
+ * The passages the markup of `source` names, with where each is written:
91
+ * `[[…]]` links and the passage of `{goto}`, `{include}`, `{link}`,
92
+ * `{watch}` and `{dialog}`, as the macros registered here declare them.
93
+ * Malformed tags are skipped, so half-typed markup reads.
94
+ */
95
+ export function collectPassageReferences(source) {
96
+ return collectStoryPassageReferences(source, metadata.values());
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
package/src/code-check.ts CHANGED
@@ -32,8 +32,9 @@ import type { ParameterDef } from './registry';
32
32
  import {
33
33
  MacroArgumentError,
34
34
  parseMacroArgs,
35
+ passageTarget,
36
+ type PassageTarget,
35
37
  } from './components/macros/macro-args';
36
- import { readWholeQuoted } from './components/macros/arg-utils';
37
38
 
38
39
  /** While a pass runs: the code it parsed so far, by goal and source. */
39
40
  let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
@@ -80,6 +81,10 @@ export interface CodePiece {
80
81
  label: string;
81
82
  /** Whether it names a passage: a `passage` argument. */
82
83
  passage?: boolean;
84
+ /** Whether it is the code in a quoted string, as in `{watch}`. */
85
+ inString?: boolean;
86
+ /** The macro it is the argument of, for a `passage` argument. */
87
+ macro?: string;
83
88
  }
84
89
 
85
90
  /** A passage name written out in markup, at `offset` in it. */
@@ -89,6 +94,10 @@ export interface PassagePiece {
89
94
  offset: number;
90
95
  /** The markup it is in, for the error: `[[Go->Hall]]`. */
91
96
  label: string;
97
+ /** How much of the markup is the name, as written. */
98
+ length: number;
99
+ /** The macro it is the argument of; `link` for `[[…]]` links. */
100
+ macro: string;
92
101
  }
93
102
 
94
103
  /** Macro arguments that don't have their parameters' forms. */
@@ -113,14 +122,24 @@ export interface TextPiece {
113
122
  const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
114
123
 
115
124
  /** Code inside the quoted strings of a macro's arguments, by parameter. */
116
- const CODE_IN_STRINGS: Record<string, Record<string, JsGoal>> = {
117
- watch: { condition: 'expression', run: 'statements' },
118
- };
125
+ const CODE_IN_STRINGS: ReadonlyMap<string, Record<string, JsGoal>> = new Map([
126
+ ['watch', { condition: 'expression', run: 'statements' }],
127
+ ]);
119
128
 
120
129
  /** The `string` parameters of a macro that name a passage. */
121
- const PASSAGE_STRINGS: Record<string, readonly string[]> = {
122
- watch: ['goto', 'dialog'],
123
- };
130
+ const PASSAGE_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
131
+ ['watch', ['goto', 'dialog']],
132
+ ]);
133
+
134
+ /**
135
+ * The `text` and `string` parameters of a macro that the runtime keeps as
136
+ * written, not as markup: an option's value, the name of a watcher.
137
+ */
138
+ const LITERAL_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
139
+ ['option', ['value']],
140
+ ['watch', ['name']],
141
+ ['unwatch', ['name']],
142
+ ]);
124
143
 
125
144
  /** Block macros whose body is the name of a passage. */
126
145
  const PASSAGE_BODIES = new Set(['dialog']);
@@ -161,6 +180,8 @@ export function* codeAndText(
161
180
  kind: 'passage',
162
181
  name: token.target,
163
182
  offset: locate(src, token.target, token.start),
183
+ length: token.target.length,
184
+ macro: 'link',
164
185
  label: src.slice(token.start, token.end),
165
186
  };
166
187
  } else if (token.type === 'expression') {
@@ -226,6 +247,8 @@ export function* codeAndText(
226
247
  kind: 'passage',
227
248
  name: passage,
228
249
  offset: locate(src, passage, token.end),
250
+ length: passage.length,
251
+ macro: name,
229
252
  label: src.slice(token.start, token.end),
230
253
  };
231
254
  }
@@ -250,7 +273,7 @@ export function* codeAndText(
250
273
  }
251
274
 
252
275
  /** The code and text in the arguments `args` (at `offset`) of `macro`. */
253
- function* argPieces(
276
+ export function* argPieces(
254
277
  args: string,
255
278
  offset: number,
256
279
  params: readonly ParameterDef[],
@@ -265,8 +288,10 @@ function* argPieces(
265
288
  yield { kind: 'argument-error', message: error.message, offset, label };
266
289
  return;
267
290
  }
268
- const inStrings = CODE_IN_STRINGS[macro.toLowerCase()] ?? {};
269
- const passageStrings = PASSAGE_STRINGS[macro.toLowerCase()] ?? [];
291
+ const key = macro.toLowerCase();
292
+ const inStrings = CODE_IN_STRINGS.get(key) ?? {};
293
+ const passageStrings = PASSAGE_STRINGS.get(key) ?? [];
294
+ const literalStrings = LITERAL_STRINGS.get(key) ?? [];
270
295
  let cursor = 0;
271
296
  /** The pieces of the parameters `list`, with their values in `from`. */
272
297
  function* visit(
@@ -294,20 +319,36 @@ function* argPieces(
294
319
  goal,
295
320
  label,
296
321
  };
297
- if (param.type === 'passage') piece.passage = true;
322
+ if (param.type === 'passage') {
323
+ piece.passage = true;
324
+ piece.macro = macro.toLowerCase();
325
+ }
326
+ if (!codeGoal(param)) piece.inString = true;
298
327
  yield piece;
299
328
  }
300
329
  // A passage written out: a quoted `passage` argument, or a string
301
- const name =
330
+ const written = param.type === 'passage' ? value.trim() : value;
331
+ const target =
302
332
  param.type === 'passage'
303
- ? readWholeQuoted(value.trim())
333
+ ? passageTarget(value)
304
334
  : passageStrings.includes(param.name)
305
- ? value
335
+ ? ({ kind: 'name', name: value } as const)
306
336
  : null;
307
- if (name !== null) {
308
- yield { kind: 'passage', name, offset: offset + at, label };
337
+ if (target?.kind === 'name') {
338
+ yield {
339
+ kind: 'passage',
340
+ name: target.name,
341
+ offset: offset + at + value.indexOf(written),
342
+ length: written.length,
343
+ macro: macro.toLowerCase(),
344
+ label,
345
+ };
309
346
  }
310
- if (!goal && (param.type === 'text' || param.type === 'string')) {
347
+ if (
348
+ !goal &&
349
+ (param.type === 'text' || param.type === 'string') &&
350
+ !literalStrings.includes(param.name)
351
+ ) {
311
352
  yield {
312
353
  kind: 'text',
313
354
  text: value,
@@ -330,3 +371,52 @@ function locate(text: string, part: string, from: number): number {
330
371
  const anywhere = text.indexOf(part);
331
372
  return anywhere >= 0 ? anywhere : from;
332
373
  }
374
+
375
+ /** A passage a piece of markup names, and where. */
376
+ export interface PassageReference {
377
+ /** The macro it is the argument of; `link` for `[[…]]` links. */
378
+ macro: string;
379
+ /** The name written out, or the expression whose value is the name. */
380
+ target: PassageTarget;
381
+ /** Where the name is written, as it is written (quotes included). */
382
+ start: number;
383
+ end: number;
384
+ }
385
+
386
+ /**
387
+ * The passages the markup `src` (with the `tokens` it tokenizes to) names:
388
+ * `[[…]]` links, the passage argument of `{goto}`, `{include}` and `{link}`
389
+ * (and of macros that declare one), the `goto` and `dialog` actions of
390
+ * `{watch}` and the body of `{dialog}`, in source order. A name written out
391
+ * is a literal; the others are expressions, which name a passage when they
392
+ * run.
393
+ */
394
+ export function collectPassageReferences(
395
+ src: string,
396
+ tokens: readonly Token[],
397
+ parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
398
+ ): PassageReference[] {
399
+ const refs: PassageReference[] = [];
400
+ for (const piece of codeAndText(src, tokens, parametersOf)) {
401
+ if (piece.kind === 'passage') {
402
+ refs.push({
403
+ macro: piece.macro,
404
+ target: { kind: 'name', name: piece.name },
405
+ start: piece.offset,
406
+ end: piece.offset + piece.length,
407
+ });
408
+ } else if (piece.kind === 'code' && piece.passage) {
409
+ refs.push({
410
+ macro: piece.macro!,
411
+ target: passageTarget(piece.code),
412
+ start: piece.offset,
413
+ end: piece.offset + piece.code.length,
414
+ });
415
+ }
416
+ }
417
+ return refs.filter(
418
+ (ref, i) =>
419
+ refs.findIndex((r) => r.start === ref.start && r.macro === ref.macro) ===
420
+ i,
421
+ );
422
+ }
@@ -4,18 +4,14 @@ import { parseMarkup } from '../../markup/parse';
4
4
  import { NobrContext } from '../../markup/render';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { evaluatePassageName } from './macro-args';
7
+ import { INCLUDE_PARAMETERS } from './include-args';
7
8
  import { MacroError } from './MacroError';
8
9
 
9
10
  defineMacro({
10
11
  name: 'include',
11
12
  interpolate: true,
12
13
  merged: true,
13
- // A standalone `inline` flag only counts outside quotes and brackets, so
14
- // passage names and expressions containing the word stay intact (#201).
15
- parameters: [
16
- { name: 'inline', type: 'flag' },
17
- { name: 'passage', type: 'passage', required: true },
18
- ],
14
+ parameters: INCLUDE_PARAMETERS,
19
15
  render(_props, ctx) {
20
16
  const { storyData } = useStoryFields('storyData');
21
17
 
@@ -34,6 +34,8 @@ defineMacro({
34
34
  const runBody = useDetachedBody();
35
35
 
36
36
  const perform = () => {
37
+ // A link that failed to resolve is an error, not a control
38
+ if (failure) return;
37
39
  runBody(children);
38
40
  if (passage) {
39
41
  useStoryStore.getState().navigate(passage);
@@ -51,6 +53,7 @@ defineMacro({
51
53
  authorId: ctx.id,
52
54
  label: display,
53
55
  target: passage ?? undefined,
56
+ disabled: failure ? true : undefined,
54
57
  perform,
55
58
  });
56
59
 
@@ -69,6 +69,43 @@ export function readWholeQuoted(src: string): string | null {
69
69
  return quoted && quoted.end === src.length ? quoted.value : null;
70
70
  }
71
71
 
72
+ const JS_SIMPLE_ESCAPES: Record<string, string> = {
73
+ n: '\n',
74
+ t: '\t',
75
+ r: '\r',
76
+ b: '\b',
77
+ f: '\f',
78
+ v: '\v',
79
+ '0': '\0',
80
+ };
81
+
82
+ /** Undo every escape of a JavaScript string literal's body. */
83
+ function decodeJsEscapes(body: string): string {
84
+ return body.replace(
85
+ /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\n\r\u2028\u2029])|([\s\S]))/g,
86
+ (_, braced, unicode, hex, lineBreak, other: string | undefined) => {
87
+ if (lineBreak !== undefined) return '';
88
+ const code = braced ?? unicode ?? hex;
89
+ if (code !== undefined) {
90
+ const point = parseInt(code, 16);
91
+ return point <= 0x10ffff ? String.fromCodePoint(point) : '';
92
+ }
93
+ return JS_SIMPLE_ESCAPES[other!] ?? other!;
94
+ },
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The value of `src` when it is exactly one `"…"` or `'…'` string, read as
100
+ * the JavaScript literal it is (`"\u0048all"` is `Hall`), otherwise `null`.
101
+ * `readWholeQuoted` keeps other escapes as written, for labels and code.
102
+ */
103
+ export function readWholeJsString(src: string): string | null {
104
+ const quoted = readQuoted(src, 0);
105
+ if (!quoted || quoted.end !== src.length) return null;
106
+ return decodeJsEscapes(src.slice(1, -1));
107
+ }
108
+
72
109
  /**
73
110
  * Strip an optional quote from each end of an unquoted-or-loosely-quoted
74
111
  * argument (`"Red`, `Red'`), leaving at least one character. This is the
@@ -0,0 +1,23 @@
1
+ import { parseMacroArgs } from './macro-args';
2
+
3
+ /**
4
+ * The parameters of `{include}`. A standalone `inline` flag only counts
5
+ * outside quotes and brackets, so passage names and expressions containing
6
+ * the word stay intact (#201).
7
+ */
8
+ export const INCLUDE_PARAMETERS = [
9
+ { name: 'inline', type: 'flag' },
10
+ { name: 'passage', type: 'passage', required: true },
11
+ ] as const;
12
+
13
+ /**
14
+ * The arguments of `{include}`: whether it has the `inline` flag, as its
15
+ * first or last word, and the passage expression that is left.
16
+ */
17
+ export function splitIncludeFlag(rawArgs: string): {
18
+ inline: boolean;
19
+ passage: string | undefined;
20
+ } {
21
+ const { inline, passage } = parseMacroArgs(rawArgs, INCLUDE_PARAMETERS);
22
+ return { inline, passage };
23
+ }
@@ -22,6 +22,7 @@ import {
22
22
  endsWithOperator,
23
23
  isWhitespace,
24
24
  readQuoted,
25
+ readWholeJsString,
25
26
  readWholeQuoted,
26
27
  stripLooseQuotes,
27
28
  topLevelIndices,
@@ -56,7 +57,9 @@ export function readText(src: string): string {
56
57
  */
57
58
  export class MacroArgumentError extends Error {
58
59
  constructor(param: ParameterDef, src: string) {
59
- super(`The ${param.name} must be a quoted string ("…" or '…'), not ${src}`);
60
+ super(
61
+ `The ${param.name} must be a quoted string ("…" or '…'), ${src ? `not ${src}` : 'but it has no value'}`,
62
+ );
60
63
  this.name = 'MacroArgumentError';
61
64
  }
62
65
  }
@@ -90,6 +93,7 @@ function unset(param: ParameterDef): unknown {
90
93
 
91
94
  const WORD_RE = /\w+/y;
92
95
  const DIGITS_RE = /\d+/y;
96
+ const WORD_OR_PUNCT_RE = /\S+/y;
93
97
 
94
98
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
95
99
  function matchAt(re: RegExp, src: string, pos: number): string | null {
@@ -100,7 +104,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
100
104
  /**
101
105
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
102
106
  * quoted string or digit run after it as its value; keywords that aren't
103
- * declared, and their values, are skipped.
107
+ * declared, and their values, are skipped. A keyword of a `string` parameter
108
+ * must be followed by a quoted string.
104
109
  */
105
110
  function readOptions(
106
111
  params: readonly ParameterDef[],
@@ -119,10 +124,11 @@ function readOptions(
119
124
 
120
125
  // A value is a quoted string or a digit run, after whitespace.
121
126
  let val: string | undefined;
127
+ let quoted: ReturnType<typeof readQuoted> = null;
122
128
  let j = i;
123
129
  while (j < src.length && isWhitespace(src[j]!)) j++;
124
130
  if (j > i) {
125
- const quoted = readQuoted(src, j);
131
+ quoted = readQuoted(src, j);
126
132
  const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
127
133
  if (quoted) {
128
134
  val = quoted.value;
@@ -134,6 +140,13 @@ function readOptions(
134
140
  }
135
141
 
136
142
  const param = params.find((p) => p.name === key);
143
+ // A keyword that takes a string was given anything else, or nothing
144
+ if (param?.type === 'string' && (!quoted || val === undefined)) {
145
+ throw new MacroArgumentError(
146
+ param,
147
+ matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
148
+ );
149
+ }
137
150
  if (param?.type === 'flag') options[key] = true;
138
151
  else if (param?.type === 'number') options[key] = Number(val ?? 0);
139
152
  else if (param) options[key] = val;
@@ -288,6 +301,25 @@ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
288
301
  return args as MacroArgs<P>;
289
302
  }
290
303
 
304
+ /** What a `passage` argument is: a quoted name, or an expression. */
305
+ export type PassageTarget =
306
+ | { kind: 'name'; name: string }
307
+ | { kind: 'expression'; expression: string };
308
+
309
+ /**
310
+ * Read a `passage` argument as written (`{goto "Hall"}`, `{goto $room}`): a
311
+ * quoted string is the name its JavaScript literal has (`"\u0048all"` is
312
+ * `Hall`), anything else an expression, whose value is the name when it
313
+ * runs (see evaluatePassageName).
314
+ */
315
+ export function passageTarget(arg: string): PassageTarget {
316
+ const expression = arg.trim();
317
+ const name = readWholeJsString(expression);
318
+ return name === null
319
+ ? { kind: 'expression', expression }
320
+ : { kind: 'name', name };
321
+ }
322
+
291
323
  /**
292
324
  * The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
293
325
  * the value of its expression. Throws what evaluating it throws, and if no
@@ -20,9 +20,15 @@ const OPTION_PARAMETERS = [
20
20
  ] as const;
21
21
 
22
22
  /** The parameters of the built-in sub-macros that take arguments. */
23
- export const SUB_MACRO_PARAMETERS: Record<string, readonly ParameterDef[]> = {
24
- option: OPTION_PARAMETERS,
25
- };
23
+ const SUB_MACRO_PARAMETERS: ReadonlyMap<string, readonly ParameterDef[]> =
24
+ new Map([['option', OPTION_PARAMETERS]]);
25
+
26
+ /** The parameters of the built-in sub-macro `name`, if it takes any. */
27
+ export function subMacroParameters(
28
+ name: string,
29
+ ): readonly ParameterDef[] | undefined {
30
+ return SUB_MACRO_PARAMETERS.get(name.toLowerCase());
31
+ }
26
32
 
27
33
  /**
28
34
  * Walk AST children to find {option} macro nodes, returning their values.
package/src/expression.ts CHANGED
@@ -2,13 +2,12 @@ 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 { parseCode, type JsGoal, type Sigil } from './js-lexer';
5
+ import { clearTransformCache, transform } from './transform';
6
6
  import {
7
7
  EMPTY_NAMESPACE,
8
8
  asNamespace,
9
9
  countOf,
10
10
  isNamespace,
11
- variableNameError,
12
11
  type Counts,
13
12
  type Namespace,
14
13
  } from './utils/namespace';
@@ -36,60 +35,11 @@ type CompiledExpression = (
36
35
  transient: Record<string, unknown>,
37
36
  ) => unknown;
38
37
 
38
+ export { transform };
39
+
39
40
  const FN_CACHE_MAX = 500;
40
41
  const fnCache = new Map<string, CompiledExpression>();
41
42
 
42
- const NAMESPACES: Record<Sigil, string> = {
43
- $: 'variables',
44
- _: 'temporary',
45
- '@': 'locals',
46
- '%': 'transient',
47
- };
48
-
49
- /** Ends with an identifier character. */
50
- const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
51
-
52
- /**
53
- * Transform expression: $var → variables["var"], _var → temporary["var"],
54
- * @var → locals["var"], %var → transient["var"].
55
- * The lexer (`lexJs`) finds the references: only in code (string, template
56
- * and regex literal text and comments are left untouched), only where an
57
- * identifier starts (not in `a$b`, nor as the property name in `obj._x`),
58
- * and `%var` only where an operand is expected, since `%` is also the
59
- * modulo operator. `goal` tells whether `expr` is an expression or a list of
60
- * statements, as that decides whether a leading `{` opens an object literal
61
- * or a block. A reference to a variable named `__proto__` throws a
62
- * SyntaxError: no namespace can hold one (see utils/namespace.ts).
63
- * Exported for tests.
64
- */
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);
70
- let result = '';
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);
87
- return result;
88
- }
89
-
90
- /** Transformed code by goal and source. */
91
- const transformCache = new Map<string, string>();
92
-
93
43
  const preamble =
94
44
  'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
95
45
 
@@ -269,7 +219,7 @@ export function execute(code: string, ...namespaces: Namespaces): void {
269
219
  /** Clear the compiled expression cache. Useful for testing and HMR. */
270
220
  export function clearExpressionCache(): void {
271
221
  fnCache.clear();
272
- transformCache.clear();
222
+ clearTransformCache();
273
223
  cachedFns = null;
274
224
  cachedVisitCounts = null;
275
225
  cachedRenderCounts = null;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What the grammar (spindle.peggy) imports from the rest of Spindle, so that
3
+ * it holds no second copy: the generated parser imports this module's
4
+ * default export as `shared` (see scripts/peggy.ts).
5
+ */
6
+ import { SIGIL_SCOPES, withSelectors } from './tokens';
7
+
8
+ export default { SIGIL_SCOPES, withSelectors };
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { parse as pegParse } from './spindle.peggy';
7
7
  import { isBlockMacro, type ASTNode } from './ast';
8
- import type { Token } from './tokens';
8
+ import type { Selectors, Token } from './tokens';
9
9
  import { isCodeAttribute } from './code-attributes';
10
10
  import { defaultCodeEnd, type CodeEnd } from './code-end';
11
11
 
@@ -74,7 +74,7 @@ export function lineColumn(
74
74
 
75
75
  function run(
76
76
  source: string,
77
- startRule: 'Markup' | 'Tokens',
77
+ startRule: 'Markup' | 'Tokens' | 'SelectorsPrefix',
78
78
  options: ParseMarkupOptions,
79
79
  ): unknown {
80
80
  const hooks = options.hooks
@@ -116,3 +116,90 @@ export function tokenizeMarkup(
116
116
  ): Token[] {
117
117
  return run(source, 'Tokens', options) as Token[];
118
118
  }
119
+
120
+ /**
121
+ * The `.class#id` selectors that start `source` at `at`, with the index just
122
+ * past them and the one space that may follow (`end`; `at` if there are
123
+ * none). A selector name may hold `{$name}` interpolations, as in markup.
124
+ */
125
+ export function parseSelectors(
126
+ source: string,
127
+ at = 0,
128
+ ): Selectors & { end: number } {
129
+ const found = run(source.slice(at), 'SelectorsPrefix', {}) as Selectors & {
130
+ end: number;
131
+ };
132
+ return { ...found, end: at + found.end };
133
+ }
134
+
135
+ /** The tokens of markup that may be malformed, and its errors. */
136
+ export interface TolerantTokens {
137
+ tokens: Token[];
138
+ /** One for each malformed tag, in source order, with offsets in the source. */
139
+ errors: MarkupError[];
140
+ }
141
+
142
+ /**
143
+ * `tokenizeMarkup` for half-typed markup: the tokens it can read, and every
144
+ * error, instead of throwing at the first. At a malformed tag, the tokens
145
+ * before it are kept, its first character is read as text and tokenizing
146
+ * resumes after it, so what follows is read as if the tag had not been
147
+ * started. Tokens and errors have offsets in `source`; for well-formed
148
+ * markup the tokens are those of `tokenizeMarkup` and there are no errors.
149
+ */
150
+ export function tokenizeMarkupTolerant(
151
+ source: string,
152
+ options: ParseMarkupOptions = {},
153
+ ): TolerantTokens {
154
+ const tokens: Token[] = [];
155
+ const errors: MarkupError[] = [];
156
+ /** `text` shifted by `base`, tokenized: the tokens or the error. */
157
+ const attempt = (text: string, base: number): Token[] | MarkupError => {
158
+ try {
159
+ return tokenizeMarkup(text, options).map((token) => shift(token, base));
160
+ } catch (error) {
161
+ if (!(error instanceof MarkupError)) throw error;
162
+ return error;
163
+ }
164
+ };
165
+ let base = 0;
166
+ while (base <= source.length) {
167
+ const rest = source.slice(base);
168
+ const result = attempt(rest, base);
169
+ if (Array.isArray(result)) {
170
+ tokens.push(...result);
171
+ break;
172
+ }
173
+ const at = base + result.offset;
174
+ const { line, column } = lineColumn(source, at);
175
+ errors.push(new MarkupError(result.reason, at, line, column));
176
+ // The longest prefix that tokenizes: the error may be inside a tag
177
+ // (an attribute value) of which the prefix is itself malformed
178
+ let cut = result.offset;
179
+ let prefix = attempt(rest.slice(0, cut), base);
180
+ while (!Array.isArray(prefix)) {
181
+ cut = prefix.offset;
182
+ prefix = attempt(rest.slice(0, cut), base);
183
+ }
184
+ tokens.push(...prefix);
185
+ // The character that started the damage is text
186
+ if (cut < rest.length) {
187
+ const start = base + cut;
188
+ tokens.push({
189
+ type: 'text',
190
+ value: rest[cut]!,
191
+ start,
192
+ end: start + 1,
193
+ });
194
+ }
195
+ base += cut + 1;
196
+ }
197
+ return { tokens, errors };
198
+ }
199
+
200
+ /** `token` with its offsets moved `by` on. */
201
+ function shift(token: Token, by: number): Token {
202
+ return by === 0
203
+ ? token
204
+ : { ...token, start: token.start + by, end: token.end + by };
205
+ }