@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -8,6 +8,7 @@ import type {
8
8
  import type { StoryAPI } from '../story-api';
9
9
  import type { StoryAction } from '../action-registry';
10
10
  import { errorMessage } from '../utils/error-message';
11
+ import { deepEqual } from '../structural';
11
12
 
12
13
  export interface RunOptions {
13
14
  onStep?: (stepIndex: number, step: AutomationStep) => void;
@@ -88,7 +89,7 @@ async function executeStep(
88
89
  if (assert.variables) {
89
90
  for (const [key, expected] of Object.entries(assert.variables)) {
90
91
  const actual = story.get(key);
91
- if (actual !== expected) {
92
+ if (!deepEqual(actual, expected)) {
92
93
  errors.push({
93
94
  step: index,
94
95
  message: `Variable "${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
package/src/code-check.ts CHANGED
@@ -8,18 +8,20 @@
8
8
  * - `{$…}` expressions, and `{do}` bodies (statements);
9
9
  * - the conditions of `{if}`, `{elseif}` and `{case}`;
10
10
  * - macro arguments whose declared parameter type is `expression`,
11
- * `statements` or `passage`, built-in and custom macros alike; the
12
- * condition and `run` action of `{watch}`, code in quoted strings;
11
+ * `statements` or `passage`, built-in and custom macros alike, and the
12
+ * `string` and `text` arguments that hold code (ParameterDef.holds: the
13
+ * condition and `run` action of `{watch}`);
13
14
  * - the `{$…}` references in attributes holding code (`onclick`).
14
15
  *
15
- * Text that may hold markup is found in quoted labels (arguments of type
16
- * `text` and `string`) and HTML attribute values; the check reads its
17
- * markup in turn.
16
+ * Text that may hold markup is found in the `string` and `text` arguments
17
+ * that hold markup (labels such as `{button}`'s) and HTML attribute values;
18
+ * the check reads its markup in turn.
18
19
  *
19
20
  * Passage names written out are found in links (`[[Go->Hall]]`), in
20
21
  * `passage` arguments that are one quoted string (`{goto "Hall"}`), in the
21
- * passage of `{link}`, the `goto` and `dialog` actions of `{watch}` and the
22
- * body of `{dialog}`; the check looks each one up.
22
+ * `string` and `text` arguments that hold a passage name (the `goto` and
23
+ * `dialog` actions of `{watch}`) and in the body of `{dialog}`; the check
24
+ * looks each one up.
23
25
  *
24
26
  * The arguments of macros that declare no parameters may be anything:
25
27
  * they are not checked.
@@ -28,13 +30,16 @@ import type { Token } from './markup/tokens';
28
30
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
29
31
  import { CodeSyntaxError, parseCode, type JsGoal } from './js-lexer';
30
32
  import type { ParsedCode } from './js-lexer';
31
- import type { ParameterDef } from './registry';
33
+ import type { ParameterDef, StringHolds } from './registry';
32
34
  import {
33
35
  MacroArgumentError,
34
36
  parseMacroArgs,
35
37
  passageTarget,
38
+ type ArgSpans,
36
39
  type PassageTarget,
37
40
  } from './components/macros/macro-args';
41
+ import { subMacroParameters } from './components/macros/option-utils';
42
+ import { NameMap } from './utils/macro-names';
38
43
 
39
44
  /** While a pass runs: the code it parsed so far, by goal and source. */
40
45
  let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
@@ -79,7 +84,10 @@ export interface CodePiece {
79
84
  goal: JsGoal;
80
85
  /** The markup it is in, for the error: `{print $a +}`. */
81
86
  label: string;
82
- /** Whether it names a passage: a `passage` argument. */
87
+ /**
88
+ * Whether it names a passage: a `passage` argument that is an
89
+ * expression (one that is a string literal is a PassagePiece).
90
+ */
83
91
  passage?: boolean;
84
92
  /** Whether it is the code in a quoted string, as in `{watch}`. */
85
93
  inString?: boolean;
@@ -94,7 +102,7 @@ export interface PassagePiece {
94
102
  offset: number;
95
103
  /** The markup it is in, for the error: `[[Go->Hall]]`. */
96
104
  label: string;
97
- /** How much of the markup is the name, as written. */
105
+ /** How much of the markup is the name, as written (quotes included). */
98
106
  length: number;
99
107
  /** The macro it is the argument of; `link` for `[[…]]` links. */
100
108
  macro: string;
@@ -121,30 +129,78 @@ export interface TextPiece {
121
129
  /** Macros whose whole argument text is one expression: branch conditions. */
122
130
  const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
123
131
 
124
- /** Code inside the quoted strings of a macro's arguments, by parameter. */
125
- const CODE_IN_STRINGS: ReadonlyMap<string, Record<string, JsGoal>> = new Map([
126
- ['watch', { condition: 'expression', run: 'statements' }],
127
- ]);
132
+ /** Block macros whose body is the name of a passage. */
133
+ const PASSAGE_BODIES = new Set(['dialog']);
128
134
 
129
- /** The `string` parameters of a macro that name a passage. */
130
- const PASSAGE_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
131
- ['watch', ['goto', 'dialog']],
132
- ]);
135
+ type Piece = CodePiece | TextPiece | PassagePiece | ArgumentErrorPiece;
136
+
137
+ /** The declared parameters of a macro, if it has any. */
138
+ export type ParametersOf = (
139
+ macro: string,
140
+ ) => readonly ParameterDef[] | undefined;
141
+
142
+ /** What the argument check needs to know of a macro (see MacroMetadata). */
143
+ export interface MacroParameters {
144
+ name: string;
145
+ parameters?: readonly ParameterDef[];
146
+ interpolate?: boolean;
147
+ }
133
148
 
134
149
  /**
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.
150
+ * `params` with each `string` and `text` parameter (options too) saying
151
+ * what it holds: what it declares, else `holds`.
137
152
  */
138
- const LITERAL_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
139
- ['option', ['value']],
140
- ['watch', ['name']],
141
- ['unwatch', ['name']],
142
- ]);
153
+ function withHolds(
154
+ params: readonly ParameterDef[],
155
+ holds: StringHolds,
156
+ ): readonly ParameterDef[] {
157
+ return params.map((param) => {
158
+ if (param.parameters) {
159
+ return { ...param, parameters: withHolds(param.parameters, holds) };
160
+ }
161
+ const isString = param.type === 'string' || param.type === 'text';
162
+ return isString && !param.holds ? { ...param, holds } : param;
163
+ });
164
+ }
143
165
 
144
- /** Block macros whose body is the name of a passage. */
145
- const PASSAGE_BODIES = new Set(['dialog']);
166
+ /**
167
+ * The declared parameters of `macros`, by name in any case, and of the
168
+ * built-in sub-macros (`{option}`): what the story-start check, reference
169
+ * collection and tooling read arguments by. Each `string` and `text`
170
+ * parameter says what it holds: what it declares, else `markup` for a macro
171
+ * with `interpolate` (which can resolve markup) and `text` for any other.
172
+ */
173
+ export function parameterLookup(
174
+ macros: Iterable<MacroParameters>,
175
+ ): ParametersOf {
176
+ const parameters = new NameMap<readonly ParameterDef[]>();
177
+ for (const { name, parameters: params, interpolate } of macros) {
178
+ if (params)
179
+ parameters.set(name, withHolds(params, holdsByDefault(interpolate)));
180
+ }
181
+ return (name) => {
182
+ let params = parameters.get(name);
183
+ if (!params) {
184
+ const sub = subMacroParameters(name);
185
+ if (sub) parameters.set(name, (params = withHolds(sub, 'text')));
186
+ }
187
+ return params;
188
+ };
189
+ }
146
190
 
147
- type Piece = CodePiece | TextPiece | PassagePiece | ArgumentErrorPiece;
191
+ /** What a `string` or `text` argument holds when its parameter doesn't say. */
192
+ const holdsByDefault = (interpolate: boolean | undefined): StringHolds =>
193
+ interpolate ? 'markup' : 'text';
194
+
195
+ /** Whether a parameter in `params` (options too) holds code in a string. */
196
+ export function holdsCode(params: readonly ParameterDef[]): boolean {
197
+ return params.some(
198
+ (p) =>
199
+ p.holds === 'expression' ||
200
+ p.holds === 'statements' ||
201
+ (!!p.parameters && holdsCode(p.parameters)),
202
+ );
203
+ }
148
204
 
149
205
  /** The goal of the code an argument of this type holds, if it is code. */
150
206
  function codeGoal(param: ParameterDef): JsGoal | undefined {
@@ -155,6 +211,15 @@ function codeGoal(param: ParameterDef): JsGoal | undefined {
155
211
  return undefined;
156
212
  }
157
213
 
214
+ /**
215
+ * What a `string` or `text` argument holds: what its parameter says (see
216
+ * parameterLookup), else markup, which is checked.
217
+ */
218
+ function stringHolds(param: ParameterDef): StringHolds | undefined {
219
+ if (param.type !== 'string' && param.type !== 'text') return undefined;
220
+ return param.holds ?? 'markup';
221
+ }
222
+
158
223
  /**
159
224
  * The code and the markup-holding text in `tokens`, the tokens of `src`.
160
225
  * `parametersOf` gives the declared parameters of a macro, if it has any.
@@ -162,7 +227,7 @@ function codeGoal(param: ParameterDef): JsGoal | undefined {
162
227
  export function* codeAndText(
163
228
  src: string,
164
229
  tokens: readonly Token[],
165
- parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
230
+ parametersOf: ParametersOf,
166
231
  ): Generator<Piece> {
167
232
  /** The index of the closing tag of the `name` macro opened at `t`. */
168
233
  const closeOf = (t: number, name: string) =>
@@ -179,8 +244,8 @@ export function* codeAndText(
179
244
  yield {
180
245
  kind: 'passage',
181
246
  name: token.target,
182
- offset: locate(src, token.target, token.start),
183
- length: token.target.length,
247
+ offset: token.targetStart,
248
+ length: token.targetEnd - token.targetStart,
184
249
  macro: 'link',
185
250
  label: src.slice(token.start, token.end),
186
251
  };
@@ -193,9 +258,11 @@ export function* codeAndText(
193
258
  label: `{${token.expression}}`,
194
259
  };
195
260
  } else if (token.type === 'html') {
261
+ // The values are the source text between their quotes, in order
262
+ let cursor = token.start;
196
263
  for (const [name, value] of Object.entries(token.attributes)) {
197
- // The value is the source text between its quotes
198
- const at = locate(src, value, token.start);
264
+ const at = locate(src, value, cursor);
265
+ cursor = at + value.length;
199
266
  if (!isCodeAttribute(name)) {
200
267
  yield {
201
268
  kind: 'text',
@@ -242,12 +309,12 @@ export function* codeAndText(
242
309
  const body = tokens.slice(t + 1, close);
243
310
  if (close > t + 1 && body.every((b) => b.type === 'text')) {
244
311
  const text = src.slice(token.end, tokens[close]!.start);
245
- const passage = text.trim().replace(/^["']|["']$/g, '');
312
+ const written = text.trim();
246
313
  yield {
247
314
  kind: 'passage',
248
- name: passage,
249
- offset: locate(src, passage, token.end),
250
- length: passage.length,
315
+ name: written.replace(/^["']|["']$/g, ''),
316
+ offset: token.end + text.indexOf(written),
317
+ length: written.length,
251
318
  macro: name,
252
319
  label: src.slice(token.start, token.end),
253
320
  };
@@ -255,7 +322,9 @@ export function* codeAndText(
255
322
  }
256
323
  if (!args) continue;
257
324
  const label = `{${token.name} ${args}}`;
258
- const argsAt = locate(src, args, token.start + 1);
325
+ // The arguments end the tag, but for the whitespace before its }
326
+ const tag = src.slice(token.start, token.end - 1);
327
+ const argsAt = token.start + tag.trimEnd().length - args.length;
259
328
  if (CONDITION_MACROS.has(name)) {
260
329
  yield {
261
330
  kind: 'code',
@@ -281,18 +350,15 @@ export function* argPieces(
281
350
  label: string,
282
351
  ): Generator<Piece> {
283
352
  let values: Record<string, unknown>;
353
+ const spans: ArgSpans = new Map();
284
354
  try {
285
- values = parseMacroArgs(args, params) as Record<string, unknown>;
355
+ values = parseMacroArgs(args, params, spans) as Record<string, unknown>;
286
356
  } catch (error) {
287
357
  if (!(error instanceof MacroArgumentError)) throw error;
288
358
  yield { kind: 'argument-error', message: error.message, offset, label };
289
359
  return;
290
360
  }
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) ?? [];
295
- let cursor = 0;
361
+ const name = macro.toLowerCase();
296
362
  /** The pieces of the parameters `list`, with their values in `from`. */
297
363
  function* visit(
298
364
  list: readonly ParameterDef[],
@@ -307,58 +373,70 @@ export function* argPieces(
307
373
  );
308
374
  continue;
309
375
  }
310
- if (typeof value !== 'string' || !value.trim()) continue;
311
- const at = locate(args, value, cursor);
312
- if (at >= cursor) cursor = at + value.length;
313
- const goal = codeGoal(param) ?? inStrings[param.name];
376
+ const span = spans.get(param);
377
+ if (typeof value !== 'string' || !value.trim() || !span) continue;
378
+ const [start, end] = span;
379
+ const written = args.slice(start, end);
380
+ /** The name as written (quotes included), at the argument. */
381
+ const passage = (passageName: string): PassagePiece => ({
382
+ kind: 'passage',
383
+ name: passageName,
384
+ offset: offset + start,
385
+ length: end - start,
386
+ macro: name,
387
+ label,
388
+ });
389
+ const goal = codeGoal(param);
390
+ if (param.type === 'passage') {
391
+ // A name written out, else an expression naming one
392
+ const target = passageTarget(value);
393
+ if (target.kind === 'name') {
394
+ yield passage(target.name);
395
+ continue;
396
+ }
397
+ }
314
398
  if (goal) {
315
399
  const piece: CodePiece = {
316
400
  kind: 'code',
317
401
  code: value,
318
- offset: offset + at,
402
+ offset: offset + start,
319
403
  goal,
320
404
  label,
321
405
  };
322
406
  if (param.type === 'passage') {
323
407
  piece.passage = true;
324
- piece.macro = macro.toLowerCase();
408
+ piece.macro = name;
325
409
  }
326
- if (!codeGoal(param)) piece.inString = true;
327
410
  yield piece;
411
+ continue;
328
412
  }
329
- // A passage written out: a quoted `passage` argument, or a string
330
- const written = param.type === 'passage' ? value.trim() : value;
331
- const target =
332
- param.type === 'passage'
333
- ? passageTarget(value)
334
- : passageStrings.includes(param.name)
335
- ? ({ kind: 'name', name: value } as const)
336
- : null;
337
- if (target?.kind === 'name') {
413
+ // The value of a string is inside its quotes
414
+ const at =
415
+ offset + start + (value !== written && /^["']/.test(written) ? 1 : 0);
416
+ const holds = stringHolds(param);
417
+ if (holds === 'expression' || holds === 'statements') {
338
418
  yield {
339
- kind: 'passage',
340
- name: target.name,
341
- offset: offset + at + value.indexOf(written),
342
- length: written.length,
343
- macro: macro.toLowerCase(),
419
+ kind: 'code',
420
+ code: value,
421
+ offset: at,
422
+ goal: holds,
344
423
  label,
424
+ inString: true,
345
425
  };
346
- }
347
- if (
348
- !goal &&
349
- (param.type === 'text' || param.type === 'string') &&
350
- !literalStrings.includes(param.name)
351
- ) {
426
+ } else if (holds === 'passage') {
427
+ yield passage(value);
428
+ } else if (holds === 'markup') {
352
429
  yield {
353
430
  kind: 'text',
354
431
  text: value,
355
- offset: offset + at,
432
+ offset: at,
356
433
  where: `In the ${param.name} of {${macro}}: `,
357
434
  };
358
435
  }
359
436
  }
360
437
  }
361
- yield* visit(params, values);
438
+ // Options are visited in metadata order, which is not the order written
439
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
362
440
  }
363
441
 
364
442
  /**
@@ -387,14 +465,15 @@ export interface PassageReference {
387
465
  * The passages the markup `src` (with the `tokens` it tokenizes to) names:
388
466
  * `[[…]]` links, the passage argument of `{goto}`, `{include}` and `{link}`
389
467
  * (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.
468
+ * `{watch}` (and the `string` and `text` arguments of macros that declare
469
+ * they hold one) and the body of `{dialog}`, in source order. A name written
470
+ * out is a literal; the others are expressions, which name a passage when
471
+ * they run.
393
472
  */
394
473
  export function collectPassageReferences(
395
474
  src: string,
396
475
  tokens: readonly Token[],
397
- parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
476
+ parametersOf: ParametersOf,
398
477
  ): PassageReference[] {
399
478
  const refs: PassageReference[] = [];
400
479
  for (const piece of codeAndText(src, tokens, parametersOf)) {
@@ -408,15 +487,11 @@ export function collectPassageReferences(
408
487
  } else if (piece.kind === 'code' && piece.passage) {
409
488
  refs.push({
410
489
  macro: piece.macro!,
411
- target: passageTarget(piece.code),
490
+ target: { kind: 'expression', expression: piece.code },
412
491
  start: piece.offset,
413
492
  end: piece.offset + piece.code.length,
414
493
  });
415
494
  }
416
495
  }
417
- return refs.filter(
418
- (ref, i) =>
419
- refs.findIndex((r) => r.start === ref.start && r.macro === ref.macro) ===
420
- i,
421
- );
496
+ return refs;
422
497
  }
@@ -5,7 +5,9 @@ defineMacro({
5
5
  name: 'button',
6
6
  block: true,
7
7
  interpolate: true,
8
- parameters: [{ name: 'label', type: 'text', required: true }],
8
+ parameters: [
9
+ { name: 'label', type: 'text', holds: 'markup', required: true },
10
+ ],
9
11
  render({ rawArgs, children = [] }, ctx) {
10
12
  const label = ctx.resolve?.(ctx.args.label ?? '') ?? rawArgs;
11
13
  const runBody = useDetachedBody();
@@ -4,7 +4,10 @@ import { VARIABLE_PARAMETER, useVariableAction } from './input-macro';
4
4
  defineMacro({
5
5
  name: 'checkbox',
6
6
  storeVar: true,
7
- parameters: [VARIABLE_PARAMETER, { name: 'label', type: 'text' }],
7
+ parameters: [
8
+ VARIABLE_PARAMETER,
9
+ { name: 'label', type: 'text', holds: 'text' },
10
+ ],
8
11
  render(_props, ctx) {
9
12
  const label = ctx.args.label ?? '';
10
13
 
@@ -6,7 +6,7 @@ defineMacro({
6
6
  block: true,
7
7
  interpolate: true,
8
8
  parameters: [
9
- { name: 'label', type: 'text', required: true },
9
+ { name: 'label', type: 'text', holds: 'markup', required: true },
10
10
  { name: 'noclose', type: 'flag' },
11
11
  ],
12
12
  render({ children = [] }, ctx) {
@@ -12,11 +12,12 @@ defineMacro({
12
12
  // {link "text" passage} or {link "text"}; the passage is a quoted name or
13
13
  // an expression, as in {goto}.
14
14
  parameters: [
15
- { name: 'text', type: 'string', required: true },
15
+ { name: 'text', type: 'string', holds: 'markup', required: true },
16
16
  { name: 'passage', type: 'passage' },
17
17
  ],
18
18
  render({ children = [] }, ctx) {
19
- const display = ctx.args.text ?? '';
19
+ // The label is markup: resolved once, for the element and its action
20
+ const display = ctx.resolve?.(ctx.args.text ?? '') ?? '';
20
21
  let passage: string | null = null;
21
22
  // Boxed: anything can be thrown, including null and other falsy values
22
23
  let failure: { error: unknown } | undefined;
@@ -40,11 +40,15 @@ defineMacro({
40
40
  parameters: [
41
41
  { name: 'current', type: 'expression', required: true },
42
42
  { name: 'max', type: 'expression', required: true },
43
- { name: 'label', type: 'string' },
43
+ { name: 'label', type: 'string', holds: 'markup' },
44
44
  ],
45
45
  render(_props, ctx) {
46
46
  try {
47
- const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
47
+ const { currentExpr, maxExpr, labelMode } = meterArgs({
48
+ ...ctx.args,
49
+ // The label is markup: resolved before reading none, % or a unit
50
+ label: ctx.resolve?.(ctx.args.label),
51
+ });
48
52
  const current = Number(ctx.evaluate!(currentExpr));
49
53
  const max = Number(ctx.evaluate!(maxExpr));
50
54
  const pct =
@@ -6,8 +6,8 @@ defineMacro({
6
6
  storeVar: true,
7
7
  parameters: [
8
8
  VARIABLE_PARAMETER,
9
- { name: 'value', type: 'text', required: true },
10
- { name: 'label', type: 'text' },
9
+ { name: 'value', type: 'text', holds: 'text', required: true },
10
+ { name: 'label', type: 'text', holds: 'text' },
11
11
  ],
12
12
  render(_props, ctx) {
13
13
  const radioValue = ctx.args.value ?? '';
@@ -5,15 +5,20 @@ defineMacro({
5
5
  name: 'watch',
6
6
  // {watch "condition" goto "X" dialog "X" run "X" name "X" priority N once}
7
7
  parameters: [
8
- { name: 'condition', type: 'string', required: true },
8
+ {
9
+ name: 'condition',
10
+ type: 'string',
11
+ holds: 'expression',
12
+ required: true,
13
+ },
9
14
  {
10
15
  name: 'options',
11
16
  type: 'options',
12
17
  parameters: [
13
- { name: 'goto', type: 'string' },
14
- { name: 'dialog', type: 'string' },
15
- { name: 'run', type: 'string' },
16
- { name: 'name', type: 'string' },
18
+ { name: 'goto', type: 'string', holds: 'passage' },
19
+ { name: 'dialog', type: 'string', holds: 'passage' },
20
+ { name: 'run', type: 'string', holds: 'statements' },
21
+ { name: 'name', type: 'string', holds: 'text' },
17
22
  { name: 'priority', type: 'number' },
18
23
  { name: 'once', type: 'flag' },
19
24
  ],
@@ -41,7 +46,7 @@ defineMacro({
41
46
 
42
47
  defineMacro({
43
48
  name: 'unwatch',
44
- parameters: [{ name: 'name', type: 'text', required: true }],
49
+ parameters: [{ name: 'name', type: 'text', holds: 'text', required: true }],
45
50
  render(_props, ctx) {
46
51
  const name = ctx.args.name ?? '';
47
52
 
@@ -69,43 +69,6 @@ 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
-
109
72
  /**
110
73
  * Strip an optional quote from each end of an unquoted-or-loosely-quoted
111
74
  * argument (`"Red`, `Red'`), leaving at least one character. This is the
@@ -46,7 +46,10 @@ export function defineInputMacro(
46
46
  defineMacro({
47
47
  name,
48
48
  storeVar: true,
49
- parameters: [VARIABLE_PARAMETER, { name: 'placeholder', type: 'string' }],
49
+ parameters: [
50
+ VARIABLE_PARAMETER,
51
+ { name: 'placeholder', type: 'string', holds: 'text' },
52
+ ],
50
53
  render(_props, ctx) {
51
54
  const placeholder = ctx.args.placeholder ?? '';
52
55