@rohal12/spindle 0.57.0 → 0.59.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.
@@ -18,6 +18,7 @@ import { parseDelay } from '../../utils/parse-delay';
18
18
  import type { MacroArgs, ParameterDef } from '../../registry';
19
19
  import type { StoryState } from '../../store';
20
20
  import { noPassageError } from '../../runtime-errors';
21
+ import { stringLiteralValue } from '../../js-lexer';
21
22
  import {
22
23
  endsWithOperator,
23
24
  isWhitespace,
@@ -29,6 +30,20 @@ import {
29
30
 
30
31
  type Span = [start: number, end: number];
31
32
 
33
+ /**
34
+ * Where parseMacroArgs read each argument it set from text (not flags or
35
+ * separators), by parameter, options too: the start and end of its text in
36
+ * the raw arguments, quotes included.
37
+ */
38
+ export type ArgSpans = Map<ParameterDef, Span>;
39
+
40
+ /** Where a group's text is in the raw arguments, and the spans read so far. */
41
+ interface Place {
42
+ /** The index in the raw arguments of the text being read. */
43
+ at: number;
44
+ spans: ArgSpans | undefined;
45
+ }
46
+
32
47
  /** The terms of `src`: the runs of text between its depth-0 whitespace. */
33
48
  function terms(src: string): Span[] {
34
49
  const spans: Span[] = [];
@@ -56,12 +71,16 @@ export function readText(src: string): string {
56
71
  */
57
72
  export class MacroArgumentError extends Error {
58
73
  constructor(param: ParameterDef, src: string) {
59
- super(`The ${param.name} must be a quoted string ("…" or '…'), not ${src}`);
74
+ super(
75
+ `The ${param.name} must be a quoted string ("…" or '…'), ${src ? `not ${src}` : 'but it has no value'}`,
76
+ );
60
77
  this.name = 'MacroArgumentError';
61
78
  }
62
79
  }
63
80
 
64
- function readValue(param: ParameterDef, src: string): unknown {
81
+ /** Read `src`, at `place`, as `param` says. */
82
+ function readValue(param: ParameterDef, src: string, place: Place): unknown {
83
+ place.spans?.set(param, [place.at, place.at + src.length]);
65
84
  switch (param.type) {
66
85
  case 'string': {
67
86
  const value = readWholeQuoted(src);
@@ -77,7 +96,7 @@ function readValue(param: ParameterDef, src: string): unknown {
77
96
  case 'number':
78
97
  return Number(src);
79
98
  case 'options':
80
- return readOptions(param.parameters ?? [], src);
99
+ return readOptions(param.parameters ?? [], src, place);
81
100
  default:
82
101
  return src;
83
102
  }
@@ -90,6 +109,7 @@ function unset(param: ParameterDef): unknown {
90
109
 
91
110
  const WORD_RE = /\w+/y;
92
111
  const DIGITS_RE = /\d+/y;
112
+ const WORD_OR_PUNCT_RE = /\S+/y;
93
113
 
94
114
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
95
115
  function matchAt(re: RegExp, src: string, pos: number): string | null {
@@ -99,12 +119,15 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
99
119
 
100
120
  /**
101
121
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
102
- * quoted string or digit run after it as its value; keywords that aren't
103
- * declared, and their values, are skipped.
122
+ * quoted string (with or without whitespace before it) or digit run after
123
+ * it as its value; keywords that aren't declared, and their values, are
124
+ * skipped. A keyword of a `string` parameter must be followed by a quoted
125
+ * string.
104
126
  */
105
127
  function readOptions(
106
128
  params: readonly ParameterDef[],
107
129
  src: string,
130
+ { at, spans }: Place,
108
131
  ): Record<string, unknown> {
109
132
  const options: Record<string, unknown> = {};
110
133
  let i = 0;
@@ -117,23 +140,33 @@ function readOptions(
117
140
  }
118
141
  i += key.length;
119
142
 
120
- // A value is a quoted string or a digit run, after whitespace.
143
+ // A value is a quoted string, which may follow the keyword directly
144
+ // (`goto"Hall"`: a quote can't be part of one), or a digit run after
145
+ // whitespace.
121
146
  let val: string | undefined;
122
147
  let j = i;
123
148
  while (j < src.length && isWhitespace(src[j]!)) j++;
124
- if (j > i) {
125
- const quoted = readQuoted(src, j);
126
- const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
127
- if (quoted) {
128
- val = quoted.value;
129
- i = quoted.end;
130
- } else if (digits) {
131
- val = digits;
132
- i = j + digits.length;
133
- }
149
+ const quoted = readQuoted(src, j);
150
+ const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
151
+ if (quoted) {
152
+ val = quoted.value;
153
+ i = quoted.end;
154
+ } else if (digits) {
155
+ val = digits;
156
+ i = j + digits.length;
134
157
  }
135
158
 
136
159
  const param = params.find((p) => p.name === key);
160
+ if (param && param.type !== 'flag' && val !== undefined) {
161
+ spans?.set(param, [at + j, at + i]);
162
+ }
163
+ // A keyword that takes a string was given anything else, or nothing
164
+ if (param?.type === 'string' && !quoted) {
165
+ throw new MacroArgumentError(
166
+ param,
167
+ matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
168
+ );
169
+ }
137
170
  if (param?.type === 'flag') options[key] = true;
138
171
  else if (param?.type === 'number') options[key] = Number(val ?? 0);
139
172
  else if (param) options[key] = val;
@@ -145,21 +178,21 @@ function readOptions(
145
178
  const LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
146
179
 
147
180
  /**
148
- * The rest of `src` without the flag `word` as its last or first term, or
149
- * `null` if it has no such flag. Next to a binary operator the word is an
150
- * operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
151
- * is not one.
181
+ * Where the rest of `src` is without the flag `word` as its last or first
182
+ * term, or `null` if it has no such flag. Next to a binary operator the
183
+ * word is an operand (`"a" + inline`), not a flag; the closing `/` of a
184
+ * regex literal is not one.
152
185
  */
153
- function takeFlag(src: string, word: string): string | null {
186
+ function takeFlag(src: string, word: string): Span | null {
154
187
  const spans = terms(src);
155
188
  if (spans.length < 2) return null;
156
189
  if (slice(src, spans[spans.length - 1]!) === word) {
157
- const rest = src.slice(0, spans[spans.length - 2]![1]);
158
- if (!endsWithOperator(rest)) return rest;
190
+ const rest: Span = [0, spans[spans.length - 2]![1]];
191
+ if (!endsWithOperator(slice(src, rest))) return rest;
159
192
  }
160
193
  if (slice(src, spans[0]!) === word) {
161
- const rest = src.slice(spans[1]![0]);
162
- if (!LEADING_OPERATOR_RE.test(rest)) return rest;
194
+ const rest: Span = [spans[1]![0], src.length];
195
+ if (!LEADING_OPERATOR_RE.test(slice(src, rest))) return rest;
163
196
  }
164
197
  return null;
165
198
  }
@@ -198,17 +231,27 @@ const takesRest = (param: ParameterDef) =>
198
231
  param.type === 'passage' ||
199
232
  param.type === 'text';
200
233
 
201
- /** Read the parameters of one group from `src` (`null`: no text) into `args`. */
234
+ /** The text of `src` without the whitespace around it, and where it is. */
235
+ function trimmed(src: string, at: number): [text: string, at: number] {
236
+ const text = src.trim();
237
+ return [text, text ? at + src.indexOf(text) : at];
238
+ }
239
+
240
+ /**
241
+ * Read the parameters of one group from `src` (`null`: no text), which is
242
+ * at `at` in the raw arguments, into `args`.
243
+ */
202
244
  function readGroup(
203
245
  params: readonly ParameterDef[],
204
246
  src: string | null,
205
247
  args: Record<string, unknown>,
248
+ { at: srcAt, spans }: Place,
206
249
  ): void {
207
- let text = src?.trim() ?? '';
250
+ let [text, at] = trimmed(src ?? '', srcAt);
208
251
  for (const flag of params.filter((p) => p.type === 'flag')) {
209
252
  const rest = src === null ? null : takeFlag(text, flag.name);
210
253
  args[flag.name] = rest !== null;
211
- text = rest ?? text;
254
+ if (rest) [text, at] = trimmed(slice(text, rest), at + rest[0]);
212
255
  }
213
256
  const positional = params.filter((p) => p.type !== 'flag');
214
257
  if (positional.length === 0) return;
@@ -218,27 +261,29 @@ function readGroup(
218
261
  (p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest),
219
262
  );
220
263
  const restIndex = lastText < 0 ? positional.length - 1 : lastText;
221
- const read = (param: ParameterDef, value: string) =>
222
- value === '' ? unset(param) : readValue(param, value);
264
+ /** Read `param` from the text from `start` to `end`. */
265
+ const read = (param: ParameterDef, [start, end]: Span) =>
266
+ start === end
267
+ ? unset(param)
268
+ : readValue(param, text.slice(start, end), { at: at + start, spans });
223
269
 
224
270
  // One parameter reads the whole text: no need to split it.
225
271
  if (positional.length === 1) {
226
- args[positional[0]!.name] = read(positional[0]!, text);
272
+ args[positional[0]!.name] = read(positional[0]!, [0, text.length]);
227
273
  return;
228
274
  }
229
275
 
230
- const spans = terms(text);
276
+ const parts = terms(text);
231
277
  let from = 0;
232
- let to = spans.length;
278
+ let to = parts.length;
233
279
  for (const param of positional.slice(0, restIndex)) {
234
- args[param.name] =
235
- from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
280
+ args[param.name] = from < to ? read(param, parts[from++]!) : unset(param);
236
281
  }
237
282
  // Optional ones at the end: a term that doesn't have their form (a
238
283
  // `string` that isn't quoted) belongs to the rest, as in {meter $hp 100}
239
- const readTrailing = (param: ParameterDef, value: string) => {
284
+ const readTrailing = (param: ParameterDef, span: Span) => {
240
285
  try {
241
- return read(param, value);
286
+ return read(param, span);
242
287
  } catch (error) {
243
288
  if (error instanceof MacroArgumentError) return undefined;
244
289
  throw error;
@@ -246,17 +291,17 @@ function readGroup(
246
291
  };
247
292
  for (const param of positional.slice(restIndex + 1).reverse()) {
248
293
  const value =
249
- from < to && !endsWithOperator(text.slice(0, spans[to - 1]![0]))
250
- ? readTrailing(param, slice(text, spans[to - 1]!))
294
+ from < to && !endsWithOperator(text.slice(0, parts[to - 1]![0]))
295
+ ? readTrailing(param, parts[to - 1]!)
251
296
  : undefined;
252
297
  args[param.name] = value ?? unset(param);
253
298
  if (value !== undefined) to--;
299
+ // A term it didn't take
300
+ else spans?.delete(param);
254
301
  }
255
302
  const rest = positional[restIndex]!;
256
303
  args[rest.name] =
257
- from < to
258
- ? read(rest, text.slice(spans[from]![0], spans[to - 1]![1]))
259
- : unset(rest);
304
+ from < to ? read(rest, [parts[from]![0], parts[to - 1]![1]]) : unset(rest);
260
305
  }
261
306
 
262
307
  /** The duration a timing macro ({repeat}, {type}, {timed}/{next}) waits. */
@@ -266,28 +311,56 @@ export const DELAY_PARAMETER = {
266
311
  required: true,
267
312
  } as const;
268
313
 
269
- /** Read `rawArgs` into the declared `parameters` (see above). */
314
+ /**
315
+ * Read `rawArgs` into the declared `parameters` (see above). With `spans`,
316
+ * note there where each argument set was read.
317
+ */
270
318
  export function parseMacroArgs<const P extends readonly ParameterDef[]>(
271
319
  rawArgs: string,
272
320
  parameters: P,
321
+ spans?: ArgSpans,
273
322
  ): MacroArgs<P> {
274
323
  const args: Record<string, unknown> = {};
275
324
  // The text after the last separator read, null once one is missing
276
- let rest: string | null = rawArgs.trim();
325
+ let [rest, restAt]: [string | null, number] = trimmed(rawArgs, 0);
277
326
  let groupStart = 0;
278
327
  parameters.forEach((separator, i) => {
279
328
  if (separator.type !== 'separator') return;
280
329
  const at = rest === null ? null : findSeparator(rest, separator.name);
281
330
  args[separator.name] = at !== null;
282
331
  const before = at ? rest!.slice(0, at[0]) : rest;
283
- readGroup(parameters.slice(groupStart, i), before, args);
332
+ readGroup(parameters.slice(groupStart, i), before, args, {
333
+ at: restAt,
334
+ spans,
335
+ });
284
336
  rest = at ? rest!.slice(at[1]) : null;
337
+ if (at) restAt += at[1];
285
338
  groupStart = i + 1;
286
339
  });
287
- readGroup(parameters.slice(groupStart), rest, args);
340
+ readGroup(parameters.slice(groupStart), rest, args, { at: restAt, spans });
288
341
  return args as MacroArgs<P>;
289
342
  }
290
343
 
344
+ /** What a `passage` argument is: a quoted name, or an expression. */
345
+ export type PassageTarget =
346
+ | { kind: 'name'; name: string }
347
+ | { kind: 'expression'; expression: string };
348
+
349
+ /**
350
+ * Read a `passage` argument as written (`{goto "Hall"}`, `{goto $room}`): a
351
+ * string literal is the name JavaScript reads from it (`"\u0048all"` is
352
+ * `Hall`), anything else (a literal that is not well-formed too) an
353
+ * expression, whose value is the name when it
354
+ * runs (see evaluatePassageName).
355
+ */
356
+ export function passageTarget(arg: string): PassageTarget {
357
+ const expression = arg.trim();
358
+ const name = stringLiteralValue(expression);
359
+ return name === null
360
+ ? { kind: 'expression', expression }
361
+ : { kind: 'name', name };
362
+ }
363
+
291
364
  /**
292
365
  * The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
293
366
  * the value of its expression. Throws what evaluating it throws, and if no
@@ -16,13 +16,19 @@ export function parseVarArgs(rawArgs: string): {
16
16
 
17
17
  /** The parameters of an `{option}` sub-macro: its value. */
18
18
  const OPTION_PARAMETERS = [
19
- { name: 'value', type: 'string', required: true },
19
+ { name: 'value', type: 'string', holds: 'text', required: true },
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;
package/src/index.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  extractDefaults,
18
18
  } from './story-variables';
19
19
  import { getMacro, getMacroRegistry, isSubMacro } from './registry';
20
+ import { parameterLookup } from './code-check';
20
21
  import { getWidget } from './widgets/widget-registry';
21
22
  import {
22
23
  formatDiagnostic,
@@ -71,14 +72,11 @@ function markupErrors(
71
72
  only: (passage: MarkupPassage) => boolean,
72
73
  ): string[] {
73
74
  const macros = getMacroRegistry();
74
- const parameters = new Map(
75
- macros.map((m) => [m.name.toLowerCase(), m.parameters]),
76
- );
77
75
  return validateMarkup(storyData.passages.values(), {
78
76
  isKnownMacro: (name) =>
79
77
  !!getMacro(name) || isSubMacro(name) || !!getWidget(name),
80
78
  macroNames: macros.map((m) => m.name),
81
- parametersOf: (name) => parameters.get(name),
79
+ parametersOf: parameterLookup(macros),
82
80
  only,
83
81
  }).map(formatDiagnostic);
84
82
  }
package/src/js-lexer.ts CHANGED
@@ -1122,6 +1122,25 @@ function regexEnd(src: string, start: number): number {
1122
1122
  return -1;
1123
1123
  }
1124
1124
 
1125
+ /**
1126
+ * The value of `src` when it is exactly one `"…"` or `'…'` string literal,
1127
+ * as JavaScript reads it (`"\u0048all"` is `Hall`, `"\1"` is U+0001 in
1128
+ * the non-strict code passages run), otherwise `null`: other code, or a
1129
+ * literal that is not well-formed (`"\u{110000}"`).
1130
+ */
1131
+ export function stringLiteralValue(src: string): string | null {
1132
+ if (src[0] !== '"' && src[0] !== "'") return null;
1133
+ try {
1134
+ const p = tokenizerAt(src, 0, 'expression');
1135
+ p.nextToken();
1136
+ return p.type === tt.string && p.end === src.length
1137
+ ? (p.value as string)
1138
+ : null;
1139
+ } catch {
1140
+ return null;
1141
+ }
1142
+ }
1143
+
1125
1144
  /**
1126
1145
  * Scan the `"…"` or `'…'` string literal opening at `start`. `end` is the
1127
1146
  * index just past its closing quote, or `src.length` when it is unterminated
@@ -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,108 @@ 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
+ // The code of a tag ends at a }: with none after it, the tag is unclosed
157
+ // without reading its code to the end of the source, which every
158
+ // unclosed tag would do again (#265).
159
+ const lastBrace = source.lastIndexOf('}');
160
+ const { closeBrace } = { ...defaultHooks, ...options.hooks };
161
+ /** `text` shifted by `base`, tokenized: the tokens or the error. */
162
+ const attempt = (text: string, base: number): Token[] | MarkupError => {
163
+ const hooks: Partial<MarkupHooks> = {
164
+ ...options.hooks,
165
+ closeBrace: (input, codeStart, lenientStart) =>
166
+ base + Math.min(codeStart, lenientStart) > lastBrace
167
+ ? -1
168
+ : closeBrace(input, codeStart, lenientStart),
169
+ };
170
+ try {
171
+ return tokenizeMarkup(text, { ...options, hooks }).map((token) =>
172
+ shift(token, base),
173
+ );
174
+ } catch (error) {
175
+ if (!(error instanceof MarkupError)) throw error;
176
+ return error;
177
+ }
178
+ };
179
+ let base = 0;
180
+ while (base <= source.length) {
181
+ const rest = source.slice(base);
182
+ const result = attempt(rest, base);
183
+ if (Array.isArray(result)) {
184
+ tokens.push(...result);
185
+ break;
186
+ }
187
+ const at = base + result.offset;
188
+ const { line, column } = lineColumn(source, at);
189
+ errors.push(new MarkupError(result.reason, at, line, column));
190
+ // The longest prefix that tokenizes: the error may be inside a tag
191
+ // (an attribute value) of which the prefix is itself malformed
192
+ let cut = result.offset;
193
+ let prefix = attempt(rest.slice(0, cut), base);
194
+ while (!Array.isArray(prefix)) {
195
+ cut = prefix.offset;
196
+ prefix = attempt(rest.slice(0, cut), base);
197
+ }
198
+ tokens.push(...prefix);
199
+ // The character that started the damage is text
200
+ if (cut < rest.length) {
201
+ const start = base + cut;
202
+ tokens.push({
203
+ type: 'text',
204
+ value: rest[cut]!,
205
+ start,
206
+ end: start + 1,
207
+ });
208
+ }
209
+ base += cut + 1;
210
+ }
211
+ return { tokens, errors };
212
+ }
213
+
214
+ /** `token` with its offsets moved `by` on. */
215
+ function shift(token: Token, by: number): Token {
216
+ if (by === 0) return token;
217
+ const moved = { ...token, start: token.start + by, end: token.end + by };
218
+ if (moved.type === 'link') {
219
+ moved.targetStart += by;
220
+ moved.targetEnd += by;
221
+ }
222
+ return moved;
223
+ }
@@ -13,12 +13,15 @@ export declare class SyntaxError extends globalThis.SyntaxError {
13
13
  }
14
14
 
15
15
  export interface ParseOptions {
16
- startRule: 'Markup' | 'Tokens';
16
+ startRule: 'Markup' | 'Tokens' | 'SelectorsPrefix';
17
17
  /** Text mode (attribute values, labels). */
18
18
  text: boolean;
19
19
  /** What the grammar leaves to code (see parse.ts MarkupHooks). */
20
20
  hooks: unknown;
21
21
  }
22
22
 
23
- /** Parse markup: the AST for `Markup`, the tokens for `Tokens`. */
23
+ /**
24
+ * Parse markup: the AST for `Markup`, the tokens for `Tokens`, the selectors
25
+ * and where they end for `SelectorsPrefix`.
26
+ */
24
27
  export declare function parse(input: string, options: ParseOptions): unknown;