@rohal12/spindle 0.52.9 → 0.54.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/README.md +5 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6484 -2558
- package/dist/pkg/story-variables.js +4009 -940
- package/dist/pkg/tooling.js +13 -1
- package/package.json +6 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +4 -2
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/Include.tsx +2 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/index.tsx +57 -37
- package/src/interpolation.ts +4 -5
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +219 -0
- package/src/parser.ts +19 -4
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +18 -4
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/tooling.ts +50 -0
- package/src/types-drift-check.ts +28 -1
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +32 -8
- package/types/tooling.d.ts +42 -0
- 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 < 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;
|