@rohal12/spindle 0.53.0 → 0.55.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.
Files changed (44) hide show
  1. package/README.md +3 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +10058 -2075
  4. package/dist/pkg/macro-registry.json +4 -4
  5. package/dist/pkg/story-variables.js +10197 -1925
  6. package/dist/pkg/tooling.js +15 -1
  7. package/package.json +3 -1
  8. package/src/code-check.ts +314 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Do.tsx +1 -1
  13. package/src/components/macros/Goto.tsx +19 -8
  14. package/src/components/macros/Include.tsx +23 -11
  15. package/src/components/macros/Set.tsx +1 -1
  16. package/src/components/macros/VarDisplay.tsx +1 -1
  17. package/src/components/macros/Widget.tsx +5 -33
  18. package/src/components/macros/macro-args.ts +16 -7
  19. package/src/define-macro.ts +2 -0
  20. package/src/expression.ts +25 -20
  21. package/src/index.tsx +63 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/js-lexer.ts +981 -1342
  24. package/src/markup/ast.ts +11 -226
  25. package/src/markup/code-attributes.ts +3 -3
  26. package/src/markup/code-end.ts +121 -0
  27. package/src/markup/parse.ts +118 -0
  28. package/src/markup/render.tsx +1 -1
  29. package/src/markup/spindle.d.peggy.ts +24 -0
  30. package/src/markup/spindle.peggy +463 -0
  31. package/src/markup/tokens.ts +95 -0
  32. package/src/markup/validate.ts +281 -0
  33. package/src/parser.ts +19 -4
  34. package/src/registry.ts +49 -14
  35. package/src/runtime-errors.ts +10 -0
  36. package/src/store.ts +8 -2
  37. package/src/story-init.ts +3 -5
  38. package/src/story-variables.ts +49 -8
  39. package/src/tooling.ts +58 -0
  40. package/src/types-drift-check.ts +28 -1
  41. package/src/widgets/widget-def.ts +60 -0
  42. package/types/index.d.ts +7 -3
  43. package/types/tooling.d.ts +53 -3
  44. package/src/markup/tokenizer.ts +0 -1112
@@ -0,0 +1,463 @@
1
+ // Spindle passage markup, as a Peggy grammar. Imported as a module, it is
2
+ // compiled to its parser on load (scripts/peggy.ts); parse.ts wraps it.
3
+ //
4
+ // Start rules:
5
+ // Markup the nested AST (ast.ts): macros with their bodies and branches,
6
+ // HTML elements with their children, links, variables, text.
7
+ // Tokens the flat token stream (tokens.ts Token), without nesting:
8
+ // what the variable checker scans.
9
+ //
10
+ // options.text selects text mode (HTML attribute values, labels): no links
11
+ // or HTML, and a backslash run before a brace is paired up.
12
+ //
13
+ // options.hooks decides what the grammar leaves to code:
14
+ // closeBrace(input, codeStart, lenientStart) index of the } ending the
15
+ // JavaScript that starts at codeStart, or -1 (pluggable code end)
16
+ // rawBodyEnd(input, bodyStart, name) index of the { of the {/name} that
17
+ // ends a raw (JavaScript) body, or -1
18
+ // isBlock(name), isRaw(name) whether a macro takes a body / a raw body
19
+ // isCodeAttribute(name) whether an attribute value is code (onclick)
20
+ //
21
+ // Note: Peggy's code blocks count braces without reading JavaScript strings,
22
+ // so the code here writes literal braces as LB and RB.
23
+
24
+ {{
25
+ const LB = String.fromCharCode(123);
26
+ const RB = String.fromCharCode(125);
27
+ const SIGIL_SCOPES = { $: 'variable', _: 'temporary', '@': 'local', '%': 'transient' };
28
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
29
+ const BRANCH_PARENT = { elseif: 'if', else: 'if', case: 'switch', default: 'switch', next: 'timed' };
30
+ const BRANCHING = new Set(['if', 'switch', 'timed']);
31
+ const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
32
+ const NO_SELECTORS = {};
33
+
34
+ function parseLink(inner) {
35
+ for (const [separator, targetFirst] of LINK_SEPARATORS) {
36
+ const idx = inner.indexOf(separator);
37
+ 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 };
41
+ }
42
+ const trimmed = inner.trim();
43
+ return { display: trimmed, target: trimmed };
44
+ }
45
+
46
+ function quoteArg(value) {
47
+ return '"' + value.replace(/[\\"]/g, '\\$&') + '"';
48
+ }
49
+
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
+ function selectorsOf(parts) {
57
+ const classes = [];
58
+ let id = '';
59
+ for (const [prefix, name] of parts) {
60
+ if (!name) continue;
61
+ if (prefix === '.') classes.push(name);
62
+ else id = name;
63
+ }
64
+ return withSelectors({}, { className: classes.join(' '), id });
65
+ }
66
+
67
+ /** How a tag reads in an error message. */
68
+ function label(tag) {
69
+ if (tag.lex === 'html') return (tag.isClose ? '</' : '<') + tag.tag + '>';
70
+ return LB + (tag.isClose ? '/' : '') + tag.name + RB;
71
+ }
72
+ function closerOf(tag) {
73
+ return tag.lex === 'html' ? '</' + tag.tag + '>' : LB + '/' + tag.name + RB;
74
+ }
75
+ function at(loc) {
76
+ return 'line ' + loc.start.line + ', column ' + loc.start.column;
77
+ }
78
+ function sameName(open, close) {
79
+ return open.lex === close.lex && open.lower === close.lower;
80
+ }
81
+
82
+ /** A lexeme: what one token of the flat stream holds, with its span. */
83
+ function lexeme(lex, loc, fields) {
84
+ fields.lex = lex;
85
+ fields.start = loc.start.offset;
86
+ fields.end = loc.end.offset;
87
+ fields.loc = loc;
88
+ return fields;
89
+ }
90
+
91
+ /** The AST node of a lexeme that stands alone (not a macro or element). */
92
+ function nodeOf(x) {
93
+ switch (x.lex) {
94
+ case 'text':
95
+ return { type: 'text', value: x.value };
96
+ case 'link':
97
+ return withSelectors({ type: 'macro', name: 'link', rawArgs: quoteArg(x.display) + ' ' + quoteArg(x.target), children: [] }, x);
98
+ case 'variable':
99
+ return withSelectors({ type: 'variable', name: x.name, scope: x.scope }, x);
100
+ case 'expression':
101
+ return withSelectors({ type: 'expression', expression: x.expression }, x);
102
+ default:
103
+ return x; // already a node
104
+ }
105
+ }
106
+
107
+ /** The token of a lexeme (tokens.ts Token). */
108
+ function tokenOf(x) {
109
+ const span = { start: x.start, end: x.end };
110
+ switch (x.lex) {
111
+ case 'text':
112
+ return { type: 'text', value: x.value, ...span };
113
+ case 'link':
114
+ return withSelectors({ type: 'link', display: x.display, target: x.target, ...span }, x);
115
+ case 'variable':
116
+ return withSelectors({ type: 'variable', name: x.name, scope: x.scope, ...span }, x);
117
+ case 'expression':
118
+ return withSelectors({ type: 'expression', expression: x.expression, ...span }, x);
119
+ case 'macro':
120
+ return withSelectors({ type: 'macro', name: x.name, rawArgs: x.rawArgs, isClose: x.isClose, ...span }, x);
121
+ case 'html':
122
+ return { type: 'html', tag: x.tag, attributes: x.attributes, isClose: x.isClose, isSelfClose: x.isSelfClose, ...span };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Flatten items (lexemes, nodes, arrays of them, nulls) and join adjacent
128
+ * text, except escaped braces, which stay a text item of their own.
129
+ */
130
+ function joinText(items, convert) {
131
+ const out = [];
132
+ let last = null;
133
+ const add = (item) => {
134
+ if (item === null || item === undefined) return;
135
+ if (Array.isArray(item)) return item.forEach(add);
136
+ if (item.lex === 'text' && !item.escape && last && last.lex === 'text' && !last.escape && last.end === item.start) {
137
+ last.value += item.value;
138
+ last.end = item.end;
139
+ return;
140
+ }
141
+ out.push(item);
142
+ last = item;
143
+ };
144
+ add(items);
145
+ return convert ? out.map(convert) : out;
146
+ }
147
+ }}
148
+
149
+ {
150
+ const textMode = options.text === true;
151
+ const hooks = options.hooks;
152
+ /** End of the stretch a `.|n|` repetition consumes (see skipTo). */
153
+ let skipEnd = -1;
154
+ /** Whether the attribute whose value is being read holds code. */
155
+ let attrCode = false;
156
+ /** The macro whose raw body is being read. */
157
+ let rawName = '';
158
+
159
+ function skipTo(end) {
160
+ skipEnd = end;
161
+ return end >= 0;
162
+ }
163
+ function fail(message, loc) {
164
+ error(message, loc);
165
+ }
166
+ function attrs(list) {
167
+ const attributes = {};
168
+ const seen = new Set();
169
+ for (const [name, value] of list) {
170
+ const lower = name.toLowerCase();
171
+ if (seen.has(lower)) continue;
172
+ seen.add(lower);
173
+ Object.defineProperty(attributes, name, { value, enumerable: true, writable: true, configurable: true });
174
+ }
175
+ return attributes;
176
+ }
177
+
178
+ /** Check the body of a macro or element against its opening tag. */
179
+ function checkBody(open, body) {
180
+ for (const branch of body.branches) {
181
+ const parent = BRANCH_PARENT[branch.tag.lower];
182
+ if (open.lex !== 'macro' || open.lower !== parent) {
183
+ fail(label(branch.tag) + ' must be directly inside ' + LB + parent + RB + ', not inside ' + label(open), branch.tag.loc);
184
+ }
185
+ }
186
+ if (!body.end) {
187
+ fail('Unclosed ' + label(open) + ': no ' + closerOf(open) + ' closes it', open.loc);
188
+ }
189
+ if (!sameName(open, body.end)) {
190
+ fail(label(body.end) + ' found where ' + closerOf(open) + ' should close the ' + label(open) + ' opened at ' + at(open.loc), body.end.loc);
191
+ }
192
+ }
193
+
194
+ function macroNode(tag, body) {
195
+ const node = { type: 'macro', name: tag.lower, rawArgs: tag.rawArgs, children: [] };
196
+ if (!body) return withSelectors(node, tag);
197
+ checkBody(tag, body);
198
+ node.children = body.children;
199
+ if (BRANCHING.has(tag.lower)) {
200
+ node.branches = [withSelectors({ rawArgs: tag.rawArgs, children: body.children }, tag)];
201
+ for (const b of body.branches) {
202
+ node.branches.push(withSelectors({ rawArgs: b.tag.rawArgs, children: b.children }, b.tag));
203
+ }
204
+ node.children = [];
205
+ } else {
206
+ withSelectors(node, tag);
207
+ }
208
+ return node;
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------- structure
213
+
214
+ Markup
215
+ = nodes:Content Tail { return nodes; }
216
+
217
+ /** What ends the top level: the end, or a closer or branch with no opener. */
218
+ Tail
219
+ = !.
220
+ / tag:BranchTag { fail(label(tag) + ' must be directly inside ' + LB + BRANCH_PARENT[tag.lower] + RB, tag.loc); }
221
+ / tag:CloseTag { fail(label(tag) + ' closes nothing: no ' + (tag.lex === 'html' ? '<' + tag.tag + '>' : LB + tag.name + RB) + ' is open here', tag.loc); }
222
+
223
+ Content
224
+ = items:Item* { return joinText(items, nodeOf); }
225
+
226
+ Item
227
+ = TextRun
228
+ / Escape
229
+ / LinkLexeme
230
+ / HtmlElement
231
+ / VoidCloser
232
+ / Value
233
+ / MacroElement
234
+ / Literal
235
+
236
+ MacroElement
237
+ = tag:MacroTag &{ return tag.kind === 'open'; }
238
+ body:( &{ rawName = tag.lower; return hooks.isRaw(rawName); } b:RawBody { return b; }
239
+ / &{ return hooks.isBlock(tag.lower); } b:BlockBody { return b; }
240
+ / "" { return null; } )
241
+ { return macroNode(tag, body); }
242
+
243
+ HtmlElement
244
+ = &{ return !textMode; } open:HtmlOpen
245
+ body:( &{ return !open.isSelfClose; } b:BlockBody { return b; } )?
246
+ {
247
+ const node = { type: 'html', tag: open.tag, attributes: open.attributes, children: [] };
248
+ if (body) {
249
+ checkBody(open, body);
250
+ node.children = body.children;
251
+ }
252
+ return node;
253
+ }
254
+
255
+ BlockBody
256
+ = children:Content branches:BranchPart* end:BlockEnd { return { children, branches, end }; }
257
+
258
+ BranchPart
259
+ = tag:BranchTag children:Content { return { tag, children }; }
260
+
261
+ BlockEnd
262
+ = CloseTag
263
+ / !. { return null; }
264
+
265
+ BranchTag
266
+ = tag:MacroTag &{ return tag.kind === 'branch'; } { return tag; }
267
+
268
+ CloseTag
269
+ = tag:MacroTag &{ return tag.kind === 'close'; } { return tag; }
270
+ / &{ return !textMode; } tag:HtmlClose { return tag; }
271
+
272
+ /** A raw body ({do}): JavaScript up to its closer, kept as one text node. */
273
+ RawBody
274
+ = start:Here &{ return skipTo(hooks.rawBodyEnd(input, start, rawName)); }
275
+ body:$(.|{ return skipEnd - offset(); }|) end:CloseTag
276
+ { return { children: body ? [{ type: 'text', value: body }] : [], branches: [], end }; }
277
+ / "" { return { children: [], branches: [], end: null }; }
278
+
279
+ // ------------------------------------------------------------- flat tokens
280
+
281
+ Tokens
282
+ = items:TokenItem* { return joinText(items, tokenOf); }
283
+
284
+ TokenItem
285
+ = TextRun
286
+ / Escape
287
+ / LinkLexeme
288
+ / &{ return !textMode; } tag:HtmlOpen { return tag; }
289
+ / &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
290
+ / Value
291
+ / tag:MacroTag
292
+ body:( &{ rawName = tag.lower; return tag.kind === 'open' && hooks.isRaw(rawName); } b:RawTokens { return b; } )?
293
+ { return body ? [tag, body] : tag; }
294
+ / Literal
295
+
296
+ /** A raw body and its closer, as tokens; nothing if it is unclosed. */
297
+ RawTokens
298
+ = start:Here &{ return skipTo(hooks.rawBodyEnd(input, start, rawName)); }
299
+ body:$(.|{ return skipEnd - offset(); }|) end:CloseTag
300
+ {
301
+ const loc = { start: { offset: start }, end: { offset: start + body.length } };
302
+ return [body ? lexeme('text', loc, { value: body }) : null, end];
303
+ }
304
+
305
+ // ----------------------------------------------------------------- lexemes
306
+
307
+ Here
308
+ = "" { return offset(); }
309
+
310
+ TextRun
311
+ = value:$[^\\{[<]+ { return lexeme('text', location(), { value }); }
312
+
313
+ /**
314
+ * A backslash run. Before a brace, an odd run escapes it; in text mode the
315
+ * run is also paired up (`\\` is one backslash), in passage text markdown
316
+ * does that.
317
+ */
318
+ Escape
319
+ = bs:$"\\"+ brace:( &{ return bs.length % 2 === 1; } c:[{}] { return c; } )? {
320
+ const loc = location();
321
+ const next = input.charAt(loc.end.offset);
322
+ if (textMode) {
323
+ if (!brace && next !== LB && next !== RB) return lexeme('text', loc, { value: bs });
324
+ const node = lexeme('text', loc, { value: '\\'.repeat(bs.length >> 1) + (brace || '') });
325
+ node.escape = true;
326
+ return node;
327
+ }
328
+ if (!brace) return lexeme('text', loc, { value: bs });
329
+ const before = lexeme('text', loc, { value: bs.slice(1) });
330
+ before.end = loc.end.offset - 2;
331
+ const escaped = lexeme('text', loc, { value: brace });
332
+ escaped.start = loc.end.offset - 2;
333
+ escaped.escape = true;
334
+ return bs.length > 1 ? [before, escaped] : escaped;
335
+ }
336
+
337
+ /** A character that starts no markup here. */
338
+ Literal
339
+ = "{" !(Selectors [$_@%(!/a-zA-Z]) { return lexeme('text', location(), { value: LB }); }
340
+ / !(&{ return !textMode; } HtmlClose) "<" { return lexeme('text', location(), { value: '<' }); }
341
+ / "[" { return lexeme('text', location(), { value: '[' }); }
342
+
343
+ LinkLexeme
344
+ = &{ return !textMode; } "[[" s:Selectors inner:$LinkText "]]" {
345
+ return withSelectors(lexeme('link', location(), parseLink(inner)), s);
346
+ }
347
+ / &{ return !textMode; } "[[" { fail('Unclosed link: [[ without ]]', location()); }
348
+
349
+ /**
350
+ * A link's text, up to the first ]]. (Nested [[…]] pairs, as the
351
+ * hand-written tokenizer read them, made unclosed [[s backtrack
352
+ * exponentially; a link's text can't hold [[ now.)
353
+ */
354
+ LinkText
355
+ = (!("]]" / "[[") .)*
356
+
357
+ /** `.class#id` selectors, and the one space after them. */
358
+ Selectors
359
+ = parts:SelectorPart+ " "? { return selectorsOf(parts); }
360
+ / "" { return NO_SELECTORS; }
361
+
362
+ SelectorPart
363
+ = prefix:[.#] name:$SelectorChar* { return [prefix, name]; }
364
+
365
+ SelectorChar
366
+ = [a-zA-Z0-9_\-]
367
+ / "{" [$_@%] [a-zA-Z0-9_.]* "}"
368
+
369
+ /** A variable `{$a.b}` or expression `{$a + 1}`, `{(…)}`, `{!…}`. */
370
+ Value
371
+ = "{" s:Selectors sigil:[$_@%] name:$[a-zA-Z0-9_.]* "}" {
372
+ return withSelectors(lexeme('variable', location(), { name, scope: SIGIL_SCOPES[sigil] }), s);
373
+ }
374
+ / "{" s:Selectors &[$_@%(!] expression:CodeToBrace "}" {
375
+ return withSelectors(lexeme('expression', location(), { expression }), s);
376
+ }
377
+ / "{" Selectors &[$_@%(!] { fail('Unclosed ' + LB + input.substr(location().end.offset, 1) + '…: no ' + RB + ' ends it', location()); }
378
+
379
+ /** JavaScript up to the } that ends it: where is up to options.hooks. */
380
+ CodeToBrace
381
+ = start:Here &{ return skipTo(hooks.closeBrace(input, start, start)); } code:$(.|{ return skipEnd - offset(); }|) { return code; }
382
+
383
+ /** A macro tag: `{name args}`, `{/name}`, with selectors on an opener. */
384
+ MacroTag
385
+ = "{" s:Selectors start:Here close:"/"? name:MacroName nameEnd:Here
386
+ tail:( &{ return skipTo(hooks.closeBrace(input, nameEnd, start)); } args:$(.|{ return skipEnd - offset(); }|) "}" { return args; } )? {
387
+ const loc = location();
388
+ const lower = name.toLowerCase();
389
+ const tag = lexeme('macro', loc, { name, lower, rawArgs: '', isClose: !!close });
390
+ if (tail === null) fail('Unclosed ' + LB + (close ? '/' : '') + name + '…: no ' + RB + ' ends the macro', loc);
391
+ tag.rawArgs = tail.trim();
392
+ if (close) {
393
+ if (s !== NO_SELECTORS) fail('A closing tag takes no selectors: ' + label(tag), loc);
394
+ if (tag.rawArgs) fail('A closing tag takes no arguments: ' + LB + '/' + name + ' ' + tag.rawArgs + RB, loc);
395
+ tag.kind = 'close';
396
+ } else {
397
+ withSelectors(tag, s);
398
+ tag.kind = Object.prototype.hasOwnProperty.call(BRANCH_PARENT, lower) ? 'branch' : 'open';
399
+ }
400
+ return tag;
401
+ }
402
+ / "{" s:Selectors "/" { fail('A closing tag starts with a letter after ' + LB + '/', location()); }
403
+
404
+ /** A macro name: a letter, then anything up to whitespace or }. */
405
+ MacroName
406
+ = $([a-zA-Z] [^ \t\n\r\x0B\x0C\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF}]*)
407
+
408
+ // -------------------------------------------------------------------- HTML
409
+
410
+ /** A redundant closer of a void element (</br>), which is dropped. */
411
+ VoidCloser
412
+ = &{ return !textMode; } tag:HtmlClose &{ return VOID_TAGS.has(tag.lower); } { return null; }
413
+
414
+ HtmlOpen
415
+ = "<" tag:TagName list:Attribute* _ slash:"/"? ">" {
416
+ const lower = tag.toLowerCase();
417
+ return lexeme('html', location(), { tag, lower, attributes: attrs(list), isClose: false, isSelfClose: !!slash || VOID_TAGS.has(lower) });
418
+ }
419
+ / "<" tag:TagName &(_c / "/" / ">" / !.) Attribute* _ bad:Here {
420
+ // Only the offset of a location is used (parse.ts reads line and
421
+ // column from it).
422
+ const c = input.charAt(bad);
423
+ if (!c) fail('Unclosed tag <' + tag + ': no > ends it (write &lt; for a < that starts no tag)', location());
424
+ const at = { source: location().source, start: { offset: bad, line: 0, column: 0 }, end: { offset: bad + 1, line: 0, column: 0 } };
425
+ fail('Unexpected ' + JSON.stringify(c) + ' in the tag <' + tag + '>: expected an attribute, or > to end the tag', at);
426
+ }
427
+
428
+ HtmlClose
429
+ = "</" tag:TagName _ ">" {
430
+ return lexeme('html', location(), { tag, lower: tag.toLowerCase(), attributes: {}, isClose: true, isSelfClose: false });
431
+ }
432
+
433
+ TagName
434
+ = $([a-zA-Z] [a-zA-Z0-9\-]*)
435
+
436
+ /**
437
+ * An attribute. Its name is as in HTML: anything up to whitespace, a quote,
438
+ * `>`, `/` or `=`; and not `<`, `{` or `}`, which markup can't use there.
439
+ */
440
+ Attribute
441
+ = _ name:$(!(_c / ["'>/=<{}]) .)+ &{ attrCode = hooks.isCodeAttribute(name); return true; } value:(_ "=" _ v:AttrValue { return v; })? {
442
+ return [name, value ?? ''];
443
+ }
444
+
445
+ AttrValue
446
+ = '"' v:$(ValuePart / [^"])* '"' { return v; }
447
+ / "'" v:$(ValuePart / [^'])* "'" { return v; }
448
+ / q:["'] { fail('Unclosed attribute value: the ' + q + ' here has no closing ' + q, location()); }
449
+ / $[^ \t\n\r\x0B\x0C\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF>]*
450
+
451
+ /** Markup in a quoted value, whose quotes don't end the value. */
452
+ ValuePart
453
+ = &{ return !attrCode; } bs:$"\\"+ (&{ return bs.length % 2 === 1; } [{}])?
454
+ / &{ return !attrCode; } "{" Selectors start:Here "/"? MacroName nameEnd:Here
455
+ &{ return skipTo(hooks.closeBrace(input, nameEnd, start)); } $(.|{ return skipEnd - offset(); }|) "}"
456
+ / &{ return !attrCode; } "{" Selectors !("/"? [a-zA-Z]) CodeToBrace "}"
457
+ / &{ return attrCode; } "{" &[$_@%] CodeToBrace "}"
458
+
459
+ _
460
+ = _c*
461
+
462
+ _c
463
+ = [ \t\n\r\x0B\x0C\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Markup tokens (what parse.ts tokenizeMarkup returns) and the variable
3
+ * sigils and selectors they share with the AST.
4
+ */
5
+ import type { Sigil } from '../js-lexer';
6
+
7
+ /** The namespace a variable reference reads. */
8
+ export type VariableScope = 'variable' | 'temporary' | 'local' | 'transient';
9
+
10
+ /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
11
+ export const SIGIL_SCOPES: Readonly<Record<Sigil, VariableScope>> = {
12
+ $: 'variable',
13
+ _: 'temporary',
14
+ '@': 'local',
15
+ '%': 'transient',
16
+ };
17
+
18
+ /** The sigil of each variable scope. */
19
+ export const SCOPE_SIGILS = Object.fromEntries(
20
+ Object.entries(SIGIL_SCOPES).map(([sigil, scope]) => [scope, sigil]),
21
+ ) as Readonly<Record<VariableScope, Sigil>>;
22
+
23
+ const SIGIL_CHARS: ReadonlySet<string> = new Set(Object.keys(SIGIL_SCOPES));
24
+
25
+ /** Whether `c` is a variable sigil. */
26
+ export function isSigil(c: string | undefined): c is Sigil {
27
+ return c !== undefined && SIGIL_CHARS.has(c);
28
+ }
29
+
30
+ /** The `.class#id` selectors written before a link, variable or macro. */
31
+ export interface Selectors {
32
+ className?: string;
33
+ id?: string;
34
+ }
35
+
36
+ /** Copy the selectors set in `from` onto `target`, and return it. */
37
+ export function withSelectors<T extends Selectors>(
38
+ target: T,
39
+ from: Selectors,
40
+ ): T {
41
+ if (from.className) target.className = from.className;
42
+ if (from.id) target.id = from.id;
43
+ return target;
44
+ }
45
+
46
+ /** Where a token is in the input: from `start` up to `end`. */
47
+ interface Span {
48
+ start: number;
49
+ end: number;
50
+ }
51
+
52
+ export interface TextToken extends Span {
53
+ type: 'text';
54
+ value: string;
55
+ }
56
+
57
+ export interface LinkToken extends Span, Selectors {
58
+ type: 'link';
59
+ display: string;
60
+ target: string;
61
+ }
62
+
63
+ export interface MacroToken extends Span, Selectors {
64
+ type: 'macro';
65
+ name: string;
66
+ rawArgs: string;
67
+ isClose: boolean;
68
+ }
69
+
70
+ export interface VariableToken extends Span, Selectors {
71
+ type: 'variable';
72
+ name: string;
73
+ scope: VariableScope;
74
+ }
75
+
76
+ export interface ExpressionToken extends Span, Selectors {
77
+ type: 'expression';
78
+ expression: string;
79
+ }
80
+
81
+ export interface HtmlToken extends Span {
82
+ type: 'html';
83
+ tag: string;
84
+ attributes: Record<string, string>;
85
+ isClose: boolean;
86
+ isSelfClose: boolean;
87
+ }
88
+
89
+ export type Token =
90
+ | TextToken
91
+ | LinkToken
92
+ | MacroToken
93
+ | VariableToken
94
+ | ExpressionToken
95
+ | HtmlToken;