@rohal12/spindle 0.58.0 → 0.59.1

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,11 +18,11 @@ 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,
24
25
  readQuoted,
25
- readWholeJsString,
26
26
  readWholeQuoted,
27
27
  stripLooseQuotes,
28
28
  topLevelIndices,
@@ -30,6 +30,20 @@ import {
30
30
 
31
31
  type Span = [start: number, end: number];
32
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
+
33
47
  /** The terms of `src`: the runs of text between its depth-0 whitespace. */
34
48
  function terms(src: string): Span[] {
35
49
  const spans: Span[] = [];
@@ -64,7 +78,9 @@ export class MacroArgumentError extends Error {
64
78
  }
65
79
  }
66
80
 
67
- 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]);
68
84
  switch (param.type) {
69
85
  case 'string': {
70
86
  const value = readWholeQuoted(src);
@@ -80,7 +96,7 @@ function readValue(param: ParameterDef, src: string): unknown {
80
96
  case 'number':
81
97
  return Number(src);
82
98
  case 'options':
83
- return readOptions(param.parameters ?? [], src);
99
+ return readOptions(param.parameters ?? [], src, place);
84
100
  default:
85
101
  return src;
86
102
  }
@@ -103,13 +119,15 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
103
119
 
104
120
  /**
105
121
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
106
- * quoted string or digit run after it as its value; keywords that aren't
107
- * declared, and their values, are skipped. A keyword of a `string` parameter
108
- * must be followed by a quoted string.
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.
109
126
  */
110
127
  function readOptions(
111
128
  params: readonly ParameterDef[],
112
129
  src: string,
130
+ { at, spans }: Place,
113
131
  ): Record<string, unknown> {
114
132
  const options: Record<string, unknown> = {};
115
133
  let i = 0;
@@ -122,26 +140,28 @@ function readOptions(
122
140
  }
123
141
  i += key.length;
124
142
 
125
- // 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.
126
146
  let val: string | undefined;
127
- let quoted: ReturnType<typeof readQuoted> = null;
128
147
  let j = i;
129
148
  while (j < src.length && isWhitespace(src[j]!)) j++;
130
- if (j > i) {
131
- quoted = readQuoted(src, j);
132
- const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
133
- if (quoted) {
134
- val = quoted.value;
135
- i = quoted.end;
136
- } else if (digits) {
137
- val = digits;
138
- i = j + digits.length;
139
- }
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;
140
157
  }
141
158
 
142
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
+ }
143
163
  // A keyword that takes a string was given anything else, or nothing
144
- if (param?.type === 'string' && (!quoted || val === undefined)) {
164
+ if (param?.type === 'string' && !quoted) {
145
165
  throw new MacroArgumentError(
146
166
  param,
147
167
  matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
@@ -158,21 +178,21 @@ function readOptions(
158
178
  const LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
159
179
 
160
180
  /**
161
- * The rest of `src` without the flag `word` as its last or first term, or
162
- * `null` if it has no such flag. Next to a binary operator the word is an
163
- * operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
164
- * 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.
165
185
  */
166
- function takeFlag(src: string, word: string): string | null {
186
+ function takeFlag(src: string, word: string): Span | null {
167
187
  const spans = terms(src);
168
188
  if (spans.length < 2) return null;
169
189
  if (slice(src, spans[spans.length - 1]!) === word) {
170
- const rest = src.slice(0, spans[spans.length - 2]![1]);
171
- if (!endsWithOperator(rest)) return rest;
190
+ const rest: Span = [0, spans[spans.length - 2]![1]];
191
+ if (!endsWithOperator(slice(src, rest))) return rest;
172
192
  }
173
193
  if (slice(src, spans[0]!) === word) {
174
- const rest = src.slice(spans[1]![0]);
175
- 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;
176
196
  }
177
197
  return null;
178
198
  }
@@ -211,17 +231,27 @@ const takesRest = (param: ParameterDef) =>
211
231
  param.type === 'passage' ||
212
232
  param.type === 'text';
213
233
 
214
- /** 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
+ */
215
244
  function readGroup(
216
245
  params: readonly ParameterDef[],
217
246
  src: string | null,
218
247
  args: Record<string, unknown>,
248
+ { at: srcAt, spans }: Place,
219
249
  ): void {
220
- let text = src?.trim() ?? '';
250
+ let [text, at] = trimmed(src ?? '', srcAt);
221
251
  for (const flag of params.filter((p) => p.type === 'flag')) {
222
252
  const rest = src === null ? null : takeFlag(text, flag.name);
223
253
  args[flag.name] = rest !== null;
224
- text = rest ?? text;
254
+ if (rest) [text, at] = trimmed(slice(text, rest), at + rest[0]);
225
255
  }
226
256
  const positional = params.filter((p) => p.type !== 'flag');
227
257
  if (positional.length === 0) return;
@@ -231,27 +261,29 @@ function readGroup(
231
261
  (p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest),
232
262
  );
233
263
  const restIndex = lastText < 0 ? positional.length - 1 : lastText;
234
- const read = (param: ParameterDef, value: string) =>
235
- 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 });
236
269
 
237
270
  // One parameter reads the whole text: no need to split it.
238
271
  if (positional.length === 1) {
239
- args[positional[0]!.name] = read(positional[0]!, text);
272
+ args[positional[0]!.name] = read(positional[0]!, [0, text.length]);
240
273
  return;
241
274
  }
242
275
 
243
- const spans = terms(text);
276
+ const parts = terms(text);
244
277
  let from = 0;
245
- let to = spans.length;
278
+ let to = parts.length;
246
279
  for (const param of positional.slice(0, restIndex)) {
247
- args[param.name] =
248
- from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
280
+ args[param.name] = from < to ? read(param, parts[from++]!) : unset(param);
249
281
  }
250
282
  // Optional ones at the end: a term that doesn't have their form (a
251
283
  // `string` that isn't quoted) belongs to the rest, as in {meter $hp 100}
252
- const readTrailing = (param: ParameterDef, value: string) => {
284
+ const readTrailing = (param: ParameterDef, span: Span) => {
253
285
  try {
254
- return read(param, value);
286
+ return read(param, span);
255
287
  } catch (error) {
256
288
  if (error instanceof MacroArgumentError) return undefined;
257
289
  throw error;
@@ -259,17 +291,17 @@ function readGroup(
259
291
  };
260
292
  for (const param of positional.slice(restIndex + 1).reverse()) {
261
293
  const value =
262
- from < to && !endsWithOperator(text.slice(0, spans[to - 1]![0]))
263
- ? readTrailing(param, slice(text, spans[to - 1]!))
294
+ from < to && !endsWithOperator(text.slice(0, parts[to - 1]![0]))
295
+ ? readTrailing(param, parts[to - 1]!)
264
296
  : undefined;
265
297
  args[param.name] = value ?? unset(param);
266
298
  if (value !== undefined) to--;
299
+ // A term it didn't take
300
+ else spans?.delete(param);
267
301
  }
268
302
  const rest = positional[restIndex]!;
269
303
  args[rest.name] =
270
- from < to
271
- ? read(rest, text.slice(spans[from]![0], spans[to - 1]![1]))
272
- : unset(rest);
304
+ from < to ? read(rest, [parts[from]![0], parts[to - 1]![1]]) : unset(rest);
273
305
  }
274
306
 
275
307
  /** The duration a timing macro ({repeat}, {type}, {timed}/{next}) waits. */
@@ -279,25 +311,33 @@ export const DELAY_PARAMETER = {
279
311
  required: true,
280
312
  } as const;
281
313
 
282
- /** 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
+ */
283
318
  export function parseMacroArgs<const P extends readonly ParameterDef[]>(
284
319
  rawArgs: string,
285
320
  parameters: P,
321
+ spans?: ArgSpans,
286
322
  ): MacroArgs<P> {
287
323
  const args: Record<string, unknown> = {};
288
324
  // The text after the last separator read, null once one is missing
289
- let rest: string | null = rawArgs.trim();
325
+ let [rest, restAt]: [string | null, number] = trimmed(rawArgs, 0);
290
326
  let groupStart = 0;
291
327
  parameters.forEach((separator, i) => {
292
328
  if (separator.type !== 'separator') return;
293
329
  const at = rest === null ? null : findSeparator(rest, separator.name);
294
330
  args[separator.name] = at !== null;
295
331
  const before = at ? rest!.slice(0, at[0]) : rest;
296
- readGroup(parameters.slice(groupStart, i), before, args);
332
+ readGroup(parameters.slice(groupStart, i), before, args, {
333
+ at: restAt,
334
+ spans,
335
+ });
297
336
  rest = at ? rest!.slice(at[1]) : null;
337
+ if (at) restAt += at[1];
298
338
  groupStart = i + 1;
299
339
  });
300
- readGroup(parameters.slice(groupStart), rest, args);
340
+ readGroup(parameters.slice(groupStart), rest, args, { at: restAt, spans });
301
341
  return args as MacroArgs<P>;
302
342
  }
303
343
 
@@ -308,13 +348,14 @@ export type PassageTarget =
308
348
 
309
349
  /**
310
350
  * 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
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
313
354
  * runs (see evaluatePassageName).
314
355
  */
315
356
  export function passageTarget(arg: string): PassageTarget {
316
357
  const expression = arg.trim();
317
- const name = readWholeJsString(expression);
358
+ const name = stringLiteralValue(expression);
318
359
  return name === null
319
360
  ? { kind: 'expression', expression }
320
361
  : { kind: 'name', name };
@@ -16,7 +16,7 @@ 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. */
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
@@ -153,10 +153,24 @@ export function tokenizeMarkupTolerant(
153
153
  ): TolerantTokens {
154
154
  const tokens: Token[] = [];
155
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 };
156
161
  /** `text` shifted by `base`, tokenized: the tokens or the error. */
157
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
+ };
158
170
  try {
159
- return tokenizeMarkup(text, options).map((token) => shift(token, base));
171
+ return tokenizeMarkup(text, { ...options, hooks }).map((token) =>
172
+ shift(token, base),
173
+ );
160
174
  } catch (error) {
161
175
  if (!(error instanceof MarkupError)) throw error;
162
176
  return error;
@@ -199,7 +213,11 @@ export function tokenizeMarkupTolerant(
199
213
 
200
214
  /** `token` with its offsets moved `by` on. */
201
215
  function shift(token: Token, by: number): Token {
202
- return by === 0
203
- ? token
204
- : { ...token, start: token.start + by, end: token.end + by };
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;
205
223
  }
@@ -32,16 +32,28 @@ const BRANCHING = new Set(['if', 'switch', 'timed']);
32
32
  const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
33
33
  const NO_SELECTORS = {};
34
34
 
35
- function parseLink(inner) {
35
+ /**
36
+ * A link's display text and target, from its text `inner` (at `at` in the
37
+ * input), with where the target is written.
38
+ */
39
+ function parseLink(inner, at) {
40
+ /** The trimmed text from `start` to `end` of inner, and where it is. */
41
+ const part = (start, end) => {
42
+ const raw = inner.slice(start, end);
43
+ const value = raw.trim();
44
+ const from = at + start + (value ? raw.indexOf(value) : 0);
45
+ return { value, start: from, end: from + value.length };
46
+ };
36
47
  for (const [separator, targetFirst] of LINK_SEPARATORS) {
37
48
  const idx = inner.indexOf(separator);
38
49
  if (idx === -1) continue;
39
- const before = inner.slice(0, idx).trim();
40
- const after = inner.slice(idx + separator.length).trim();
41
- return targetFirst ? { display: after, target: before } : { display: before, target: after };
50
+ const before = part(0, idx);
51
+ const after = part(idx + separator.length, inner.length);
52
+ const [display, target] = targetFirst ? [after, before] : [before, after];
53
+ return { display: display.value, target: target.value, targetStart: target.start, targetEnd: target.end };
42
54
  }
43
- const trimmed = inner.trim();
44
- return { display: trimmed, target: trimmed };
55
+ const whole = part(0, inner.length);
56
+ return { display: whole.value, target: whole.value, targetStart: whole.start, targetEnd: whole.end };
45
57
  }
46
58
 
47
59
  function quoteArg(value) {
@@ -106,7 +118,7 @@ function tokenOf(x) {
106
118
  case 'text':
107
119
  return { type: 'text', value: x.value, ...span };
108
120
  case 'link':
109
- return withSelectors({ type: 'link', display: x.display, target: x.target, ...span }, x);
121
+ return withSelectors({ type: 'link', display: x.display, target: x.target, targetStart: x.targetStart, targetEnd: x.targetEnd, ...span }, x);
110
122
  case 'variable':
111
123
  return withSelectors({ type: 'variable', name: x.name, scope: x.scope, ...span }, x);
112
124
  case 'expression':
@@ -336,8 +348,8 @@ Literal
336
348
  / "[" { return lexeme('text', location(), { value: '[' }); }
337
349
 
338
350
  LinkLexeme
339
- = &{ return !textMode; } "[[" s:Selectors inner:$LinkText "]]" {
340
- return withSelectors(lexeme('link', location(), parseLink(inner)), s);
351
+ = &{ return !textMode; } "[[" s:Selectors at:Here inner:$LinkText "]]" {
352
+ return withSelectors(lexeme('link', location(), parseLink(inner, at)), s);
341
353
  }
342
354
  / &{ return !textMode; } "[[" { fail('Unclosed link: [[ without ]]', location()); }
343
355
 
@@ -58,6 +58,9 @@ export interface LinkToken extends Span, Selectors {
58
58
  type: 'link';
59
59
  display: string;
60
60
  target: string;
61
+ /** Where the target is written, from `targetStart` to `targetEnd`. */
62
+ targetStart: number;
63
+ targetEnd: number;
61
64
  }
62
65
 
63
66
  export interface MacroToken extends Span, Selectors {
@@ -8,10 +8,13 @@
8
8
  import { lineColumn, MarkupError, parseMarkup, tokenizeMarkup } from './parse';
9
9
  import type { Token } from './tokens';
10
10
  import { parseWidgetDef } from '../widgets/widget-def';
11
- import { codeAndText, parseOrError, withParseCache } from '../code-check';
11
+ import {
12
+ codeAndText,
13
+ parseOrError,
14
+ withParseCache,
15
+ type ParametersOf,
16
+ } from '../code-check';
12
17
  import { CodeSyntaxError } from '../js-lexer';
13
- import type { ParameterDef } from '../registry';
14
- import { subMacroParameters } from '../components/macros/option-utils';
15
18
 
16
19
  /** A passage to validate. */
17
20
  export interface MarkupPassage {
@@ -59,12 +62,13 @@ export interface MarkupValidationOptions {
59
62
  */
60
63
  checkPassageNames?: boolean;
61
64
  /**
62
- * The declared parameters of a macro, whose `expression` and `statements`
63
- * arguments are checked as code and whose `text` and `string` arguments
64
- * as markup (see code-check.ts). Without them only the code of `{$…}`,
65
- * `{do}`, branch conditions and attributes is checked.
65
+ * The declared parameters of a macro (see parameterLookup in
66
+ * code-check.ts), whose `expression` and `statements` arguments are
67
+ * checked as code and whose `text` and `string` arguments as what they
68
+ * hold. Without them only the code of `{$…}`, `{do}`, branch conditions
69
+ * and attributes is checked.
66
70
  */
67
- parametersOf?(name: string): readonly ParameterDef[] | undefined;
71
+ parametersOf?: ParametersOf;
68
72
  }
69
73
 
70
74
  /**
@@ -228,8 +232,7 @@ export function validateMarkup(
228
232
  }
229
233
  };
230
234
 
231
- const parametersOf = (name: string) =>
232
- options.parametersOf?.(name) ?? subMacroParameters(name);
235
+ const parametersOf: ParametersOf = options.parametersOf ?? (() => undefined);
233
236
 
234
237
  /**
235
238
  * Check `tokens`, the tokens of `src`, which starts at `base` in the
package/src/registry.ts CHANGED
@@ -104,11 +104,36 @@ export const PARAMETER_TYPES = [
104
104
 
105
105
  export type ParameterType = (typeof PARAMETER_TYPES)[number];
106
106
 
107
+ /**
108
+ * What the value of a `string` or `text` argument holds, for the check at
109
+ * story start and for tooling (see code-check.ts):
110
+ * - `markup`: markup the macro renders (`{button}`'s label): its markup
111
+ * is checked.
112
+ * - `text`: plain text the macro uses as written (`{checkbox}`'s label).
113
+ * - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
114
+ * - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
115
+ * is checked as code, and its variable references against the schema.
116
+ *
117
+ * Without it, the argument of a macro with `interpolate` holds `markup`,
118
+ * any other `text`.
119
+ */
120
+ export const STRING_HOLDS = [
121
+ 'markup',
122
+ 'text',
123
+ 'passage',
124
+ 'expression',
125
+ 'statements',
126
+ ] as const;
127
+
128
+ export type StringHolds = (typeof STRING_HOLDS)[number];
129
+
107
130
  export interface ParameterDef {
108
131
  name: string;
109
132
  required?: boolean;
110
133
  description?: string;
111
134
  type: ParameterType;
135
+ /** What a `string` or `text` argument holds (see StringHolds). */
136
+ holds?: StringHolds;
112
137
  /** The options of an `options` parameter. */
113
138
  parameters?: readonly ParameterDef[];
114
139
  }
@@ -116,30 +141,62 @@ export interface ParameterDef {
116
141
  /**
117
142
  * Throw if a parameter `macro` declares (or an option of one) has no type,
118
143
  * or one that isn't a ParameterType: arguments are read by their type, and
119
- * there is no default.
144
+ * there is no default. Throw too if it declares what it `holds` wrongly.
120
145
  */
121
146
  export function checkParameterTypes(
122
147
  macro: string,
123
148
  parameters: readonly ParameterDef[],
124
149
  ): void {
150
+ const holdsFix =
151
+ `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(', ')} ` +
152
+ 'to hold (see docs/custom-macros.md#what-a-string-holds).';
125
153
  for (const param of parameters) {
126
154
  const type: unknown = param.type;
127
155
  if (!PARAMETER_TYPES.includes(type as ParameterType)) {
128
- const problem =
156
+ throw parameterError(
157
+ macro,
158
+ param,
129
159
  type === undefined
130
160
  ? 'has no type'
131
- : `has the unknown type ${JSON.stringify(type)}`;
132
- throw new Error(
133
- `spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ` +
134
- `Give it one of the types ${PARAMETER_TYPES.join(', ')} ` +
161
+ : `has the unknown type ${JSON.stringify(type)}`,
162
+ `Give it one of the types ${PARAMETER_TYPES.join(', ')} ` +
135
163
  '(see docs/custom-macros.md#parameter-types), ' +
136
164
  'or declare no parameters and read props.rawArgs.',
137
165
  );
138
166
  }
167
+ const holds: unknown = param.holds;
168
+ if (holds !== undefined && type !== 'string' && type !== 'text') {
169
+ throw parameterError(
170
+ macro,
171
+ param,
172
+ `is of the type ${type}, which holds what it is`,
173
+ holdsFix,
174
+ );
175
+ }
176
+ if (holds !== undefined && !STRING_HOLDS.includes(holds as StringHolds)) {
177
+ throw parameterError(
178
+ macro,
179
+ param,
180
+ `holds the unknown ${JSON.stringify(holds)}`,
181
+ holdsFix,
182
+ );
183
+ }
139
184
  if (param.parameters) checkParameterTypes(macro, param.parameters);
140
185
  }
141
186
  }
142
187
 
188
+ /** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
189
+ function parameterError(
190
+ macro: string,
191
+ param: ParameterDef,
192
+ problem: string,
193
+ fix: string,
194
+ ): Error {
195
+ return new Error(
196
+ `spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`,
197
+ );
198
+ }
199
+
143
200
  type ArgValue<T, D> = T extends 'flag' | 'separator'
144
201
  ? boolean
145
202
  : T extends 'names'
@@ -315,7 +315,20 @@ function createIDBBackend(): StorageBackend {
315
315
  make: (store: IDBObjectStore) => IDBRequest<R>,
316
316
  ): Promise<R> => {
317
317
  const db = await openDB();
318
- return idbReq(make(db.transaction(name, mode).objectStore(name)));
318
+ const tx = db.transaction(name, mode);
319
+ const result = idbReq(make(tx.objectStore(name)));
320
+ // A request can succeed and its transaction still roll back, so a
321
+ // write is done only when the transaction completes.
322
+ if (mode === 'readonly') return result;
323
+ const committed = new Promise<void>((resolve, reject) => {
324
+ tx.oncomplete = () => resolve();
325
+ tx.onabort = () =>
326
+ reject(
327
+ tx.error ?? new DOMException('Transaction aborted', 'AbortError'),
328
+ );
329
+ });
330
+ const [value] = await Promise.all([result, committed]);
331
+ return value;
319
332
  };
320
333
 
321
334
  return {