@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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +638 -414
- package/dist/pkg/macro-registry.json +2 -1
- package/dist/pkg/story-variables.js +611 -157
- package/dist/pkg/tooling.js +39 -2
- package/package.json +1 -1
- package/src/code-check.ts +129 -21
- package/src/components/macros/Include.tsx +2 -6
- package/src/components/macros/MacroLink.tsx +33 -7
- package/src/components/macros/arg-utils.ts +37 -0
- package/src/components/macros/include-args.ts +23 -0
- package/src/components/macros/macro-args.ts +61 -5
- package/src/components/macros/option-utils.ts +17 -6
- package/src/define-macro.ts +22 -2
- package/src/expression.ts +4 -54
- package/src/markup/grammar-shared.ts +8 -0
- package/src/markup/parse.ts +89 -2
- package/src/markup/spindle.d.peggy.ts +5 -2
- package/src/markup/spindle.peggy +6 -7
- package/src/markup/validate.ts +16 -3
- package/src/registry.ts +7 -0
- package/src/story-variables.ts +48 -10
- package/src/tooling.ts +59 -0
- package/src/transform.ts +65 -0
- package/types/tooling.d.ts +311 -0
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 {
|
|
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
|
-
|
|
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 };
|
package/src/markup/parse.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/src/markup/spindle.peggy
CHANGED
|
@@ -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
|
|
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
|
|
package/src/markup/validate.ts
CHANGED
|
@@ -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
|
-
/**
|
|
70
|
-
|
|
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 =
|
|
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
|
}
|
package/src/story-variables.ts
CHANGED
|
@@ -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(
|
|
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
|
|
394
|
-
if (name
|
|
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(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
}
|
package/src/transform.ts
ADDED
|
@@ -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
|
+
}
|