@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,41 +18,48 @@
18
18
  // isBlock(name), isRaw(name) whether a macro takes a body / a raw body
19
19
  // isCodeAttribute(name) whether an attribute value is code (onclick)
20
20
  //
21
+ // SIGIL_SCOPES and withSelectors come from tokens.ts (see grammar-shared.ts)
21
22
  // Note: Peggy's code blocks count braces without reading JavaScript strings,
22
23
  // so the code here writes literal braces as LB and RB.
23
24
 
24
25
  {{
25
26
  const LB = String.fromCharCode(123);
26
27
  const RB = String.fromCharCode(125);
27
- const SIGIL_SCOPES = { $: 'variable', _: 'temporary', '@': 'local', '%': 'transient' };
28
+ const { SIGIL_SCOPES, withSelectors } = shared;
28
29
  const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
29
30
  const BRANCH_PARENT = { elseif: 'if', else: 'if', case: 'switch', default: 'switch', next: 'timed' };
30
31
  const BRANCHING = new Set(['if', 'switch', 'timed']);
31
32
  const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
32
33
  const NO_SELECTORS = {};
33
34
 
34
- 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
+ };
35
47
  for (const [separator, targetFirst] of LINK_SEPARATORS) {
36
48
  const idx = inner.indexOf(separator);
37
49
  if (idx === -1) continue;
38
- const before = inner.slice(0, idx).trim();
39
- const after = inner.slice(idx + separator.length).trim();
40
- 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 };
41
54
  }
42
- const trimmed = inner.trim();
43
- 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 };
44
57
  }
45
58
 
46
59
  function quoteArg(value) {
47
60
  return '"' + value.replace(/[\\"]/g, '\\$&') + '"';
48
61
  }
49
62
 
50
- function withSelectors(target, from) {
51
- if (from.className) target.className = from.className;
52
- if (from.id) target.id = from.id;
53
- return target;
54
- }
55
-
56
63
  function selectorsOf(parts) {
57
64
  const classes = [];
58
65
  let id = '';
@@ -111,7 +118,7 @@ function tokenOf(x) {
111
118
  case 'text':
112
119
  return { type: 'text', value: x.value, ...span };
113
120
  case 'link':
114
- 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);
115
122
  case 'variable':
116
123
  return withSelectors({ type: 'variable', name: x.name, scope: x.scope, ...span }, x);
117
124
  case 'expression':
@@ -341,8 +348,8 @@ Literal
341
348
  / "[" { return lexeme('text', location(), { value: '[' }); }
342
349
 
343
350
  LinkLexeme
344
- = &{ return !textMode; } "[[" s:Selectors inner:$LinkText "]]" {
345
- 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);
346
353
  }
347
354
  / &{ return !textMode; } "[[" { fail('Unclosed link: [[ without ]]', location()); }
348
355
 
@@ -359,6 +366,10 @@ Selectors
359
366
  = parts:SelectorPart+ " "? { return selectorsOf(parts); }
360
367
  / "" { return NO_SELECTORS; }
361
368
 
369
+ /** The selectors at the start of the input, and where they end. */
370
+ SelectorsPrefix
371
+ = s:Selectors end:Here .* { return { ...s, end }; }
372
+
362
373
  SelectorPart
363
374
  = prefix:[.#] name:$SelectorChar* { return [prefix, name]; }
364
375
 
@@ -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 { SUB_MACRO_PARAMETERS } from '../components/macros/option-utils';
15
18
 
16
19
  /** A passage to validate. */
17
20
  export interface MarkupPassage {
@@ -59,16 +62,24 @@ 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
- /** Passages that hold no markup. */
71
- const NOT_MARKUP = new Set(['StoryVariables', 'StoryTransients']);
74
+ /**
75
+ * Passages that hold no markup: declarations, and the JavaScript function
76
+ * body of SaveTitle.
77
+ */
78
+ export const NOT_MARKUP = new Set([
79
+ 'StoryVariables',
80
+ 'StoryTransients',
81
+ 'SaveTitle',
82
+ ]);
72
83
  const NOT_MARKUP_TAGS = ['script', 'stylesheet'];
73
84
 
74
85
  /** Branch macros, which their parent macro renders. */
@@ -221,8 +232,7 @@ export function validateMarkup(
221
232
  }
222
233
  };
223
234
 
224
- const parametersOf = (name: string) =>
225
- options.parametersOf?.(name) ?? SUB_MACRO_PARAMETERS[name.toLowerCase()];
235
+ const parametersOf: ParametersOf = options.parametersOf ?? (() => undefined);
226
236
 
227
237
  /**
228
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'
@@ -178,6 +235,13 @@ export function registerMacroMetadata(
178
235
  metadataRegistry.set(name, metadata);
179
236
  }
180
237
 
238
+ /** The declared parameters of the macro `name`, if it declares any. */
239
+ export function getMacroParameters(
240
+ name: string,
241
+ ): readonly ParameterDef[] | undefined {
242
+ return metadataRegistry.get(name)?.parameters;
243
+ }
244
+
181
245
  export function getMacroRegistry(): MacroMetadata[] {
182
246
  return Array.from(metadataRegistry.values());
183
247
  }
@@ -9,7 +9,15 @@ import {
9
9
  scanStringLiteral,
10
10
  type JsGoal,
11
11
  } from './js-lexer';
12
- import { parseOrError, withParseCache } from './code-check';
12
+ import {
13
+ argPieces,
14
+ holdsCode,
15
+ parseOrError,
16
+ withParseCache,
17
+ type ParametersOf,
18
+ } from './code-check';
19
+ import { getMacroParameters, type ParameterDef } from './registry';
20
+ import { NOT_MARKUP } from './markup/validate';
13
21
  import { createNamespace, variableNameError } from './utils/namespace';
14
22
 
15
23
  /**
@@ -319,18 +327,45 @@ function collectPassageRefs(
319
327
  content: string,
320
328
  storeVarMacros: ReadonlySet<string>,
321
329
  onRef: RefCallback,
330
+ parametersOf: ParametersOf,
322
331
  ): void {
323
332
  withParseCache(() =>
324
- collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef),
333
+ collectTokenRefs(
334
+ content,
335
+ tokensOf(content, false),
336
+ storeVarMacros,
337
+ onRef,
338
+ parametersOf,
339
+ ),
325
340
  );
326
341
  }
327
342
 
343
+ /**
344
+ * Report the references in the code that a macro's quoted arguments hold
345
+ * (see ParameterDef.holds), the condition and `run` action of `{watch}`:
346
+ * the argument scan sees only the strings. Only the macros that declare
347
+ * code in a string have their arguments read.
348
+ */
349
+ function scanStringCode(
350
+ token: Extract<Token, { type: 'macro' }>,
351
+ params: readonly ParameterDef[] | undefined,
352
+ onRef: RefCallback,
353
+ ): void {
354
+ if (!params || !token.rawArgs || !holdsCode(params)) return;
355
+ for (const piece of argPieces(token.rawArgs, 0, params, token.name, '')) {
356
+ if (piece.kind === 'code' && piece.inString) {
357
+ scanCode(piece.code, onRef, piece.goal);
358
+ }
359
+ }
360
+ }
361
+
328
362
  /** Report the `$var` references in the tokens of `content`. */
329
363
  function collectTokenRefs(
330
364
  content: string,
331
365
  tokens: Token[],
332
366
  storeVarMacros: ReadonlySet<string>,
333
367
  onRef: RefCallback,
368
+ parametersOf: ParametersOf = () => undefined,
334
369
  ): void {
335
370
  for (let t = 0; t < tokens.length; t++) {
336
371
  const token = tokens[t]!;
@@ -345,6 +380,7 @@ function collectTokenRefs(
345
380
  }
346
381
  } else if (token.type === 'macro' && !token.isClose) {
347
382
  scanCode(token.rawArgs, onRef);
383
+ scanStringCode(token, parametersOf(token.name), onRef);
348
384
 
349
385
  if (storeVarMacros.has(token.name.toLowerCase())) {
350
386
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
@@ -383,6 +419,7 @@ export function validatePassages(
383
419
  passages: Map<string, Passage>,
384
420
  schema: Map<string, VariableSchema>,
385
421
  storeVarMacros: Iterable<string> = BUILTIN_STORE_VAR_MACROS,
422
+ parametersOf: ParametersOf = getMacroParameters,
386
423
  ): string[] {
387
424
  const errors: string[] = [];
388
425
  const storeVarSet = new Set(
@@ -390,16 +427,21 @@ export function validatePassages(
390
427
  );
391
428
 
392
429
  for (const [name, passage] of passages) {
393
- // Don't validate the StoryVariables/StoryTransients passages themselves
394
- if (name === 'StoryVariables' || name === 'StoryTransients') continue;
430
+ // Don't validate the declarations or the SaveTitle code themselves
431
+ if (NOT_MARKUP.has(name)) continue;
395
432
 
396
433
  const forLocals = extractForLocals(passage.content);
397
- collectPassageRefs(passage.content, storeVarSet, (ref) => {
398
- const error = validateRef(ref, schema, forLocals);
399
- if (error) {
400
- errors.push(`Passage "${name}": ${error}`);
401
- }
402
- });
434
+ collectPassageRefs(
435
+ passage.content,
436
+ storeVarSet,
437
+ (ref) => {
438
+ const error = validateRef(ref, schema, forLocals);
439
+ if (error) {
440
+ errors.push(`Passage "${name}": ${error}`);
441
+ }
442
+ },
443
+ parametersOf,
444
+ );
403
445
  }
404
446
 
405
447
  return errors;
package/src/tooling.ts CHANGED
@@ -10,12 +10,53 @@ import {
10
10
  type MarkupPassage,
11
11
  } from './markup/validate';
12
12
  import { isBlockMacro } from './markup/ast';
13
+ import { tokenizeMarkupTolerant } from './markup/parse';
14
+ import {
15
+ collectPassageReferences,
16
+ parameterLookup,
17
+ type PassageReference,
18
+ } from './code-check';
13
19
  import { blockWidgetNames } from './widgets/widget-def';
14
20
  import type { ParameterDef } from './registry';
15
21
 
16
22
  export { parseStoryVariables } from './story-variables';
17
23
  export { checkParameterTypes } from './registry';
18
24
  export { formatDiagnostic } from './markup/validate';
25
+
26
+ // The parsing rules (see "Tooling API" in docs/tooling.md): the leaf rules
27
+ // the runtime parses with, so editor tooling needn't mirror them.
28
+ export { findCodeEnd, lexJs, lexTemplate, scanStringLiteral } from './js-lexer';
29
+ export type {
30
+ FindCodeEndOptions,
31
+ JsGoal,
32
+ JsLexHandlers,
33
+ Sigil,
34
+ } from './js-lexer';
35
+ export {
36
+ MarkupError,
37
+ parseSelectors,
38
+ tokenizeMarkup,
39
+ tokenizeMarkupTolerant,
40
+ } from './markup/parse';
41
+ export type { ParseMarkupOptions, TolerantTokens } from './markup/parse';
42
+ export { SIGIL_SCOPES, isSigil } from './markup/tokens';
43
+ export type { Selectors, Token, VariableScope } from './markup/tokens';
44
+ export {
45
+ endsWithOperator,
46
+ readQuoted,
47
+ splitArgs,
48
+ splitTopLevel,
49
+ stripLooseQuotes,
50
+ unescapeQuoted,
51
+ } from './components/macros/arg-utils';
52
+ export { splitIncludeFlag } from './components/macros/include-args';
53
+ export {
54
+ evaluatePassageName,
55
+ passageTarget,
56
+ } from './components/macros/macro-args';
57
+ export type { PassageTarget } from './components/macros/macro-args';
58
+ export { transform } from './transform';
59
+ export type { PassageReference } from './code-check';
19
60
  export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
20
61
 
21
62
  /** What tooling knows about a macro (see MacroMetadata). */
@@ -33,8 +74,13 @@ export interface ToolingMacro {
33
74
  name: string;
34
75
  block: boolean;
35
76
  subMacros: string[];
36
- /** Its declared parameters: their types tell which arguments are code. */
77
+ /**
78
+ * Its declared parameters: their types tell which arguments are code, and
79
+ * what a `string` or `text` argument holds (see ParameterDef.holds).
80
+ */
37
81
  parameters?: readonly ParameterDef[];
82
+ /** Whether it resolves markup: its strings hold markup by default. */
83
+ interpolate?: boolean;
38
84
  }
39
85
 
40
86
  /**
@@ -49,22 +95,34 @@ export function validateStoryMarkup(
49
95
  options: ValidateMarkupOptions = {},
50
96
  ): MarkupDiagnostic[] {
51
97
  const list = [...passages];
98
+ const all = [...macros];
52
99
  const known = new Set<string>();
53
- const parameters = new Map<string, readonly ParameterDef[] | undefined>();
54
100
  const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
55
- for (const macro of macros) {
101
+ for (const macro of all) {
56
102
  const name = macro.name.toLowerCase();
57
103
  known.add(name);
58
- parameters.set(name, macro.parameters);
59
104
  for (const sub of macro.subMacros) known.add(sub.toLowerCase());
60
105
  if (macro.block) blocks.add(name);
61
106
  }
62
107
  return validateMarkup(list, {
63
108
  isKnownMacro: (name) => known.has(name),
64
109
  macroNames: known,
65
- parametersOf: (name) => parameters.get(name),
110
+ parametersOf: parameterLookup(all),
66
111
  isBlockMacro: (name) =>
67
112
  blocks.has(name.toLowerCase()) || isBlockMacro(name),
68
113
  checkPassageNames: options.checkPassageNames,
69
114
  });
70
115
  }
116
+
117
+ /**
118
+ * The passages the markup of `source` names (see PassageReference), against
119
+ * the given macros: what the story-start check looks up. Malformed tags are
120
+ * skipped, so it reads half-typed markup.
121
+ */
122
+ export function collectStoryPassageReferences(
123
+ source: string,
124
+ macros: Iterable<ToolingMacro>,
125
+ ): PassageReference[] {
126
+ const { tokens } = tokenizeMarkupTolerant(source);
127
+ return collectPassageReferences(source, tokens, parameterLookup(macros));
128
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The sigil transform: the story's code with its variable references
3
+ * (`$var`, `_var`, `@var`, `%var`) turned into the namespace lookups the
4
+ * expression engine runs. Pure string-to-string, so tooling can use it
5
+ * (see src/tooling.ts).
6
+ */
7
+ import { parseCode, type JsGoal, type Sigil } from './js-lexer';
8
+ import { variableNameError } from './utils/namespace';
9
+
10
+ const TRANSFORM_CACHE_MAX = 500;
11
+
12
+ const NAMESPACES: Record<Sigil, string> = {
13
+ $: 'variables',
14
+ _: 'temporary',
15
+ '@': 'locals',
16
+ '%': 'transient',
17
+ };
18
+
19
+ /** Ends with an identifier character. */
20
+ const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
21
+
22
+ /**
23
+ * Transform expression: $var → variables["var"], _var → temporary["var"],
24
+ * @var → locals["var"], %var → transient["var"].
25
+ * The lexer (`lexJs`) finds the references: only in code (string, template
26
+ * and regex literal text and comments are left untouched), only where an
27
+ * identifier starts (not in `a$b`, nor as the property name in `obj._x`),
28
+ * and `%var` only where an operand is expected, since `%` is also the
29
+ * modulo operator. `goal` tells whether `expr` is an expression or a list of
30
+ * statements, as that decides whether a leading `{` opens an object literal
31
+ * or a block. A reference to a variable named `__proto__` throws a
32
+ * SyntaxError: no namespace can hold one (see utils/namespace.ts).
33
+ * */
34
+ export function transform(expr: string, goal: JsGoal = 'expression'): string {
35
+ const key = goal === 'statements' ? 's' + expr : 'e' + expr;
36
+ const cached = transformCache.get(key);
37
+ if (cached !== undefined) return cached;
38
+ const { refs } = parseCode(expr, goal);
39
+ let result = '';
40
+ let at = 0;
41
+ for (const ref of refs) {
42
+ const error = variableNameError(ref.name, ref.sigil + ref.name);
43
+ if (error) throw new SyntaxError(`spindle: ${error}`);
44
+ result += expr.slice(at, ref.start);
45
+ if (ref.shorthand) result += `${ref.name}: `;
46
+ // `typeof%x` needs a space once `%x` turns into an identifier.
47
+ else if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
48
+ result += `${NAMESPACES[ref.sigil]}["${ref.name}"]`;
49
+ at = ref.end;
50
+ }
51
+ result += expr.slice(at);
52
+ if (transformCache.size >= TRANSFORM_CACHE_MAX) {
53
+ transformCache.delete(transformCache.keys().next().value!);
54
+ }
55
+ transformCache.set(key, result);
56
+ return result;
57
+ }
58
+
59
+ /** Transformed code by goal and source. */
60
+ const transformCache = new Map<string, string>();
61
+
62
+ /** Clear the transformed code. Useful for testing and HMR. */
63
+ export function clearTransformCache(): void {
64
+ transformCache.clear();
65
+ }
@@ -7,8 +7,8 @@ import type { MacroArgs } from '../registry';
7
7
 
8
8
  /** A {widget} definition's name, then its `@` parameters. */
9
9
  export const WIDGET_PARAMETERS = [
10
- { name: 'name', type: 'text', required: true },
11
- { name: 'parameters', type: 'text' },
10
+ { name: 'name', type: 'text', holds: 'text', required: true },
11
+ { name: 'parameters', type: 'text', holds: 'text' },
12
12
  ] as const;
13
13
 
14
14
  export interface WidgetDef {
package/types/index.d.ts CHANGED
@@ -272,6 +272,27 @@ export type ParameterType =
272
272
  | 'separator'
273
273
  | 'options';
274
274
 
275
+ /**
276
+ * What the value of a `string` or `text` argument holds, for the check at
277
+ * story start and for tooling:
278
+ * - `markup`: markup the macro renders (`{button}`'s label): its markup
279
+ * is checked.
280
+ * - `text`: plain text the macro uses as written (`{checkbox}`'s label).
281
+ * - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
282
+ * - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
283
+ * is checked as code, and its variable references against the schema.
284
+ *
285
+ * Without it, the argument of a macro with `interpolate` holds `markup`,
286
+ * any other `text`.
287
+ * @see {@link ../../src/registry.ts} for the implementation.
288
+ */
289
+ export type StringHolds =
290
+ | 'markup'
291
+ | 'text'
292
+ | 'passage'
293
+ | 'expression'
294
+ | 'statements';
295
+
275
296
  /**
276
297
  * Parameter metadata for a macro definition.
277
298
  * @see {@link ../../src/registry.ts} for the implementation.
@@ -282,6 +303,8 @@ export interface ParameterDef {
282
303
  description?: string;
283
304
  /** How the argument is read: required, there is no default. */
284
305
  type: ParameterType;
306
+ /** What a `string` or `text` argument holds (see StringHolds). */
307
+ holds?: StringHolds;
285
308
  /** The options of an `options` parameter. */
286
309
  parameters?: readonly ParameterDef[];
287
310
  }