@chilts/weave 0.6.0 → 0.8.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/CHANGELOG.md +303 -0
- package/ReadMe.md +261 -8
- package/dist/cli/check.d.ts +14 -0
- package/dist/cli/check.js +133 -0
- package/dist/cli/index.d.ts +42 -4
- package/dist/cli/index.js +190 -36
- package/dist/codegen/index.d.ts +17 -3
- package/dist/codegen/index.js +436 -238
- package/dist/codegen/sourcemap.d.ts +49 -1
- package/dist/codegen/sourcemap.js +154 -2
- package/dist/diagnostics/index.d.ts +25 -0
- package/dist/diagnostics/index.js +68 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/lexer/index.d.ts +5 -1
- package/dist/lexer/index.js +340 -92
- package/dist/parser/index.d.ts +1 -1
- package/dist/parser/index.js +74 -9
- package/dist/parser/types.d.ts +13 -0
- package/dist/runtime/index.d.ts +45 -1
- package/dist/runtime/index.js +67 -2
- package/package.json +23 -5
package/dist/lexer/index.js
CHANGED
|
@@ -19,8 +19,24 @@ const KEYWORDS = new Set([
|
|
|
19
19
|
'include',
|
|
20
20
|
'doctype',
|
|
21
21
|
'if',
|
|
22
|
+
'unless',
|
|
22
23
|
'for',
|
|
23
24
|
]);
|
|
25
|
+
// Pug's 'block append' / 'block prepend' have no Weave form: a page fills
|
|
26
|
+
// a block by replacing it, so extra content needs a slot of its own.
|
|
27
|
+
const APPEND_HINT = "there is no 'append' or 'prepend'; add a second, empty slot to the layout " +
|
|
28
|
+
"(e.g. 'block pageScripts' after 'block scripts') and fill that instead";
|
|
29
|
+
// Pug keywords with no Weave equivalent, and what to write instead. Without
|
|
30
|
+
// this they lex as elements and render as e.g. <case> and <when>.
|
|
31
|
+
const PUG_KEYWORDS = new Map([
|
|
32
|
+
['case', "use 'if' / 'else if' / 'else' instead of 'case' / 'when'"],
|
|
33
|
+
['when', "use 'if' / 'else if' / 'else' instead of 'case' / 'when'"],
|
|
34
|
+
['default', "use 'else' instead of 'case' / 'default'"],
|
|
35
|
+
['each', "use 'for ( const item of items )' instead of 'each'"],
|
|
36
|
+
['while', "iterate with 'for ( const item of items )' instead of 'while'"],
|
|
37
|
+
['append', APPEND_HINT],
|
|
38
|
+
['prepend', APPEND_HINT],
|
|
39
|
+
]);
|
|
24
40
|
class Lexer {
|
|
25
41
|
lines;
|
|
26
42
|
lineIndex = 0;
|
|
@@ -54,7 +70,9 @@ class Lexer {
|
|
|
54
70
|
}
|
|
55
71
|
// Preserve indentation relative to the base
|
|
56
72
|
const text = rawLine.slice(this.rawBlockBaseIndent);
|
|
57
|
-
|
|
73
|
+
// The cursor still belongs to the previous line, so anchor
|
|
74
|
+
// explicitly on this line's first non-space character.
|
|
75
|
+
this.emit('RAW_TEXT', text, 0);
|
|
58
76
|
this.emit('NEWLINE', '');
|
|
59
77
|
continue;
|
|
60
78
|
}
|
|
@@ -196,6 +214,9 @@ class Lexer {
|
|
|
196
214
|
case 'if':
|
|
197
215
|
this.scanIf();
|
|
198
216
|
break;
|
|
217
|
+
case 'unless':
|
|
218
|
+
this.scanUnless();
|
|
219
|
+
break;
|
|
199
220
|
case 'for':
|
|
200
221
|
this.scanFor();
|
|
201
222
|
break;
|
|
@@ -225,70 +246,78 @@ class Lexer {
|
|
|
225
246
|
}
|
|
226
247
|
scanBlock() {
|
|
227
248
|
this.emit('BLOCK', 'block');
|
|
228
|
-
const
|
|
229
|
-
this.
|
|
249
|
+
const nameStart = this.skipSpacesFrom('block '.length);
|
|
250
|
+
const name = this.currentLine.slice(nameStart).trim();
|
|
251
|
+
// A bare 'block' (mixin call-site content) has no name.
|
|
252
|
+
if (name !== '' && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
253
|
+
const word = name.split(/\s/)[0];
|
|
254
|
+
const message = word === 'append' || word === 'prepend'
|
|
255
|
+
? `'block ${word}' is not supported; ${APPEND_HINT}`
|
|
256
|
+
: `Block name '${name}' must be a single identifier`;
|
|
257
|
+
throw new LexerError(message, this.lineIndex + 1, this.indentWidth + nameStart + 1);
|
|
258
|
+
}
|
|
259
|
+
this.emit('IDENTIFIER', name, nameStart);
|
|
230
260
|
this.pos = this.currentLine.length;
|
|
231
261
|
}
|
|
232
262
|
scanInclude() {
|
|
233
263
|
this.emit('INCLUDE', 'include');
|
|
234
|
-
const
|
|
235
|
-
this.
|
|
264
|
+
const pathStart = this.skipSpacesFrom('include '.length);
|
|
265
|
+
const path = this.currentLine.slice(pathStart).trim();
|
|
266
|
+
this.emit('PATH', path, pathStart);
|
|
236
267
|
this.pos = this.currentLine.length;
|
|
237
268
|
}
|
|
238
269
|
// --- Declaration scanners ---
|
|
239
270
|
scanTemplate() {
|
|
271
|
+
this.emit('TEMPLATE', 'template', 0);
|
|
240
272
|
this.pos = 'template '.length;
|
|
241
|
-
this.
|
|
273
|
+
const nameStart = this.pos;
|
|
242
274
|
const name = this.scanIdentifier();
|
|
243
|
-
this.emit('IDENTIFIER', name);
|
|
244
|
-
|
|
245
|
-
const params = this.scanBalancedParens();
|
|
246
|
-
this.emit('PARAMS', params);
|
|
247
|
-
}
|
|
275
|
+
this.emit('IDENTIFIER', name, nameStart);
|
|
276
|
+
this.scanParamsIfPresent();
|
|
248
277
|
}
|
|
249
278
|
scanMixin() {
|
|
279
|
+
this.emit('MIXIN', 'mixin', 0);
|
|
250
280
|
this.pos = 'mixin '.length;
|
|
251
|
-
this.
|
|
281
|
+
const nameStart = this.pos;
|
|
252
282
|
const name = this.scanIdentifier();
|
|
253
|
-
this.emit('IDENTIFIER', name);
|
|
254
|
-
|
|
255
|
-
const params = this.scanBalancedParens();
|
|
256
|
-
this.emit('PARAMS', params);
|
|
257
|
-
}
|
|
283
|
+
this.emit('IDENTIFIER', name, nameStart);
|
|
284
|
+
this.scanParamsIfPresent();
|
|
258
285
|
}
|
|
259
286
|
scanExtends() {
|
|
287
|
+
this.emit('EXTENDS', 'extends', 0);
|
|
260
288
|
this.pos = 'extends '.length;
|
|
261
|
-
this.
|
|
289
|
+
const nameStart = this.pos;
|
|
262
290
|
const name = this.scanIdentifier();
|
|
263
|
-
this.emit('IDENTIFIER', name);
|
|
264
|
-
|
|
265
|
-
const params = this.scanBalancedParens();
|
|
266
|
-
this.emit('PARAMS', params);
|
|
267
|
-
}
|
|
291
|
+
this.emit('IDENTIFIER', name, nameStart);
|
|
292
|
+
this.scanParamsIfPresent();
|
|
268
293
|
}
|
|
269
294
|
// --- Control flow scanners ---
|
|
270
295
|
scanIf() {
|
|
296
|
+
this.emit('IF', 'if', 0);
|
|
271
297
|
this.pos = 'if '.length;
|
|
272
|
-
this.
|
|
298
|
+
const expr = this.currentLine.slice(this.pos);
|
|
299
|
+
this.emit('BUFFERED_CODE', expr);
|
|
300
|
+
this.pos = this.currentLine.length;
|
|
301
|
+
}
|
|
302
|
+
scanUnless() {
|
|
303
|
+
this.emit('UNLESS', 'unless', 0);
|
|
304
|
+
this.pos = 'unless '.length;
|
|
273
305
|
const expr = this.currentLine.slice(this.pos);
|
|
274
306
|
this.emit('BUFFERED_CODE', expr);
|
|
275
307
|
this.pos = this.currentLine.length;
|
|
276
308
|
}
|
|
277
309
|
scanElseIf() {
|
|
310
|
+
this.emit('ELSE_IF', 'else if', 0);
|
|
278
311
|
this.pos = 'else if '.length;
|
|
279
|
-
this.emit('ELSE_IF', 'else if');
|
|
280
312
|
const expr = this.currentLine.slice(this.pos);
|
|
281
313
|
this.emit('BUFFERED_CODE', expr);
|
|
282
314
|
this.pos = this.currentLine.length;
|
|
283
315
|
}
|
|
284
316
|
scanFor() {
|
|
317
|
+
this.emit('FOR', 'for', 0);
|
|
285
318
|
this.pos = 'for '.length;
|
|
286
|
-
this.emit('FOR', 'for');
|
|
287
319
|
this.skipSpaces();
|
|
288
|
-
|
|
289
|
-
const params = this.scanBalancedParens();
|
|
290
|
-
this.emit('PARAMS', params);
|
|
291
|
-
}
|
|
320
|
+
this.scanParamsIfPresent();
|
|
292
321
|
}
|
|
293
322
|
// --- Element scanner ---
|
|
294
323
|
scanElement() {
|
|
@@ -301,8 +330,10 @@ class Lexer {
|
|
|
301
330
|
this.emit('TAG', 'div');
|
|
302
331
|
}
|
|
303
332
|
else {
|
|
333
|
+
const tagStart = this.pos;
|
|
304
334
|
const tag = this.scanTagName();
|
|
305
|
-
this.
|
|
335
|
+
this.checkTagName(tag, tagStart);
|
|
336
|
+
this.emit('TAG', tag, tagStart);
|
|
306
337
|
}
|
|
307
338
|
// Classes and IDs
|
|
308
339
|
this.scanClassesAndIds();
|
|
@@ -311,11 +342,19 @@ class Lexer {
|
|
|
311
342
|
this.emit('ATTR_START', '(');
|
|
312
343
|
this.pos++; // skip '('
|
|
313
344
|
this.scanAttributes(elementIndent);
|
|
314
|
-
this.emit('ATTR_END', ')');
|
|
345
|
+
this.emit('ATTR_END', ')', this.pos - 1);
|
|
315
346
|
}
|
|
316
347
|
// Raw block: tag!
|
|
317
348
|
if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '!') {
|
|
318
349
|
this.pos++; // skip !
|
|
350
|
+
// Anything after the '!' used to be dropped without a word, which
|
|
351
|
+
// silently lost the expression in Pug's unescaped 'p!= html'.
|
|
352
|
+
const rest = this.currentLine.slice(this.pos);
|
|
353
|
+
if (rest.trim() !== '') {
|
|
354
|
+
throw new LexerError(rest.startsWith('=')
|
|
355
|
+
? "Unescaped buffered code ('!=') is not supported; use '= raw(expr)' instead"
|
|
356
|
+
: "A raw block 'tag!' takes no content on its own line; put the raw text on indented lines below it", this.lineIndex + 1, this.indentWidth + this.pos);
|
|
357
|
+
}
|
|
319
358
|
this.rawBlockIndent = elementIndent;
|
|
320
359
|
this.rawBlockBaseIndent = null;
|
|
321
360
|
return;
|
|
@@ -336,36 +375,93 @@ class Lexer {
|
|
|
336
375
|
}
|
|
337
376
|
return this.currentLine.slice(start, this.pos);
|
|
338
377
|
}
|
|
378
|
+
// Rejects tag names that can't be an element. Pug syntax Weave doesn't
|
|
379
|
+
// have used to land here and come out as bogus elements: '- const x = 1'
|
|
380
|
+
// became a '-' element and 'case'/'when' became <case>/<when>.
|
|
381
|
+
checkTagName(tag, tagStart) {
|
|
382
|
+
let message = null;
|
|
383
|
+
if (tag.startsWith('-')) {
|
|
384
|
+
message =
|
|
385
|
+
"Unbuffered code ('- ...') is not supported; templates hold no statements. " +
|
|
386
|
+
"Compute values in TypeScript before rendering, or use a buffered expression 'tag= expr'";
|
|
387
|
+
}
|
|
388
|
+
else if (tag === '') {
|
|
389
|
+
message = `Unexpected '${this.currentLine[tagStart]}'; expected a tag name, '.class' or '#id'`;
|
|
390
|
+
}
|
|
391
|
+
else if (!/^[a-zA-Z]/.test(tag)) {
|
|
392
|
+
message = `Tag name '${tag}' must start with a letter`;
|
|
393
|
+
}
|
|
394
|
+
else if (PUG_KEYWORDS.has(tag)) {
|
|
395
|
+
message = `'${tag}' is not a Weave keyword; ${PUG_KEYWORDS.get(tag)}`;
|
|
396
|
+
}
|
|
397
|
+
if (message !== null) {
|
|
398
|
+
throw new LexerError(message, this.lineIndex + 1, this.indentWidth + tagStart + 1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
339
401
|
scanClassesAndIds() {
|
|
402
|
+
let prevClass = null;
|
|
340
403
|
while (this.pos < this.currentLine.length) {
|
|
341
404
|
const ch = this.currentLine[this.pos];
|
|
342
405
|
if (ch === '.') {
|
|
406
|
+
const dotPos = this.pos;
|
|
407
|
+
const dotCol = this.indentWidth + dotPos + 1;
|
|
343
408
|
this.pos++; // skip dot
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
409
|
+
const { name, raw } = this.scanShorthandName();
|
|
410
|
+
if (name === '') {
|
|
411
|
+
// Pug's 'p.' block text emitted class="" and then lexed
|
|
412
|
+
// the text lines below it as elements.
|
|
413
|
+
throw new LexerError(this.pos >= this.currentLine.length
|
|
414
|
+
? "Block text ('tag.') is not supported; use 'tag!' for raw text, or '|' lines for escaped text"
|
|
415
|
+
: "Expected a class name after '.'", this.lineIndex + 1, dotCol);
|
|
350
416
|
}
|
|
351
|
-
|
|
417
|
+
// A dot is a class separator, so '.gap-1.5' silently became
|
|
418
|
+
// 'gap-1' plus a stray '5'. A class that is a bare number is
|
|
419
|
+
// almost certainly that mistake, so reject it and name the fix.
|
|
420
|
+
if (/^[0-9]+$/.test(name)) {
|
|
421
|
+
const example = prevClass === null ? `.gap-1\\.${name}` : `.${prevClass}\\.${name}`;
|
|
422
|
+
const split = prevClass === null ? '' : ` ('.${prevClass}.${name}' was split into '${prevClass}' and '${name}')`;
|
|
423
|
+
throw new LexerError(`Class '.${name}' is a bare number; a dot inside a class name is read as a separator${split}. ` +
|
|
424
|
+
`Escape the dot with a backslash, e.g. '${example}'`, this.lineIndex + 1, dotCol);
|
|
425
|
+
}
|
|
426
|
+
prevClass = raw;
|
|
427
|
+
this.emit('CLASS', name, dotPos);
|
|
352
428
|
}
|
|
353
429
|
else if (ch === '#') {
|
|
430
|
+
const hashPos = this.pos;
|
|
354
431
|
this.pos++; // skip hash
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
break;
|
|
360
|
-
this.pos++;
|
|
432
|
+
prevClass = null;
|
|
433
|
+
const { name } = this.scanShorthandName();
|
|
434
|
+
if (name === '') {
|
|
435
|
+
throw new LexerError("Expected an id after '#'", this.lineIndex + 1, this.indentWidth + hashPos + 1);
|
|
361
436
|
}
|
|
362
|
-
this.emit('ID',
|
|
437
|
+
this.emit('ID', name, hashPos);
|
|
363
438
|
}
|
|
364
439
|
else {
|
|
365
440
|
break;
|
|
366
441
|
}
|
|
367
442
|
}
|
|
368
443
|
}
|
|
444
|
+
// Scans a class or id name after its '.' or '#'. A backslash-escaped dot
|
|
445
|
+
// ('\.') is a literal dot rather than the start of the next class, so
|
|
446
|
+
// Tailwind's 'gap-1\.5' is a single class 'gap-1.5'. Returns the unescaped
|
|
447
|
+
// name and the source text it came from.
|
|
448
|
+
scanShorthandName() {
|
|
449
|
+
const start = this.pos;
|
|
450
|
+
let name = '';
|
|
451
|
+
while (this.pos < this.currentLine.length) {
|
|
452
|
+
const c = this.currentLine[this.pos];
|
|
453
|
+
if (c === '\\' && this.currentLine[this.pos + 1] === '.') {
|
|
454
|
+
name += '.';
|
|
455
|
+
this.pos += 2;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
|
|
459
|
+
break;
|
|
460
|
+
name += c;
|
|
461
|
+
this.pos++;
|
|
462
|
+
}
|
|
463
|
+
return { name, raw: this.currentLine.slice(start, this.pos) };
|
|
464
|
+
}
|
|
369
465
|
// Scans an attribute list, with the cursor just past the opening '('.
|
|
370
466
|
// `ownerIndent` is the indent of the element or mixin call that owns the
|
|
371
467
|
// list, which bounds the multi-line form.
|
|
@@ -461,6 +557,7 @@ class Lexer {
|
|
|
461
557
|
// Scans a single `...spread`, `name` or `name=value`, with the cursor on
|
|
462
558
|
// its first character.
|
|
463
559
|
scanAttribute() {
|
|
560
|
+
const start = this.pos;
|
|
464
561
|
// Spread: ...identifier
|
|
465
562
|
if (this.currentLine[this.pos] === '.' &&
|
|
466
563
|
this.pos + 2 < this.currentLine.length &&
|
|
@@ -468,7 +565,7 @@ class Lexer {
|
|
|
468
565
|
this.currentLine[this.pos + 2] === '.') {
|
|
469
566
|
this.pos += 3; // skip ...
|
|
470
567
|
const name = this.scanIdentifier();
|
|
471
|
-
this.emit('SPREAD', name);
|
|
568
|
+
this.emit('SPREAD', name, start);
|
|
472
569
|
return;
|
|
473
570
|
}
|
|
474
571
|
// Attribute name
|
|
@@ -480,14 +577,15 @@ class Lexer {
|
|
|
480
577
|
// Without this guard the caller's loop spins forever emitting
|
|
481
578
|
// empty ATTR_NAME tokens until the process aborts.
|
|
482
579
|
throw new LexerError(`Unexpected '${this.currentLine[this.pos]}' in attribute list; ` +
|
|
483
|
-
|
|
580
|
+
'wrap complex attribute values in (parentheses) or `backticks`', this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
484
581
|
}
|
|
485
|
-
this.emit('ATTR_NAME', name);
|
|
582
|
+
this.emit('ATTR_NAME', name, start);
|
|
486
583
|
// Check for = value
|
|
487
584
|
if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '=') {
|
|
488
585
|
this.pos++; // skip =
|
|
586
|
+
const valueStart = this.pos;
|
|
489
587
|
const value = this.scanAttrValue();
|
|
490
|
-
this.emit('ATTR_VALUE', value);
|
|
588
|
+
this.emit('ATTR_VALUE', value, valueStart);
|
|
491
589
|
}
|
|
492
590
|
}
|
|
493
591
|
scanAttrName() {
|
|
@@ -565,25 +663,49 @@ class Lexer {
|
|
|
565
663
|
this.pos = this.currentLine.length;
|
|
566
664
|
return;
|
|
567
665
|
}
|
|
666
|
+
// Anything else used to be dropped along with the rest of the line,
|
|
667
|
+
// e.g. the child in Pug's block expansion 'li: a(href="/") Home'.
|
|
668
|
+
let message;
|
|
669
|
+
if (ch === ':') {
|
|
670
|
+
message =
|
|
671
|
+
"Block expansion ('tag: child') is not supported; " +
|
|
672
|
+
'put the child element on its own indented line';
|
|
673
|
+
}
|
|
674
|
+
else if (ch === '/') {
|
|
675
|
+
message = "Self-closing 'tag/' is not supported; void elements like 'br' and 'img' close themselves";
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
message = `Unexpected '${ch}' after element; separate inline text from the tag with a space`;
|
|
679
|
+
}
|
|
680
|
+
throw new LexerError(message, this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
568
681
|
}
|
|
569
682
|
// --- Mixin call scanner ---
|
|
570
683
|
scanMixinCall() {
|
|
571
684
|
const elementIndent = this.indentWidth;
|
|
572
685
|
this.pos = 1; // skip +
|
|
573
686
|
const name = this.scanIdentifier();
|
|
574
|
-
|
|
687
|
+
// Anchored on the '+', not where the name scan left the cursor.
|
|
688
|
+
this.emit('MIXIN_CALL', name, 0);
|
|
575
689
|
// Args in first set of parens
|
|
576
|
-
|
|
577
|
-
const args = this.scanBalancedParens();
|
|
578
|
-
this.emit('PARAMS', args);
|
|
579
|
-
}
|
|
690
|
+
this.scanParamsIfPresent();
|
|
580
691
|
// Extra attrs in second set of parens
|
|
581
692
|
if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
|
|
582
693
|
this.emit('ATTR_START', '(');
|
|
583
694
|
this.pos++; // skip '('
|
|
584
695
|
this.scanAttributes(elementIndent);
|
|
585
|
-
this.emit('ATTR_END', ')');
|
|
696
|
+
this.emit('ATTR_END', ')', this.pos - 1);
|
|
697
|
+
}
|
|
698
|
+
// One line of call-site content may follow, as for an element:
|
|
699
|
+
// '+btn("x") Save' or '+btn("x")= label'. Trailing spaces alone are
|
|
700
|
+
// not content.
|
|
701
|
+
const rest = this.currentLine.slice(this.pos);
|
|
702
|
+
if (rest.trim() === '')
|
|
703
|
+
return;
|
|
704
|
+
if (rest[0] === ' ' || rest[0] === '=') {
|
|
705
|
+
this.scanTrailingContent();
|
|
706
|
+
return;
|
|
586
707
|
}
|
|
708
|
+
throw new LexerError(`Unexpected '${rest[0]}' after mixin call; separate inline content from the call with a space`, this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
587
709
|
}
|
|
588
710
|
// --- Pipe scanner ---
|
|
589
711
|
scanPipe() {
|
|
@@ -599,17 +721,31 @@ class Lexer {
|
|
|
599
721
|
this.pos = this.currentLine.length;
|
|
600
722
|
}
|
|
601
723
|
// --- Text with interpolation ---
|
|
724
|
+
// `text` is the rest of the current line from the cursor, so an offset
|
|
725
|
+
// into it plus the cursor is that character's position on the line.
|
|
602
726
|
scanTextWithInterpolation(text) {
|
|
603
727
|
let i = 0;
|
|
604
728
|
let buf = '';
|
|
729
|
+
let bufStart = 0;
|
|
605
730
|
while (i < text.length) {
|
|
731
|
+
// Pug's tag interpolation '#[strong x]' and unescaped '!{html}'
|
|
732
|
+
// used to pass through as literal text, so the markup characters
|
|
733
|
+
// rendered (escaped) on the page.
|
|
734
|
+
if ((text[i] === '#' && text[i + 1] === '[') || (text[i] === '!' && text[i + 1] === '{')) {
|
|
735
|
+
throw new LexerError((text[i] === '#'
|
|
736
|
+
? "Tag interpolation ('#[tag ...]') is not supported; put the element on its own " +
|
|
737
|
+
"indented line, with '|' lines for the text around it"
|
|
738
|
+
: "Unescaped interpolation ('!{...}') is not supported; use '#{raw(expr)}' instead") +
|
|
739
|
+
`. For literal '${text.slice(i, i + 2)}' text, use a string expression: 'tag= "..."'`, this.lineIndex + 1, this.indentWidth + this.pos + i + 1);
|
|
740
|
+
}
|
|
606
741
|
// Check for interpolation #{...}
|
|
607
742
|
if (text[i] === '#' && i + 1 < text.length && text[i + 1] === '{') {
|
|
608
743
|
// Emit accumulated text
|
|
609
744
|
if (buf.length > 0) {
|
|
610
|
-
this.emit('TEXT', buf);
|
|
745
|
+
this.emit('TEXT', buf, this.pos + bufStart);
|
|
611
746
|
buf = '';
|
|
612
747
|
}
|
|
748
|
+
const interpStart = i;
|
|
613
749
|
i += 2; // skip #{
|
|
614
750
|
// Scan balanced braces to find closing }
|
|
615
751
|
let depth = 1;
|
|
@@ -641,10 +777,11 @@ class Lexer {
|
|
|
641
777
|
i++;
|
|
642
778
|
}
|
|
643
779
|
if (depth !== 0) {
|
|
644
|
-
throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
780
|
+
throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + interpStart + 1);
|
|
645
781
|
}
|
|
646
|
-
this.emit('INTERPOLATION', text.slice(start, i));
|
|
782
|
+
this.emit('INTERPOLATION', text.slice(start, i), this.pos + interpStart);
|
|
647
783
|
i++; // skip closing }
|
|
784
|
+
bufStart = i;
|
|
648
785
|
}
|
|
649
786
|
else {
|
|
650
787
|
buf += text[i];
|
|
@@ -653,42 +790,125 @@ class Lexer {
|
|
|
653
790
|
}
|
|
654
791
|
// Emit remaining text
|
|
655
792
|
if (buf.length > 0) {
|
|
656
|
-
this.emit('TEXT', buf);
|
|
793
|
+
this.emit('TEXT', buf, this.pos + bufStart);
|
|
657
794
|
}
|
|
658
795
|
}
|
|
659
796
|
// --- Balanced scanning helpers ---
|
|
797
|
+
// Scans a '(...)' group whose contents are captured verbatim (params, for
|
|
798
|
+
// headers, mixin call args), with the cursor on the '('. Returns the text
|
|
799
|
+
// between the parens.
|
|
800
|
+
//
|
|
801
|
+
// The group may span lines: it simply stays open until its parens balance.
|
|
802
|
+
// Unlike a multi-line attribute list there is no required shape, so the
|
|
803
|
+
// way TypeScript is usually wrapped works as written:
|
|
804
|
+
//
|
|
805
|
+
// template dashboard({
|
|
806
|
+
// title,
|
|
807
|
+
// }: DashboardProps)
|
|
808
|
+
//
|
|
809
|
+
// Continuation lines are consumed here, so they never reach the main loop
|
|
810
|
+
// and produce no INDENT/DEDENT/NEWLINE tokens. They keep their indentation
|
|
811
|
+
// relative to the line that opened the group. `//` comments are dropped:
|
|
812
|
+
// the captured text is re-emitted inside a single expression, where a
|
|
813
|
+
// comment would swallow whatever the compiler appends after it.
|
|
660
814
|
scanBalancedParens() {
|
|
661
815
|
if (this.currentLine[this.pos] !== '(') {
|
|
662
816
|
throw new LexerError('Expected opening parenthesis', this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
663
817
|
}
|
|
818
|
+
const ownerIndent = this.indentWidth;
|
|
819
|
+
const openLine = this.lineIndex + 1;
|
|
820
|
+
const openCol = this.indentWidth + this.pos + 1;
|
|
664
821
|
this.pos++; // skip opening (
|
|
665
822
|
let depth = 1;
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
823
|
+
const pieces = [];
|
|
824
|
+
const starts = [];
|
|
825
|
+
let prefix = ''; // this line's indentation relative to the owner
|
|
826
|
+
for (;;) {
|
|
827
|
+
const start = this.pos;
|
|
828
|
+
let end = -1; // where this line's content stops: the closing ')' or a comment
|
|
829
|
+
while (this.pos < this.currentLine.length) {
|
|
830
|
+
const ch = this.currentLine[this.pos];
|
|
831
|
+
if (ch === '(') {
|
|
832
|
+
depth++;
|
|
833
|
+
this.pos++;
|
|
834
|
+
}
|
|
835
|
+
else if (ch === ')') {
|
|
836
|
+
depth--;
|
|
837
|
+
if (depth === 0) {
|
|
838
|
+
end = this.pos;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
this.pos++;
|
|
842
|
+
}
|
|
843
|
+
else if (ch === '"' || ch === "'" || ch === '`') {
|
|
844
|
+
const quotePos = this.pos;
|
|
845
|
+
if (!this.skipStringInPlace(ch)) {
|
|
846
|
+
// A string has to close on its own line; letting it run
|
|
847
|
+
// on would throw the paren count off for every line after.
|
|
848
|
+
throw new LexerError('Unterminated string', this.lineIndex + 1, this.indentWidth + quotePos + 1);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
else if (ch === '/' && this.currentLine[this.pos + 1] === '/') {
|
|
852
|
+
end = this.pos;
|
|
853
|
+
this.pos = this.currentLine.length;
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
this.pos++;
|
|
857
|
+
}
|
|
678
858
|
}
|
|
679
|
-
|
|
680
|
-
|
|
859
|
+
const isComment = end !== -1 && depth > 0;
|
|
860
|
+
const text = this.currentLine.slice(start, end === -1 ? this.currentLine.length : end).trimEnd();
|
|
861
|
+
const line = this.lineIndex + 1;
|
|
862
|
+
if (pieces.length === 0) {
|
|
863
|
+
const lead = text.length - text.trimStart().length;
|
|
864
|
+
starts.push({ line, col: this.indentWidth + start + lead + 1 });
|
|
865
|
+
if (depth === 0) {
|
|
866
|
+
// Closed on the line it opened on: the common, single-line case.
|
|
867
|
+
this.pos++; // skip closing )
|
|
868
|
+
return { value: text.trim(), starts };
|
|
869
|
+
}
|
|
870
|
+
pieces.push(text.trimStart());
|
|
681
871
|
}
|
|
682
|
-
else {
|
|
683
|
-
|
|
872
|
+
else if (text !== '' || !isComment) {
|
|
873
|
+
// Lines holding nothing but a comment are dropped entirely.
|
|
874
|
+
// A kept line is `prefix + text`, so it starts at the owner's indent.
|
|
875
|
+
pieces.push(text === '' ? '' : prefix + text);
|
|
876
|
+
starts.push({ line, col: ownerIndent + 1 });
|
|
684
877
|
}
|
|
878
|
+
if (depth === 0)
|
|
879
|
+
break;
|
|
880
|
+
this.nextParamsLine(ownerIndent, openLine, openCol);
|
|
881
|
+
prefix = ' '.repeat(this.indentWidth - ownerIndent);
|
|
685
882
|
}
|
|
686
|
-
if (depth !== 0) {
|
|
687
|
-
throw new LexerError('Unterminated parentheses', this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
688
|
-
}
|
|
689
|
-
const content = this.currentLine.slice(start, this.pos);
|
|
690
883
|
this.pos++; // skip closing )
|
|
691
|
-
|
|
884
|
+
const content = pieces.join('\n');
|
|
885
|
+
return content.trim() === '' ? { value: '', starts: [] } : { value: content, starts };
|
|
886
|
+
}
|
|
887
|
+
// Moves onto the next non-blank line of a multi-line paren group. Lines in
|
|
888
|
+
// the group must be indented under its owner; the one exception is the
|
|
889
|
+
// line that closes it (e.g. '}: Props)'), which may sit level with the
|
|
890
|
+
// owner as long as it starts with a closing bracket. Anything else at or
|
|
891
|
+
// left of the owner means the ')' was forgotten, so stop there rather
|
|
892
|
+
// than swallowing the rest of the file.
|
|
893
|
+
nextParamsLine(ownerIndent, openLine, openCol) {
|
|
894
|
+
for (;;) {
|
|
895
|
+
if (this.lineIndex + 1 >= this.lines.length) {
|
|
896
|
+
throw new LexerError('Unterminated parentheses', openLine, openCol);
|
|
897
|
+
}
|
|
898
|
+
this.lineIndex++;
|
|
899
|
+
const rawLine = this.lines[this.lineIndex];
|
|
900
|
+
if (rawLine.trim().length === 0)
|
|
901
|
+
continue;
|
|
902
|
+
this.indentWidth = this.measureIndent(rawLine);
|
|
903
|
+
this.currentLine = rawLine.slice(this.indentWidth);
|
|
904
|
+
this.pos = 0;
|
|
905
|
+
if (this.indentWidth < ownerIndent ||
|
|
906
|
+
(this.indentWidth === ownerIndent && !/^[)}\]]/.test(this.currentLine))) {
|
|
907
|
+
throw new LexerError(`Expected ')' to close the parentheses opened at line ${openLine}; ` +
|
|
908
|
+
'continuation lines must be indented under the line that opens them', this.lineIndex + 1, this.indentWidth + 1);
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
692
912
|
}
|
|
693
913
|
scanString(quote) {
|
|
694
914
|
const start = this.pos;
|
|
@@ -745,7 +965,10 @@ class Lexer {
|
|
|
745
965
|
}
|
|
746
966
|
throw new LexerError('Unterminated template literal', this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
747
967
|
}
|
|
748
|
-
/**
|
|
968
|
+
/**
|
|
969
|
+
* Skip over a string in-place (used within balanced paren scanning).
|
|
970
|
+
* Returns false when the line ends before the string closes.
|
|
971
|
+
*/
|
|
749
972
|
skipStringInPlace(quote) {
|
|
750
973
|
this.pos++; // skip opening quote
|
|
751
974
|
while (this.pos < this.currentLine.length) {
|
|
@@ -754,7 +977,7 @@ class Lexer {
|
|
|
754
977
|
}
|
|
755
978
|
else if (this.currentLine[this.pos] === quote) {
|
|
756
979
|
this.pos++;
|
|
757
|
-
return;
|
|
980
|
+
return true;
|
|
758
981
|
}
|
|
759
982
|
else if (quote === '`' &&
|
|
760
983
|
this.currentLine[this.pos] === '$' &&
|
|
@@ -764,21 +987,27 @@ class Lexer {
|
|
|
764
987
|
this.pos += 2;
|
|
765
988
|
let depth = 1;
|
|
766
989
|
while (this.pos < this.currentLine.length && depth > 0) {
|
|
767
|
-
if (this.currentLine[this.pos] === '{')
|
|
990
|
+
if (this.currentLine[this.pos] === '{') {
|
|
768
991
|
depth++;
|
|
769
|
-
|
|
992
|
+
}
|
|
993
|
+
else if (this.currentLine[this.pos] === '}') {
|
|
770
994
|
depth--;
|
|
771
|
-
|
|
772
|
-
|
|
995
|
+
}
|
|
996
|
+
else if (this.currentLine[this.pos] === '"' ||
|
|
997
|
+
this.currentLine[this.pos] === "'" ||
|
|
998
|
+
this.currentLine[this.pos] === '`') {
|
|
999
|
+
this.skipStringInPlace(this.currentLine[this.pos]);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
this.pos++;
|
|
773
1003
|
}
|
|
774
|
-
|
|
775
|
-
this.pos++; // skip closing }
|
|
776
|
-
return;
|
|
1004
|
+
// fall through to keep scanning for the closing backtick
|
|
777
1005
|
}
|
|
778
1006
|
else {
|
|
779
1007
|
this.pos++;
|
|
780
1008
|
}
|
|
781
1009
|
}
|
|
1010
|
+
return false;
|
|
782
1011
|
}
|
|
783
1012
|
// --- Utility methods ---
|
|
784
1013
|
scanIdentifier() {
|
|
@@ -794,17 +1023,36 @@ class Lexer {
|
|
|
794
1023
|
}
|
|
795
1024
|
return this.currentLine.slice(start, this.pos);
|
|
796
1025
|
}
|
|
1026
|
+
// Scans a '(...)' group at the cursor, if there is one, as a PARAMS token
|
|
1027
|
+
// anchored on the '('. The group may run onto later lines, so the anchor
|
|
1028
|
+
// is taken before scanning moves the cursor.
|
|
1029
|
+
scanParamsIfPresent() {
|
|
1030
|
+
if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
|
|
1031
|
+
const line = this.lineIndex + 1;
|
|
1032
|
+
const col = this.indentWidth + this.pos + 1;
|
|
1033
|
+
const { value, starts } = this.scanBalancedParens();
|
|
1034
|
+
this.tokens.push({ type: 'PARAMS', value, line, col, starts });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
// Returns the first non-space position at or after `pos` on the current line.
|
|
1038
|
+
skipSpacesFrom(pos) {
|
|
1039
|
+
while (pos < this.currentLine.length && this.currentLine[pos] === ' ')
|
|
1040
|
+
pos++;
|
|
1041
|
+
return pos;
|
|
1042
|
+
}
|
|
797
1043
|
skipSpaces() {
|
|
798
1044
|
while (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
|
|
799
1045
|
this.pos++;
|
|
800
1046
|
}
|
|
801
1047
|
}
|
|
802
|
-
|
|
1048
|
+
// `start` is the token's position on the current line; it defaults to the
|
|
1049
|
+
// cursor, which for scanned tokens is already past the token itself.
|
|
1050
|
+
emit(type, value, start = this.pos) {
|
|
803
1051
|
this.tokens.push({
|
|
804
1052
|
type,
|
|
805
1053
|
value,
|
|
806
1054
|
line: this.lineIndex + 1,
|
|
807
|
-
col: this.indentWidth +
|
|
1055
|
+
col: this.indentWidth + start + 1,
|
|
808
1056
|
});
|
|
809
1057
|
}
|
|
810
1058
|
}
|