@rohal12/spindle 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/expression.ts CHANGED
@@ -2,13 +2,12 @@ import type { StoryState } from './store';
2
2
  import { useStoryStore } from './store';
3
3
  import type { Passage } from './parser';
4
4
  import { random, randomInt } from './prng';
5
- import { parseCode, type JsGoal, type Sigil } from './js-lexer';
5
+ import { clearTransformCache, transform } from './transform';
6
6
  import {
7
7
  EMPTY_NAMESPACE,
8
8
  asNamespace,
9
9
  countOf,
10
10
  isNamespace,
11
- variableNameError,
12
11
  type Counts,
13
12
  type Namespace,
14
13
  } from './utils/namespace';
@@ -36,60 +35,11 @@ type CompiledExpression = (
36
35
  transient: Record<string, unknown>,
37
36
  ) => unknown;
38
37
 
38
+ export { transform };
39
+
39
40
  const FN_CACHE_MAX = 500;
40
41
  const fnCache = new Map<string, CompiledExpression>();
41
42
 
42
- const NAMESPACES: Record<Sigil, string> = {
43
- $: 'variables',
44
- _: 'temporary',
45
- '@': 'locals',
46
- '%': 'transient',
47
- };
48
-
49
- /** Ends with an identifier character. */
50
- const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
51
-
52
- /**
53
- * Transform expression: $var → variables["var"], _var → temporary["var"],
54
- * @var → locals["var"], %var → transient["var"].
55
- * The lexer (`lexJs`) finds the references: only in code (string, template
56
- * and regex literal text and comments are left untouched), only where an
57
- * identifier starts (not in `a$b`, nor as the property name in `obj._x`),
58
- * and `%var` only where an operand is expected, since `%` is also the
59
- * modulo operator. `goal` tells whether `expr` is an expression or a list of
60
- * statements, as that decides whether a leading `{` opens an object literal
61
- * or a block. A reference to a variable named `__proto__` throws a
62
- * SyntaxError: no namespace can hold one (see utils/namespace.ts).
63
- * Exported for tests.
64
- */
65
- export function transform(expr: string, goal: JsGoal = 'expression'): string {
66
- const key = goal === 'statements' ? 's' + expr : 'e' + expr;
67
- const cached = transformCache.get(key);
68
- if (cached !== undefined) return cached;
69
- const { refs } = parseCode(expr, goal);
70
- let result = '';
71
- let at = 0;
72
- for (const ref of refs) {
73
- const error = variableNameError(ref.name, ref.sigil + ref.name);
74
- if (error) throw new SyntaxError(`spindle: ${error}`);
75
- result += expr.slice(at, ref.start);
76
- if (ref.shorthand) result += `${ref.name}: `;
77
- // `typeof%x` needs a space once `%x` turns into an identifier.
78
- else if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
79
- result += `${NAMESPACES[ref.sigil]}["${ref.name}"]`;
80
- at = ref.end;
81
- }
82
- result += expr.slice(at);
83
- if (transformCache.size >= FN_CACHE_MAX) {
84
- transformCache.delete(transformCache.keys().next().value!);
85
- }
86
- transformCache.set(key, result);
87
- return result;
88
- }
89
-
90
- /** Transformed code by goal and source. */
91
- const transformCache = new Map<string, string>();
92
-
93
43
  const preamble =
94
44
  'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
95
45
 
@@ -269,7 +219,7 @@ export function execute(code: string, ...namespaces: Namespaces): void {
269
219
  /** Clear the compiled expression cache. Useful for testing and HMR. */
270
220
  export function clearExpressionCache(): void {
271
221
  fnCache.clear();
272
- transformCache.clear();
222
+ clearTransformCache();
273
223
  cachedFns = null;
274
224
  cachedVisitCounts = null;
275
225
  cachedRenderCounts = null;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What the grammar (spindle.peggy) imports from the rest of Spindle, so that
3
+ * it holds no second copy: the generated parser imports this module's
4
+ * default export as `shared` (see scripts/peggy.ts).
5
+ */
6
+ import { SIGIL_SCOPES, withSelectors } from './tokens';
7
+
8
+ export default { SIGIL_SCOPES, withSelectors };
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { parse as pegParse } from './spindle.peggy';
7
7
  import { isBlockMacro, type ASTNode } from './ast';
8
- import type { Token } from './tokens';
8
+ import type { Selectors, Token } from './tokens';
9
9
  import { isCodeAttribute } from './code-attributes';
10
10
  import { defaultCodeEnd, type CodeEnd } from './code-end';
11
11
 
@@ -74,7 +74,7 @@ export function lineColumn(
74
74
 
75
75
  function run(
76
76
  source: string,
77
- startRule: 'Markup' | 'Tokens',
77
+ startRule: 'Markup' | 'Tokens' | 'SelectorsPrefix',
78
78
  options: ParseMarkupOptions,
79
79
  ): unknown {
80
80
  const hooks = options.hooks
@@ -116,3 +116,90 @@ export function tokenizeMarkup(
116
116
  ): Token[] {
117
117
  return run(source, 'Tokens', options) as Token[];
118
118
  }
119
+
120
+ /**
121
+ * The `.class#id` selectors that start `source` at `at`, with the index just
122
+ * past them and the one space that may follow (`end`; `at` if there are
123
+ * none). A selector name may hold `{$name}` interpolations, as in markup.
124
+ */
125
+ export function parseSelectors(
126
+ source: string,
127
+ at = 0,
128
+ ): Selectors & { end: number } {
129
+ const found = run(source.slice(at), 'SelectorsPrefix', {}) as Selectors & {
130
+ end: number;
131
+ };
132
+ return { ...found, end: at + found.end };
133
+ }
134
+
135
+ /** The tokens of markup that may be malformed, and its errors. */
136
+ export interface TolerantTokens {
137
+ tokens: Token[];
138
+ /** One for each malformed tag, in source order, with offsets in the source. */
139
+ errors: MarkupError[];
140
+ }
141
+
142
+ /**
143
+ * `tokenizeMarkup` for half-typed markup: the tokens it can read, and every
144
+ * error, instead of throwing at the first. At a malformed tag, the tokens
145
+ * before it are kept, its first character is read as text and tokenizing
146
+ * resumes after it, so what follows is read as if the tag had not been
147
+ * started. Tokens and errors have offsets in `source`; for well-formed
148
+ * markup the tokens are those of `tokenizeMarkup` and there are no errors.
149
+ */
150
+ export function tokenizeMarkupTolerant(
151
+ source: string,
152
+ options: ParseMarkupOptions = {},
153
+ ): TolerantTokens {
154
+ const tokens: Token[] = [];
155
+ const errors: MarkupError[] = [];
156
+ /** `text` shifted by `base`, tokenized: the tokens or the error. */
157
+ const attempt = (text: string, base: number): Token[] | MarkupError => {
158
+ try {
159
+ return tokenizeMarkup(text, options).map((token) => shift(token, base));
160
+ } catch (error) {
161
+ if (!(error instanceof MarkupError)) throw error;
162
+ return error;
163
+ }
164
+ };
165
+ let base = 0;
166
+ while (base <= source.length) {
167
+ const rest = source.slice(base);
168
+ const result = attempt(rest, base);
169
+ if (Array.isArray(result)) {
170
+ tokens.push(...result);
171
+ break;
172
+ }
173
+ const at = base + result.offset;
174
+ const { line, column } = lineColumn(source, at);
175
+ errors.push(new MarkupError(result.reason, at, line, column));
176
+ // The longest prefix that tokenizes: the error may be inside a tag
177
+ // (an attribute value) of which the prefix is itself malformed
178
+ let cut = result.offset;
179
+ let prefix = attempt(rest.slice(0, cut), base);
180
+ while (!Array.isArray(prefix)) {
181
+ cut = prefix.offset;
182
+ prefix = attempt(rest.slice(0, cut), base);
183
+ }
184
+ tokens.push(...prefix);
185
+ // The character that started the damage is text
186
+ if (cut < rest.length) {
187
+ const start = base + cut;
188
+ tokens.push({
189
+ type: 'text',
190
+ value: rest[cut]!,
191
+ start,
192
+ end: start + 1,
193
+ });
194
+ }
195
+ base += cut + 1;
196
+ }
197
+ return { tokens, errors };
198
+ }
199
+
200
+ /** `token` with its offsets moved `by` on. */
201
+ function shift(token: Token, by: number): Token {
202
+ return by === 0
203
+ ? token
204
+ : { ...token, start: token.start + by, end: token.end + by };
205
+ }
@@ -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;
@@ -18,13 +18,14 @@
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']);
@@ -47,12 +48,6 @@ function quoteArg(value) {
47
48
  return '"' + value.replace(/[\\"]/g, '\\$&') + '"';
48
49
  }
49
50
 
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
51
  function selectorsOf(parts) {
57
52
  const classes = [];
58
53
  let id = '';
@@ -359,6 +354,10 @@ Selectors
359
354
  = parts:SelectorPart+ " "? { return selectorsOf(parts); }
360
355
  / "" { return NO_SELECTORS; }
361
356
 
357
+ /** The selectors at the start of the input, and where they end. */
358
+ SelectorsPrefix
359
+ = s:Selectors end:Here .* { return { ...s, end }; }
360
+
362
361
  SelectorPart
363
362
  = prefix:[.#] name:$SelectorChar* { return [prefix, name]; }
364
363
 
@@ -11,6 +11,7 @@ import { parseWidgetDef } from '../widgets/widget-def';
11
11
  import { codeAndText, parseOrError, withParseCache } from '../code-check';
12
12
  import { CodeSyntaxError } from '../js-lexer';
13
13
  import type { ParameterDef } from '../registry';
14
+ import { subMacroParameters } from '../components/macros/option-utils';
14
15
 
15
16
  /** A passage to validate. */
16
17
  export interface MarkupPassage {
@@ -66,8 +67,15 @@ export interface MarkupValidationOptions {
66
67
  parametersOf?(name: string): readonly ParameterDef[] | undefined;
67
68
  }
68
69
 
69
- /** Passages that hold no markup. */
70
- const NOT_MARKUP = new Set(['StoryVariables', 'StoryTransients']);
70
+ /**
71
+ * Passages that hold no markup: declarations, and the JavaScript function
72
+ * body of SaveTitle.
73
+ */
74
+ export const NOT_MARKUP = new Set([
75
+ 'StoryVariables',
76
+ 'StoryTransients',
77
+ 'SaveTitle',
78
+ ]);
71
79
  const NOT_MARKUP_TAGS = ['script', 'stylesheet'];
72
80
 
73
81
  /** Branch macros, which their parent macro renders. */
@@ -220,7 +228,8 @@ export function validateMarkup(
220
228
  }
221
229
  };
222
230
 
223
- const parametersOf = options.parametersOf ?? (() => undefined);
231
+ const parametersOf = (name: string) =>
232
+ options.parametersOf?.(name) ?? subMacroParameters(name);
224
233
 
225
234
  /**
226
235
  * Check `tokens`, the tokens of `src`, which starts at `base` in the
@@ -236,6 +245,10 @@ export function validateMarkup(
236
245
  checkMacros(passage, tokens, base, where);
237
246
  for (const piece of codeAndText(src, tokens, parametersOf)) {
238
247
  const at = base + piece.offset;
248
+ if (piece.kind === 'argument-error') {
249
+ report(passage, at, `${where}${piece.message} in ${piece.label}`);
250
+ continue;
251
+ }
239
252
  if (piece.kind === 'passage') {
240
253
  if (
241
254
  options.checkPassageNames !== false &&
package/src/registry.ts CHANGED
@@ -178,6 +178,13 @@ export function registerMacroMetadata(
178
178
  metadataRegistry.set(name, metadata);
179
179
  }
180
180
 
181
+ /** The declared parameters of the macro `name`, if it declares any. */
182
+ export function getMacroParameters(
183
+ name: string,
184
+ ): readonly ParameterDef[] | undefined {
185
+ return metadataRegistry.get(name)?.parameters;
186
+ }
187
+
181
188
  export function getMacroRegistry(): MacroMetadata[] {
182
189
  return Array.from(metadataRegistry.values());
183
190
  }
@@ -9,7 +9,9 @@ import {
9
9
  scanStringLiteral,
10
10
  type JsGoal,
11
11
  } from './js-lexer';
12
- import { parseOrError, withParseCache } from './code-check';
12
+ import { argPieces, parseOrError, withParseCache } from './code-check';
13
+ import { getMacroParameters, type ParameterDef } from './registry';
14
+ import { NOT_MARKUP } from './markup/validate';
13
15
  import { createNamespace, variableNameError } from './utils/namespace';
14
16
 
15
17
  /**
@@ -319,18 +321,47 @@ function collectPassageRefs(
319
321
  content: string,
320
322
  storeVarMacros: ReadonlySet<string>,
321
323
  onRef: RefCallback,
324
+ parametersOf: ParametersOf,
322
325
  ): void {
323
326
  withParseCache(() =>
324
- collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef),
327
+ collectTokenRefs(
328
+ content,
329
+ tokensOf(content, false),
330
+ storeVarMacros,
331
+ onRef,
332
+ parametersOf,
333
+ ),
325
334
  );
326
335
  }
327
336
 
337
+ /** The declared parameters of a macro, if it has any. */
338
+ type ParametersOf = (macro: string) => readonly ParameterDef[] | undefined;
339
+
340
+ /**
341
+ * Report the references in the code that a macro's quoted arguments hold,
342
+ * the condition and `run` action of `{watch}`: the argument scan sees only
343
+ * the strings.
344
+ */
345
+ function scanStringCode(
346
+ token: Extract<Token, { type: 'macro' }>,
347
+ params: readonly ParameterDef[] | undefined,
348
+ onRef: RefCallback,
349
+ ): void {
350
+ if (!params || !token.rawArgs) return;
351
+ for (const piece of argPieces(token.rawArgs, 0, params, token.name, '')) {
352
+ if (piece.kind === 'code' && piece.inString) {
353
+ scanCode(piece.code, onRef, piece.goal);
354
+ }
355
+ }
356
+ }
357
+
328
358
  /** Report the `$var` references in the tokens of `content`. */
329
359
  function collectTokenRefs(
330
360
  content: string,
331
361
  tokens: Token[],
332
362
  storeVarMacros: ReadonlySet<string>,
333
363
  onRef: RefCallback,
364
+ parametersOf: ParametersOf = () => undefined,
334
365
  ): void {
335
366
  for (let t = 0; t < tokens.length; t++) {
336
367
  const token = tokens[t]!;
@@ -345,6 +376,7 @@ function collectTokenRefs(
345
376
  }
346
377
  } else if (token.type === 'macro' && !token.isClose) {
347
378
  scanCode(token.rawArgs, onRef);
379
+ scanStringCode(token, parametersOf(token.name), onRef);
348
380
 
349
381
  if (storeVarMacros.has(token.name.toLowerCase())) {
350
382
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
@@ -383,6 +415,7 @@ export function validatePassages(
383
415
  passages: Map<string, Passage>,
384
416
  schema: Map<string, VariableSchema>,
385
417
  storeVarMacros: Iterable<string> = BUILTIN_STORE_VAR_MACROS,
418
+ parametersOf: ParametersOf = getMacroParameters,
386
419
  ): string[] {
387
420
  const errors: string[] = [];
388
421
  const storeVarSet = new Set(
@@ -390,16 +423,21 @@ export function validatePassages(
390
423
  );
391
424
 
392
425
  for (const [name, passage] of passages) {
393
- // Don't validate the StoryVariables/StoryTransients passages themselves
394
- if (name === 'StoryVariables' || name === 'StoryTransients') continue;
426
+ // Don't validate the declarations or the SaveTitle code themselves
427
+ if (NOT_MARKUP.has(name)) continue;
395
428
 
396
429
  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
- });
430
+ collectPassageRefs(
431
+ passage.content,
432
+ storeVarSet,
433
+ (ref) => {
434
+ const error = validateRef(ref, schema, forLocals);
435
+ if (error) {
436
+ errors.push(`Passage "${name}": ${error}`);
437
+ }
438
+ },
439
+ parametersOf,
440
+ );
403
441
  }
404
442
 
405
443
  return errors;
package/src/tooling.ts CHANGED
@@ -10,12 +10,50 @@ 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 { collectPassageReferences, type PassageReference } from './code-check';
15
+ import { subMacroParameters } from './components/macros/option-utils';
13
16
  import { blockWidgetNames } from './widgets/widget-def';
14
17
  import type { ParameterDef } from './registry';
15
18
 
16
19
  export { parseStoryVariables } from './story-variables';
17
20
  export { checkParameterTypes } from './registry';
18
21
  export { formatDiagnostic } from './markup/validate';
22
+
23
+ // The parsing rules (see "Tooling API" in docs/tooling.md): the leaf rules
24
+ // the runtime parses with, so editor tooling needn't mirror them.
25
+ export { findCodeEnd, lexJs, lexTemplate, scanStringLiteral } from './js-lexer';
26
+ export type {
27
+ FindCodeEndOptions,
28
+ JsGoal,
29
+ JsLexHandlers,
30
+ Sigil,
31
+ } from './js-lexer';
32
+ export {
33
+ MarkupError,
34
+ parseSelectors,
35
+ tokenizeMarkup,
36
+ tokenizeMarkupTolerant,
37
+ } from './markup/parse';
38
+ export type { ParseMarkupOptions, TolerantTokens } from './markup/parse';
39
+ export { SIGIL_SCOPES, isSigil } from './markup/tokens';
40
+ export type { Selectors, Token, VariableScope } from './markup/tokens';
41
+ export {
42
+ endsWithOperator,
43
+ readQuoted,
44
+ splitArgs,
45
+ splitTopLevel,
46
+ stripLooseQuotes,
47
+ unescapeQuoted,
48
+ } from './components/macros/arg-utils';
49
+ export { splitIncludeFlag } from './components/macros/include-args';
50
+ export {
51
+ evaluatePassageName,
52
+ passageTarget,
53
+ } from './components/macros/macro-args';
54
+ export type { PassageTarget } from './components/macros/macro-args';
55
+ export { transform } from './transform';
56
+ export type { PassageReference } from './code-check';
19
57
  export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
20
58
 
21
59
  /** What tooling knows about a macro (see MacroMetadata). */
@@ -68,3 +106,24 @@ export function validateStoryMarkup(
68
106
  checkPassageNames: options.checkPassageNames,
69
107
  });
70
108
  }
109
+
110
+ /**
111
+ * The passages the markup of `source` names (see PassageReference), against
112
+ * the given macros: what the story-start check looks up. Malformed tags are
113
+ * skipped, so it reads half-typed markup.
114
+ */
115
+ export function collectStoryPassageReferences(
116
+ source: string,
117
+ macros: Iterable<ToolingMacro>,
118
+ ): PassageReference[] {
119
+ const parameters = new Map<string, readonly ParameterDef[] | undefined>();
120
+ for (const macro of macros) {
121
+ parameters.set(macro.name.toLowerCase(), macro.parameters);
122
+ }
123
+ const { tokens } = tokenizeMarkupTolerant(source);
124
+ return collectPassageReferences(
125
+ source,
126
+ tokens,
127
+ (name) => parameters.get(name.toLowerCase()) ?? subMacroParameters(name),
128
+ );
129
+ }
@@ -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
+ }