@lithent/lithent-template-parser 0.1.0 → 0.1.1
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 +1 -1
- package/dist/codegen/createElement.d.cts +10 -0
- package/dist/codegen/createElement.d.ts +1 -1
- package/dist/codegen/index.d.cts +1 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/compiler.d.cts +26 -0
- package/dist/compiler.d.ts +8 -8
- package/dist/docPipe/index.d.cts +40 -0
- package/dist/docPipe/index.d.ts +2 -2
- package/dist/docPipe/scan.d.cts +8 -0
- package/dist/docPipe/scan.d.ts +2 -0
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +9 -9
- package/dist/index.mjs +955 -1192
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.cjs +12 -12
- package/dist/index.umd.cjs.map +1 -1
- package/dist/parser/ast.d.cts +207 -0
- package/dist/parser/ast.d.ts +1 -1
- package/dist/parser/lexer.d.cts +109 -0
- package/dist/parser/lexer.d.ts +1 -1
- package/dist/parser/parser.d.cts +80 -0
- package/dist/parser/parser.d.ts +5 -4
- package/dist/parser/tokens.d.cts +51 -0
- package/dist/transform/directives.d.cts +30 -0
- package/dist/transform/directives.d.ts +1 -1
- package/dist/transform/index.d.cts +14 -0
- package/dist/transform/index.d.ts +5 -5
- package/dist/transform/optimize.d.cts +21 -0
- package/dist/transform/optimize.d.ts +1 -1
- package/package.json +2 -2
- package/src/codegen/createElement.ts +48 -332
- package/src/compiler.ts +1 -1
- package/src/docPipe/scan.ts +209 -246
- package/src/parser/lexer.ts +41 -91
- package/src/parser/parser.ts +45 -39
- package/src/tests/compiler.test.ts +2 -2
- package/src/tests/parser.test.ts +2 -2
- package/src/tests/regressions.test.ts +200 -0
- package/src/transform/directives.ts +25 -2
- package/src/transform/optimize.ts +37 -25
package/src/parser/lexer.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { skipJsExpression } from '../docPipe/scan';
|
|
1
2
|
import {
|
|
2
3
|
Token,
|
|
3
4
|
TokenType,
|
|
@@ -139,16 +140,23 @@ export class Lexer {
|
|
|
139
140
|
// Consume <!--
|
|
140
141
|
value += this.advance() + this.advance() + this.advance() + this.advance();
|
|
141
142
|
|
|
143
|
+
let closed = false;
|
|
142
144
|
// Read until -->
|
|
143
145
|
while (!this.isEOF()) {
|
|
144
146
|
if (this.peek() === '-' && this.peek(1) === '-' && this.peek(2) === '>') {
|
|
145
147
|
value += this.advance() + this.advance() + this.advance();
|
|
148
|
+
closed = true;
|
|
146
149
|
break;
|
|
147
150
|
}
|
|
148
151
|
value += this.advance();
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
const end = this.getCurrentPosition();
|
|
155
|
+
if (!closed)
|
|
156
|
+
throw Object.assign(new Error('Unclosed comment'), {
|
|
157
|
+
line: start.line,
|
|
158
|
+
column: start.column,
|
|
159
|
+
});
|
|
152
160
|
this.tokens.push(createToken(TokenType.COMMENT, value, start, end));
|
|
153
161
|
}
|
|
154
162
|
|
|
@@ -324,6 +332,7 @@ export class Lexer {
|
|
|
324
332
|
const quote = this.advance(); // " or '
|
|
325
333
|
let value = quote;
|
|
326
334
|
let escaped = false;
|
|
335
|
+
let closed = false;
|
|
327
336
|
|
|
328
337
|
while (!this.isEOF()) {
|
|
329
338
|
const char = this.peek();
|
|
@@ -342,6 +351,7 @@ export class Lexer {
|
|
|
342
351
|
|
|
343
352
|
if (char === quote) {
|
|
344
353
|
value += this.advance();
|
|
354
|
+
closed = true;
|
|
345
355
|
break;
|
|
346
356
|
}
|
|
347
357
|
|
|
@@ -349,6 +359,11 @@ export class Lexer {
|
|
|
349
359
|
}
|
|
350
360
|
|
|
351
361
|
const end = this.getCurrentPosition();
|
|
362
|
+
if (!closed)
|
|
363
|
+
throw Object.assign(new Error('Unclosed attribute string'), {
|
|
364
|
+
line: start.line,
|
|
365
|
+
column: start.column,
|
|
366
|
+
});
|
|
352
367
|
this.tokens.push(createToken(TokenType.STRING_LITERAL, value, start, end));
|
|
353
368
|
}
|
|
354
369
|
|
|
@@ -386,100 +401,35 @@ export class Lexer {
|
|
|
386
401
|
createToken(TokenType.EXPRESSION_START, '{', startPos, openEnd)
|
|
387
402
|
);
|
|
388
403
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const consumeChar = (): string => {
|
|
397
|
-
const charStart = this.getCurrentPosition();
|
|
398
|
-
const char = this.advance();
|
|
399
|
-
const charEnd = this.getCurrentPosition();
|
|
400
|
-
|
|
401
|
-
value += char;
|
|
402
|
-
if (!contentStart) {
|
|
403
|
-
contentStart = charStart;
|
|
404
|
-
}
|
|
405
|
-
contentEnd = charEnd;
|
|
406
|
-
|
|
407
|
-
return char;
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
while (!this.isEOF() && depth > 0) {
|
|
411
|
-
const char = this.peek();
|
|
412
|
-
|
|
413
|
-
// Handle string literals inside expressions
|
|
414
|
-
if (inString) {
|
|
415
|
-
if (escaped) {
|
|
416
|
-
consumeChar();
|
|
417
|
-
escaped = false;
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
if (char === '\\') {
|
|
421
|
-
escaped = true;
|
|
422
|
-
consumeChar();
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
if (char === inString) {
|
|
426
|
-
consumeChar();
|
|
427
|
-
inString = null;
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
consumeChar();
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Not in string
|
|
435
|
-
if (char === '"' || char === "'" || char === '`') {
|
|
436
|
-
inString = char;
|
|
437
|
-
consumeChar();
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (char === '{') {
|
|
442
|
-
depth++;
|
|
443
|
-
consumeChar();
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (char === '}') {
|
|
448
|
-
depth--;
|
|
449
|
-
if (depth === 0) {
|
|
450
|
-
// End of expression
|
|
451
|
-
if (value.trim()) {
|
|
452
|
-
const start = contentStart ?? openEnd;
|
|
453
|
-
const end = contentEnd;
|
|
454
|
-
this.tokens.push(
|
|
455
|
-
createToken(TokenType.EXPRESSION_CONTENT, value, start, end)
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const closeStart = this.getCurrentPosition();
|
|
460
|
-
this.advance(); // consume }
|
|
461
|
-
const closeEnd = this.getCurrentPosition();
|
|
462
|
-
this.tokens.push(
|
|
463
|
-
createToken(TokenType.EXPRESSION_END, '}', closeStart, closeEnd)
|
|
464
|
-
);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
consumeChar();
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
consumeChar();
|
|
404
|
+
const contentStart = this.getCurrentPosition();
|
|
405
|
+
const end = skipJsExpression(this.input, this.position);
|
|
406
|
+
if (end === null) {
|
|
407
|
+
throw Object.assign(new Error('Unclosed expression'), {
|
|
408
|
+
line: startPos.line,
|
|
409
|
+
column: startPos.column,
|
|
410
|
+
});
|
|
472
411
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (value.trim())
|
|
477
|
-
const start = contentStart ?? openEnd;
|
|
478
|
-
const end = contentStart ? contentEnd : this.getCurrentPosition();
|
|
412
|
+
const value = this.input.slice(this.position, end - 1);
|
|
413
|
+
while (this.position < end - 1) this.advance();
|
|
414
|
+
const contentEnd = this.getCurrentPosition();
|
|
415
|
+
if (value.trim())
|
|
479
416
|
this.tokens.push(
|
|
480
|
-
createToken(
|
|
417
|
+
createToken(
|
|
418
|
+
TokenType.EXPRESSION_CONTENT,
|
|
419
|
+
value,
|
|
420
|
+
contentStart,
|
|
421
|
+
contentEnd
|
|
422
|
+
)
|
|
481
423
|
);
|
|
482
|
-
|
|
424
|
+
this.advance();
|
|
425
|
+
this.tokens.push(
|
|
426
|
+
createToken(
|
|
427
|
+
TokenType.EXPRESSION_END,
|
|
428
|
+
'}',
|
|
429
|
+
contentEnd,
|
|
430
|
+
this.getCurrentPosition()
|
|
431
|
+
)
|
|
432
|
+
);
|
|
483
433
|
}
|
|
484
434
|
|
|
485
435
|
/**
|
package/src/parser/parser.ts
CHANGED
|
@@ -25,9 +25,11 @@ export class Parser {
|
|
|
25
25
|
private tokens: Token[];
|
|
26
26
|
private position: number = 0;
|
|
27
27
|
|
|
28
|
-
constructor(tokens: Token[]) {
|
|
28
|
+
constructor(tokens: Token[], preserveWhitespace = false) {
|
|
29
29
|
// Filter out whitespace tokens for easier parsing
|
|
30
|
-
this.tokens =
|
|
30
|
+
this.tokens = preserveWhitespace
|
|
31
|
+
? tokens
|
|
32
|
+
: tokens.filter(t => t.type !== TokenType.WHITESPACE);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -67,18 +69,16 @@ export class Parser {
|
|
|
67
69
|
case TokenType.FRAGMENT_OPEN:
|
|
68
70
|
return this.parseFragment();
|
|
69
71
|
case TokenType.TEXT:
|
|
72
|
+
case TokenType.WHITESPACE:
|
|
70
73
|
return this.parseText();
|
|
71
74
|
case TokenType.EXPRESSION_START:
|
|
72
75
|
return this.parseInterpolation();
|
|
73
76
|
case TokenType.COMMENT:
|
|
74
77
|
return this.parseComment();
|
|
75
78
|
case TokenType.TAG_CLOSE_START:
|
|
76
|
-
|
|
77
|
-
this.advance();
|
|
78
|
-
return null;
|
|
79
|
+
throw this.error('Unexpected closing tag', token);
|
|
79
80
|
case TokenType.FRAGMENT_CLOSE:
|
|
80
|
-
this.
|
|
81
|
-
return null;
|
|
81
|
+
throw this.error('Unexpected fragment closing tag', token);
|
|
82
82
|
case TokenType.EOF:
|
|
83
83
|
return null;
|
|
84
84
|
default:
|
|
@@ -108,9 +108,10 @@ export class Parser {
|
|
|
108
108
|
return createFragmentNode(children, start, closeToken.end);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
throw new Error(
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
throw Object.assign(new Error('Unclosed fragment'), {
|
|
112
|
+
line: start.line,
|
|
113
|
+
column: start.column,
|
|
114
|
+
});
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/**
|
|
@@ -204,17 +205,7 @@ export class Parser {
|
|
|
204
205
|
);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
|
|
208
|
-
const end = children.length > 0 ? children[children.length - 1].end : start;
|
|
209
|
-
return createElementNode(
|
|
210
|
-
tag,
|
|
211
|
-
attributes,
|
|
212
|
-
directives,
|
|
213
|
-
children,
|
|
214
|
-
false,
|
|
215
|
-
start,
|
|
216
|
-
end
|
|
217
|
-
);
|
|
208
|
+
throw this.error(`Unclosed tag <${tag}>`, tagToken);
|
|
218
209
|
}
|
|
219
210
|
|
|
220
211
|
/**
|
|
@@ -298,17 +289,14 @@ export class Parser {
|
|
|
298
289
|
isDynamic = true;
|
|
299
290
|
this.advance();
|
|
300
291
|
} else {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (this.check(TokenType.EXPRESSION_END)) {
|
|
306
|
-
end = this.peek().end;
|
|
307
|
-
this.advance(); // consume }
|
|
292
|
+
throw this.error(
|
|
293
|
+
'Expected a non-empty attribute expression',
|
|
294
|
+
exprToken
|
|
295
|
+
);
|
|
308
296
|
}
|
|
297
|
+
end = this.expect(TokenType.EXPRESSION_END).end;
|
|
309
298
|
} else {
|
|
310
|
-
|
|
311
|
-
return createAttributeNode(name, null, false, start, nameToken.end);
|
|
299
|
+
throw this.error('Expected a quoted attribute or expression', valueToken);
|
|
312
300
|
}
|
|
313
301
|
|
|
314
302
|
return createAttributeNode(name, value, isDynamic, start, end);
|
|
@@ -335,6 +323,8 @@ export class Parser {
|
|
|
335
323
|
this.advance();
|
|
336
324
|
}
|
|
337
325
|
|
|
326
|
+
if (!condition)
|
|
327
|
+
throw this.error('Expected a non-empty condition', exprToken);
|
|
338
328
|
const end = this.peek().end;
|
|
339
329
|
this.expect(TokenType.EXPRESSION_END);
|
|
340
330
|
|
|
@@ -364,7 +354,10 @@ export class Parser {
|
|
|
364
354
|
this.expect(TokenType.EXPRESSION_END);
|
|
365
355
|
|
|
366
356
|
// Parse for expression: "item in list" or "(item, index) in list"
|
|
367
|
-
const { item, index, list } = this.parseForExpression(
|
|
357
|
+
const { item, index, list } = this.parseForExpression(
|
|
358
|
+
expression,
|
|
359
|
+
exprToken
|
|
360
|
+
);
|
|
368
361
|
|
|
369
362
|
return createDirectiveForNode(item, list, index, start, end);
|
|
370
363
|
}
|
|
@@ -377,7 +370,10 @@ export class Parser {
|
|
|
377
370
|
/**
|
|
378
371
|
* Parse l-for expression
|
|
379
372
|
*/
|
|
380
|
-
private parseForExpression(
|
|
373
|
+
private parseForExpression(
|
|
374
|
+
expression: string,
|
|
375
|
+
token: Token
|
|
376
|
+
): {
|
|
381
377
|
item: string;
|
|
382
378
|
index?: string;
|
|
383
379
|
list: string;
|
|
@@ -408,11 +404,10 @@ export class Parser {
|
|
|
408
404
|
};
|
|
409
405
|
}
|
|
410
406
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
};
|
|
407
|
+
throw this.error(
|
|
408
|
+
'Invalid l-for: expected item in list or (item, index) in list',
|
|
409
|
+
token
|
|
410
|
+
);
|
|
416
411
|
}
|
|
417
412
|
|
|
418
413
|
/**
|
|
@@ -439,6 +434,8 @@ export class Parser {
|
|
|
439
434
|
this.advance();
|
|
440
435
|
}
|
|
441
436
|
|
|
437
|
+
if (!expression)
|
|
438
|
+
throw this.error('Expected a non-empty interpolation', exprToken);
|
|
442
439
|
const end = this.peek().end;
|
|
443
440
|
this.expect(TokenType.EXPRESSION_END);
|
|
444
441
|
|
|
@@ -455,6 +452,15 @@ export class Parser {
|
|
|
455
452
|
return createCommentNode(content, token.start, token.end);
|
|
456
453
|
}
|
|
457
454
|
|
|
455
|
+
private error(message: string, token: Token): Error {
|
|
456
|
+
return Object.assign(
|
|
457
|
+
new Error(
|
|
458
|
+
`${message} at line ${token.start.line}, column ${token.start.column}`
|
|
459
|
+
),
|
|
460
|
+
{ line: token.start.line, column: token.start.column }
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
458
464
|
/**
|
|
459
465
|
* Peek at current token without consuming
|
|
460
466
|
*/
|
|
@@ -508,7 +514,7 @@ export class Parser {
|
|
|
508
514
|
/**
|
|
509
515
|
* Parse template string into AST
|
|
510
516
|
*/
|
|
511
|
-
export function parse(tokens: Token[]): RootNode {
|
|
512
|
-
const parser = new Parser(tokens);
|
|
517
|
+
export function parse(tokens: Token[], preserveWhitespace = false): RootNode {
|
|
518
|
+
const parser = new Parser(tokens, preserveWhitespace);
|
|
513
519
|
return parser.parse();
|
|
514
520
|
}
|
|
@@ -351,7 +351,7 @@ describe('Compiler', () => {
|
|
|
351
351
|
</ul>
|
|
352
352
|
</article>
|
|
353
353
|
</main>
|
|
354
|
-
<footer l-
|
|
354
|
+
<footer l-if={items.length === 0}>
|
|
355
355
|
<p>No items</p>
|
|
356
356
|
</footer>
|
|
357
357
|
</section>
|
|
@@ -383,7 +383,7 @@ describe('Compiler', () => {
|
|
|
383
383
|
</>
|
|
384
384
|
</section>
|
|
385
385
|
</div>
|
|
386
|
-
<footer l-
|
|
386
|
+
<footer l-if={!sidebar}>
|
|
387
387
|
<p>Sidebar disabled</p>
|
|
388
388
|
</footer>
|
|
389
389
|
</>
|
package/src/tests/parser.test.ts
CHANGED
|
@@ -622,7 +622,7 @@ describe('Parser', () => {
|
|
|
622
622
|
</ul>
|
|
623
623
|
</article>
|
|
624
624
|
</main>
|
|
625
|
-
<footer l-
|
|
625
|
+
<footer l-if={items.length === 0}>
|
|
626
626
|
<p>No items</p>
|
|
627
627
|
</footer>
|
|
628
628
|
</section>
|
|
@@ -654,7 +654,7 @@ describe('Parser', () => {
|
|
|
654
654
|
</>
|
|
655
655
|
</section>
|
|
656
656
|
</div>
|
|
657
|
-
<footer l-
|
|
657
|
+
<footer l-if={!sidebar}>
|
|
658
658
|
<p>Sidebar disabled</p>
|
|
659
659
|
</footer>
|
|
660
660
|
</>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { h, Fragment } from 'lithent';
|
|
3
|
+
import { renderToString } from '../../../../ssr/src/renderToString';
|
|
4
|
+
import { compile, type CompileOptions } from '../compiler';
|
|
5
|
+
import { transformDocument } from '../docPipe';
|
|
6
|
+
|
|
7
|
+
function evaluate(
|
|
8
|
+
template: string,
|
|
9
|
+
scope: Record<string, unknown> = {},
|
|
10
|
+
options?: CompileOptions
|
|
11
|
+
) {
|
|
12
|
+
const result = compile(template, options);
|
|
13
|
+
expect(result.errors).toEqual([]);
|
|
14
|
+
return new Function(
|
|
15
|
+
'h',
|
|
16
|
+
'Fragment',
|
|
17
|
+
...Object.keys(scope),
|
|
18
|
+
`return (${result.code});`
|
|
19
|
+
)(h, Fragment, ...Object.values(scope));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function render(
|
|
23
|
+
template: string,
|
|
24
|
+
scope: Record<string, unknown> = {},
|
|
25
|
+
options?: CompileOptions
|
|
26
|
+
) {
|
|
27
|
+
return renderToString(evaluate(template, scope, options));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('executable template regressions', () => {
|
|
31
|
+
it('renders hyphenated and namespaced attributes', () => {
|
|
32
|
+
expect(render('<div data-id="x" aria-label="hello" />')).toBe(
|
|
33
|
+
'<div data-id="x" aria-label="hello"></div>'
|
|
34
|
+
);
|
|
35
|
+
expect(() => evaluate('<svg xml:lang="en" />')).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('preserves backslashes and line breaks in static properties', () => {
|
|
39
|
+
expect(render(String.raw`<div title="C:\temp" />`)).toContain(
|
|
40
|
+
String.raw`title="C:\temp"`
|
|
41
|
+
);
|
|
42
|
+
expect(render('<div title="hello\nworld" />')).toContain(
|
|
43
|
+
'title="hello\nworld"'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('loops before evaluating conditions that use the loop variable', () => {
|
|
48
|
+
expect(
|
|
49
|
+
render(
|
|
50
|
+
'<ul><li l-for={item in items} l-if={item.visible}>{item.label}</li></ul>',
|
|
51
|
+
{
|
|
52
|
+
items: [
|
|
53
|
+
{ visible: true, label: 'yes' },
|
|
54
|
+
{ visible: false, label: 'no' },
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
).toBe('<ul><li>yes</li></ul>');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('preserves loops on each branch of a conditional chain', () => {
|
|
62
|
+
const template =
|
|
63
|
+
'<ul><li l-if={ok}>primary</li><li l-else l-for={other in fallback}>{other}</li></ul>';
|
|
64
|
+
expect(
|
|
65
|
+
render(template, { ok: true, items: ['a', 'b'], fallback: ['c'] })
|
|
66
|
+
).toBe('<ul><li>primary</li></ul>');
|
|
67
|
+
expect(
|
|
68
|
+
render(template, { ok: false, items: ['a', 'b'], fallback: ['c'] })
|
|
69
|
+
).toBe('<ul><li>c</li></ul>');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('allows comments and whitespace between conditional branches', () => {
|
|
73
|
+
const template =
|
|
74
|
+
'<main><div l-if={ok}>yes</div>\n<!-- comment --><div l-else>no</div></main>';
|
|
75
|
+
expect(render(template, { ok: true })).toBe('<main><div>yes</div></main>');
|
|
76
|
+
expect(render(template, { ok: false })).toBe('<main><div>no</div></main>');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('preserves meaningful spaces around interpolations and inline elements', () => {
|
|
80
|
+
expect(render('<p>Hello {name}, welcome!</p>', { name: 'Ada' })).toBe(
|
|
81
|
+
'<p>Hello Ada, welcome!</p>'
|
|
82
|
+
);
|
|
83
|
+
expect(render('<p><b>Hello</b> <i>world</i></p>')).toBe(
|
|
84
|
+
'<p><b>Hello</b> <i>world</i></p>'
|
|
85
|
+
);
|
|
86
|
+
expect(
|
|
87
|
+
render('<p> Hello</p>', {}, { transform: { optimize: false } })
|
|
88
|
+
).toBe('<p> Hello</p>');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('preserves preformatted content and explicitly disabled whitespace trimming', () => {
|
|
92
|
+
expect(render('<pre>\n Hello {name}\n</pre>', { name: 'Ada' })).toBe(
|
|
93
|
+
'<pre>\n Hello Ada\n</pre>'
|
|
94
|
+
);
|
|
95
|
+
expect(
|
|
96
|
+
render(
|
|
97
|
+
'<p> Hello </p>',
|
|
98
|
+
{},
|
|
99
|
+
{
|
|
100
|
+
transform: {
|
|
101
|
+
optimizeOptions: { trimText: false, removeWhitespace: false },
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
).toBe('<p> Hello </p>');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('preserves comma-expression values in children and properties', () => {
|
|
109
|
+
expect(render('<div>{a, b}</div>', { a: 'first', b: 'second' })).toBe(
|
|
110
|
+
'<div>second</div>'
|
|
111
|
+
);
|
|
112
|
+
expect(render('<div title={a, b} />', { a: 'first', b: 'second' })).toBe(
|
|
113
|
+
'<div title="second"></div>'
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
['1 /* } */ + 2', '3'],
|
|
119
|
+
['1 // }\n + 2', '3'],
|
|
120
|
+
['String(/}/.test(value))', 'true'],
|
|
121
|
+
['String(/[/{}]/.test(value))', 'true'],
|
|
122
|
+
['8 / 2 / 2', '2'],
|
|
123
|
+
['`hello ${`${value}`}`', 'hello }'],
|
|
124
|
+
])('handles JavaScript lexical contexts in %s', (expression, expected) => {
|
|
125
|
+
expect(render(`<p>{${expression}}</p>`, { value: '}' })).toBe(
|
|
126
|
+
`<p>${expected}</p>`
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('compiles embedded templates after return', () => {
|
|
131
|
+
expect(
|
|
132
|
+
render(
|
|
133
|
+
'<div>{items.map(item => { return <span>{item}</span>; })}</div>',
|
|
134
|
+
{ items: ['a', 'b'] }
|
|
135
|
+
)
|
|
136
|
+
).toBe('<div><span>a</span><span>b</span></div>');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each([
|
|
140
|
+
'<div>hello',
|
|
141
|
+
'</div>',
|
|
142
|
+
'<!-- unclosed',
|
|
143
|
+
'<div title="unclosed',
|
|
144
|
+
'<div l-else>fallback</div>',
|
|
145
|
+
'<div l-else-if={ok} />',
|
|
146
|
+
'<li l-for={item of items}>{item}</li>',
|
|
147
|
+
'<div value={} />',
|
|
148
|
+
'<div l-if={} />',
|
|
149
|
+
'<div value={foo />',
|
|
150
|
+
'<div l-if={ok} /><span /><div l-else />',
|
|
151
|
+
'<><div /></div>',
|
|
152
|
+
'<>',
|
|
153
|
+
])('reports positioned errors for malformed input %s', template => {
|
|
154
|
+
const result = compile(template);
|
|
155
|
+
expect(result.code).toBe('');
|
|
156
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
157
|
+
expect(result.errors[0].line).toBeGreaterThan(0);
|
|
158
|
+
expect(result.errors[0].column).toBeGreaterThan(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('document scanning regressions', () => {
|
|
163
|
+
it('transforms bare return templates', () => {
|
|
164
|
+
const result = transformDocument('function View() { return <div />; }');
|
|
165
|
+
expect(result.errors).toEqual([]);
|
|
166
|
+
expect(result.transformed).toBe(true);
|
|
167
|
+
expect(new Function('h', `${result.code}; return View();`)(h).tag).toBe(
|
|
168
|
+
'div'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not interpret comment-looking template text as JavaScript', () => {
|
|
173
|
+
const result = transformDocument(
|
|
174
|
+
'const View=()=> <p>https://example.com /* hello */</p>;'
|
|
175
|
+
);
|
|
176
|
+
expect(result.transformed).toBe(true);
|
|
177
|
+
expect(
|
|
178
|
+
renderToString(new Function('h', `${result.code}; return View();`)(h))
|
|
179
|
+
).toBe('<p>https://example.com /* hello */</p>');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('preserves regexes, strings, comments, comparisons and TypeScript generics', () => {
|
|
183
|
+
const source = String.raw`const re = /<img \/>/; const text = '<div />'; // <a />
|
|
184
|
+
const less = a < b; const identity = <T>(value: T) => value;`;
|
|
185
|
+
expect(transformDocument(source).code).toBe(source);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('preserves regex expression statements after control parentheses and blocks', () => {
|
|
189
|
+
const source = String.raw`if (ok) /<img \/>/.test(html); if (ok) {} /<div \/>/.test(html);`;
|
|
190
|
+
expect(transformDocument(source).code).toBe(source);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('transforms templates after division and preserves regexes elsewhere', () => {
|
|
194
|
+
const source = String.raw`const re = /<img \/>/; const n = 8 / 2; const View = () => <p>{n}</p>;`;
|
|
195
|
+
const result = transformDocument(source);
|
|
196
|
+
expect(result.transformed).toBe(true);
|
|
197
|
+
const view = new Function('h', `${result.code}; return View();`)(h);
|
|
198
|
+
expect(renderToString(view)).toBe('<p>4</p>');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -46,6 +46,18 @@ function transformNodeList(nodes: TemplateNode[]): TemplateNode[] {
|
|
|
46
46
|
children: transformNodeList(node.children),
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
const orphan = node.directives.find(
|
|
50
|
+
d =>
|
|
51
|
+
d.type === NodeType.DIRECTIVE_ELSE_IF ||
|
|
52
|
+
d.type === NodeType.DIRECTIVE_ELSE
|
|
53
|
+
);
|
|
54
|
+
if (orphan) {
|
|
55
|
+
throw Object.assign(
|
|
56
|
+
new Error('l-else-if/l-else must follow l-if or l-else-if'),
|
|
57
|
+
{ line: orphan.start.line, column: orphan.start.column }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
// Check if this starts a conditional chain
|
|
50
62
|
const ifDirective = node.directives.find(
|
|
51
63
|
d => d.type === NodeType.DIRECTIVE_IF
|
|
@@ -54,15 +66,24 @@ function transformNodeList(nodes: TemplateNode[]): TemplateNode[] {
|
|
|
54
66
|
if (ifDirective) {
|
|
55
67
|
// This starts a conditional chain, look ahead for else-if/else
|
|
56
68
|
const conditionalGroup: ConditionalGroup = {
|
|
57
|
-
if: transformedElement,
|
|
69
|
+
if: { ...transformedElement },
|
|
58
70
|
elseIfs: [],
|
|
59
71
|
};
|
|
60
72
|
|
|
61
73
|
// Look ahead for else-if and else
|
|
62
74
|
let j = i + 1;
|
|
75
|
+
let consumed = i;
|
|
63
76
|
while (j < nodes.length) {
|
|
64
77
|
const nextNode = nodes[j];
|
|
65
78
|
|
|
79
|
+
if (
|
|
80
|
+
nextNode.type === NodeType.COMMENT ||
|
|
81
|
+
(nextNode.type === NodeType.TEXT && !nextNode.content.trim())
|
|
82
|
+
) {
|
|
83
|
+
j++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
if (nextNode.type === NodeType.ELEMENT) {
|
|
67
88
|
const elseIfDirective = nextNode.directives.find(
|
|
68
89
|
d => d.type === NodeType.DIRECTIVE_ELSE_IF
|
|
@@ -77,6 +98,7 @@ function transformNodeList(nodes: TemplateNode[]): TemplateNode[] {
|
|
|
77
98
|
...nextNode,
|
|
78
99
|
children: transformNodeList(nextNode.children),
|
|
79
100
|
});
|
|
101
|
+
consumed = j;
|
|
80
102
|
j++;
|
|
81
103
|
continue;
|
|
82
104
|
}
|
|
@@ -86,6 +108,7 @@ function transformNodeList(nodes: TemplateNode[]): TemplateNode[] {
|
|
|
86
108
|
...nextNode,
|
|
87
109
|
children: transformNodeList(nextNode.children),
|
|
88
110
|
};
|
|
111
|
+
consumed = j;
|
|
89
112
|
j++;
|
|
90
113
|
break;
|
|
91
114
|
}
|
|
@@ -100,7 +123,7 @@ function transformNodeList(nodes: TemplateNode[]): TemplateNode[] {
|
|
|
100
123
|
(transformedElement as any).__conditionalGroup = conditionalGroup;
|
|
101
124
|
|
|
102
125
|
// Skip the else-if/else nodes we've processed
|
|
103
|
-
i =
|
|
126
|
+
i = consumed;
|
|
104
127
|
}
|
|
105
128
|
|
|
106
129
|
result.push(transformedElement);
|