gofront 0.0.8 → 0.0.9
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 +84 -8
- package/package.json +3 -2
- package/src/codegen.js +278 -1
- package/src/compiler.js +37 -3
- package/src/index.js +9 -5
- package/src/lexer.js +14 -14
- package/src/parser/expressions.js +3 -2
- package/src/parser/statements.js +5 -1
- package/src/parser.js +8 -1
- package/src/templ-lexer.js +737 -0
- package/src/templ-parser.js +235 -0
- package/src/typechecker/types.js +6 -3
- package/src/typechecker.js +30 -2
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// Templ lexer: tokenizes .templ files into a stream of tokens.
|
|
2
|
+
//
|
|
3
|
+
// A .templ file is a Go-like file where `templ Name(params) { ... }` declarations
|
|
4
|
+
// contain HTML bodies instead of Go statements. All other constructs (package,
|
|
5
|
+
// import, regular func, var, const, type) are lexed as normal Go.
|
|
6
|
+
//
|
|
7
|
+
// Token types produced (in addition to the standard Go token types from lexer.js):
|
|
8
|
+
// TEMPL_KW — the `templ` keyword
|
|
9
|
+
// HTML_OPEN — <tag attr...> (includes the `>`)
|
|
10
|
+
// HTML_CLOSE — </tag>
|
|
11
|
+
// HTML_SELF — <tag .../>
|
|
12
|
+
// HTML_TEXT — literal text between tags
|
|
13
|
+
// TEMPL_EXPR — { goExpr } inside HTML (the goExpr tokens are inlined)
|
|
14
|
+
// TEMPL_COMP — @Call(...) inside HTML
|
|
15
|
+
// TEMPL_CHILDREN — { children... }
|
|
16
|
+
//
|
|
17
|
+
// The approach: scan the file character-by-character. Outside templ bodies, use
|
|
18
|
+
// the existing Lexer class to tokenise Go. Inside templ bodies, scan HTML.
|
|
19
|
+
|
|
20
|
+
import { Lexer, T, Token } from "./lexer.js";
|
|
21
|
+
|
|
22
|
+
export const TT = {
|
|
23
|
+
TEMPL_KW: "templ",
|
|
24
|
+
HTML_OPEN: "HTML_OPEN",
|
|
25
|
+
HTML_CLOSE: "HTML_CLOSE",
|
|
26
|
+
HTML_SELF: "HTML_SELF",
|
|
27
|
+
HTML_TEXT: "HTML_TEXT",
|
|
28
|
+
TEMPL_EXPR: "TEMPL_EXPR", // inline Go expression token stream
|
|
29
|
+
TEMPL_COMP: "TEMPL_COMP", // @Component(args) call
|
|
30
|
+
TEMPL_CHILDREN: "TEMPL_CHILDREN",
|
|
31
|
+
TEMPL_IF: "TEMPL_IF",
|
|
32
|
+
TEMPL_ELSE: "TEMPL_ELSE",
|
|
33
|
+
TEMPL_FOR: "TEMPL_FOR",
|
|
34
|
+
TEMPL_SWITCH: "TEMPL_SWITCH", // switch expr {
|
|
35
|
+
TEMPL_CASE: "TEMPL_CASE", // case expr:
|
|
36
|
+
TEMPL_DEFAULT: "TEMPL_DEFAULT", // default:
|
|
37
|
+
TEMPL_END: "TEMPL_END", // closing } of if/for/switch/templ body
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Describes a parsed HTML attribute
|
|
41
|
+
// { kind: "static", name, value } | { kind: "expr", name, tokens } | { kind: "bool", name }
|
|
42
|
+
|
|
43
|
+
export class TemplLexer {
|
|
44
|
+
constructor(source, filename = "<templ>") {
|
|
45
|
+
this.src = source;
|
|
46
|
+
this.pos = 0;
|
|
47
|
+
this.line = 1;
|
|
48
|
+
this.col = 1;
|
|
49
|
+
this.filename = filename;
|
|
50
|
+
this.tokens = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Character primitives ────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
peek(n = 0) {
|
|
56
|
+
return this.src[this.pos + n];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
advance() {
|
|
60
|
+
const ch = this.src[this.pos++];
|
|
61
|
+
if (ch === "\n") {
|
|
62
|
+
this.line++;
|
|
63
|
+
this.col = 1;
|
|
64
|
+
} else {
|
|
65
|
+
this.col++;
|
|
66
|
+
}
|
|
67
|
+
return ch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
eof() {
|
|
71
|
+
return this.pos >= this.src.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
skipWhitespace() {
|
|
75
|
+
while (!this.eof() && /[ \t\r\n]/.test(this.peek())) this.advance();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
skipLineComment() {
|
|
79
|
+
while (!this.eof() && this.peek() !== "\n") this.advance();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
skipBlockComment() {
|
|
83
|
+
while (!this.eof()) {
|
|
84
|
+
if (this.peek() === "*" && this.peek(1) === "/") {
|
|
85
|
+
this.advance();
|
|
86
|
+
this.advance();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.advance();
|
|
90
|
+
}
|
|
91
|
+
this.err("unterminated block comment");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
err(msg) {
|
|
95
|
+
throw new Error(`${this.filename}:${this.line}:${this.col}: ${msg}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Main tokenise entry point ────────────────────────────────
|
|
99
|
+
|
|
100
|
+
tokenize() {
|
|
101
|
+
// Scan the entire file. We identify `templ Name(params) {` blocks
|
|
102
|
+
// and handle their bodies in HTML mode; everything else is Go.
|
|
103
|
+
while (!this.eof()) {
|
|
104
|
+
this.skipWhitespace();
|
|
105
|
+
if (this.eof()) break;
|
|
106
|
+
|
|
107
|
+
// Skip Go-style comments
|
|
108
|
+
if (this.peek() === "/" && this.peek(1) === "/") {
|
|
109
|
+
this.skipLineComment();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (this.peek() === "/" && this.peek(1) === "*") {
|
|
113
|
+
this.advance();
|
|
114
|
+
this.advance();
|
|
115
|
+
this.skipBlockComment();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Detect `templ` keyword at identifier boundaries
|
|
120
|
+
if (this._matchKeyword("templ")) {
|
|
121
|
+
this.tokens.push(new Token(TT.TEMPL_KW, "templ", this.line, this.col));
|
|
122
|
+
this._lexTemplDecl();
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Everything else: scan to end of top-level declaration using the
|
|
127
|
+
// raw Go lexer, accumulating tokens until the next `templ` keyword
|
|
128
|
+
// or EOF.
|
|
129
|
+
this._lexGoChunk();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.tokens.push(new Token(T.EOF, "", this.line, this.col));
|
|
133
|
+
return this.tokens;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Go chunk lexing ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
// Scan ahead to find the next `templ ` keyword at top level (not inside
|
|
139
|
+
// braces/parens/strings/comments). Collect all source text up to that
|
|
140
|
+
// point, lex it as normal Go, and append the tokens.
|
|
141
|
+
_lexGoChunk() {
|
|
142
|
+
const start = this.pos;
|
|
143
|
+
let depth = 0; // brace depth
|
|
144
|
+
|
|
145
|
+
while (!this.eof()) {
|
|
146
|
+
// Check for `templ` keyword at top level (depth === 0)
|
|
147
|
+
if (depth === 0 && this._peekKeyword("templ")) break;
|
|
148
|
+
|
|
149
|
+
const ch = this.peek();
|
|
150
|
+
|
|
151
|
+
// Strings (raw or interpreted)
|
|
152
|
+
if (ch === '"' || ch === "'") {
|
|
153
|
+
this._skipGoString(ch);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (ch === "`") {
|
|
157
|
+
this._skipRawString();
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Comments
|
|
162
|
+
if (ch === "/" && this.peek(1) === "/") {
|
|
163
|
+
this.skipLineComment();
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === "/" && this.peek(1) === "*") {
|
|
167
|
+
this.advance();
|
|
168
|
+
this.advance();
|
|
169
|
+
this.skipBlockComment();
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (ch === "{") depth++;
|
|
174
|
+
else if (ch === "}") {
|
|
175
|
+
if (depth === 0) break; // should not happen at top level, stop
|
|
176
|
+
depth--;
|
|
177
|
+
}
|
|
178
|
+
this.advance();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.pos === start) {
|
|
182
|
+
// Nothing to lex; skip one char to avoid infinite loop
|
|
183
|
+
this.advance();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const chunk = this.src.slice(start, this.pos);
|
|
188
|
+
for (const t of this._goTokens(chunk)) this.tokens.push(t);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Templ declaration lexing ─────────────────────────────────
|
|
192
|
+
|
|
193
|
+
// Called right after consuming `templ` keyword.
|
|
194
|
+
// Lexes: Name(params) { <HTML body> }
|
|
195
|
+
_lexTemplDecl() {
|
|
196
|
+
this.skipWhitespace();
|
|
197
|
+
|
|
198
|
+
// Name
|
|
199
|
+
const nameStart = this.pos;
|
|
200
|
+
while (!this.eof() && /\w/.test(this.peek())) this.advance();
|
|
201
|
+
const name = this.src.slice(nameStart, this.pos);
|
|
202
|
+
if (!name) this.err("expected component name after 'templ'");
|
|
203
|
+
this.tokens.push(new Token(T.IDENT, name, this.line, this.col));
|
|
204
|
+
|
|
205
|
+
this.skipWhitespace();
|
|
206
|
+
|
|
207
|
+
// Parameter list ( ... ) — lex as Go
|
|
208
|
+
if (this.peek() !== "(") this.err("expected '(' after templ name");
|
|
209
|
+
const paramStart = this.pos;
|
|
210
|
+
this._skipBalanced("(", ")");
|
|
211
|
+
const paramSrc = this.src.slice(paramStart, this.pos);
|
|
212
|
+
for (const t of this._goTokens(paramSrc)) this.tokens.push(t);
|
|
213
|
+
|
|
214
|
+
this.skipWhitespace();
|
|
215
|
+
|
|
216
|
+
// Opening { of body
|
|
217
|
+
if (this.peek() !== "{") this.err("expected '{' after templ params");
|
|
218
|
+
this.advance(); // consume {
|
|
219
|
+
this.tokens.push(new Token(T.LBRACE, "{", this.line, this.col));
|
|
220
|
+
|
|
221
|
+
// HTML body
|
|
222
|
+
this._lexHtmlBody(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Lex the HTML body of a templ declaration.
|
|
226
|
+
// depth: current brace depth (starts at 1 for the opening { of templ)
|
|
227
|
+
_lexHtmlBody(depth) {
|
|
228
|
+
while (!this.eof()) {
|
|
229
|
+
this.skipWhitespace();
|
|
230
|
+
if (this.eof()) break;
|
|
231
|
+
|
|
232
|
+
const ch = this.peek();
|
|
233
|
+
|
|
234
|
+
// Closing brace: if depth becomes 0, we're done
|
|
235
|
+
if (ch === "}") {
|
|
236
|
+
if (depth === 1) {
|
|
237
|
+
this.advance(); // consume }
|
|
238
|
+
this.tokens.push(new Token(T.RBRACE, "}", this.line, this.col));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// depth > 1 should not happen at the top of a templ body; treat as end
|
|
242
|
+
this.advance();
|
|
243
|
+
this.tokens.push(new Token(TT.TEMPL_END, "}", this.line, this.col));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this._lexHtmlNode();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Lex HTML nodes for a switch-case body until case/default/} without consuming
|
|
252
|
+
// the terminator (the caller handles it).
|
|
253
|
+
_lexCaseBody() {
|
|
254
|
+
while (!this.eof()) {
|
|
255
|
+
this.skipWhitespace();
|
|
256
|
+
if (this.eof()) break;
|
|
257
|
+
const ch = this.peek();
|
|
258
|
+
if (ch === "}") break;
|
|
259
|
+
if (this._peekKeyword("case") || this._peekKeyword("default")) break;
|
|
260
|
+
this._lexHtmlNode();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Lex a single HTML node at the current position.
|
|
265
|
+
_lexHtmlNode() {
|
|
266
|
+
const ch = this.peek();
|
|
267
|
+
|
|
268
|
+
// HTML comment <!-- ... -->
|
|
269
|
+
if (ch === "<" && this.src.startsWith("<!--", this.pos)) {
|
|
270
|
+
this._skipHtmlComment();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Closing tag </tag>
|
|
274
|
+
if (ch === "<" && this.peek(1) === "/") {
|
|
275
|
+
this._lexHtmlCloseTag();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Opening tag <tag ...> or <tag .../>
|
|
279
|
+
if (ch === "<") {
|
|
280
|
+
this._lexHtmlOpenTag();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// @Component(args) call
|
|
284
|
+
if (ch === "@") {
|
|
285
|
+
this._lexTemplComponent();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// { children... } or { goExpr }
|
|
289
|
+
if (ch === "{") {
|
|
290
|
+
this._lexBraceExprOrControl();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Control flow keywords
|
|
294
|
+
if (this._peekKeyword("if")) {
|
|
295
|
+
this._lexTemplIf();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (this._peekKeyword("for")) {
|
|
299
|
+
this._lexTemplFor();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (this._peekKeyword("switch")) {
|
|
303
|
+
this._lexTemplSwitch();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Plain text content
|
|
307
|
+
this._lexHtmlText();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── HTML element lexing ───────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
_lexHtmlOpenTag() {
|
|
313
|
+
const line = this.line;
|
|
314
|
+
const col = this.col;
|
|
315
|
+
this.advance(); // consume <
|
|
316
|
+
this.skipWhitespace();
|
|
317
|
+
|
|
318
|
+
// Tag name
|
|
319
|
+
const tagName = this._readHtmlName();
|
|
320
|
+
if (!tagName) this.err("expected tag name");
|
|
321
|
+
|
|
322
|
+
const attrs = [];
|
|
323
|
+
let selfClose = false;
|
|
324
|
+
|
|
325
|
+
// Attributes
|
|
326
|
+
while (!this.eof()) {
|
|
327
|
+
this.skipWhitespace();
|
|
328
|
+
const c = this.peek();
|
|
329
|
+
|
|
330
|
+
if (c === "/" && this.peek(1) === ">") {
|
|
331
|
+
this.advance();
|
|
332
|
+
this.advance(); // />
|
|
333
|
+
selfClose = true;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (c === ">") {
|
|
337
|
+
this.advance(); // >
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Attribute name
|
|
342
|
+
const attrName = this._readHtmlAttrName();
|
|
343
|
+
if (!attrName) break;
|
|
344
|
+
|
|
345
|
+
this.skipWhitespace();
|
|
346
|
+
|
|
347
|
+
// Conditional boolean attribute: name?={expr}
|
|
348
|
+
if (this.peek() === "?" && this.peek(1) === "=") {
|
|
349
|
+
this.advance();
|
|
350
|
+
this.advance(); // ?=
|
|
351
|
+
this.skipWhitespace();
|
|
352
|
+
if (this.peek() !== "{") this.err("expected '{' after ?=");
|
|
353
|
+
const exprTokens = this._readGoExprInBraces();
|
|
354
|
+
attrs.push({ kind: "cond-bool", name: attrName, tokens: exprTokens });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (this.peek() !== "=") {
|
|
359
|
+
// Boolean attribute: no value
|
|
360
|
+
attrs.push({ kind: "bool", name: attrName });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
this.advance(); // =
|
|
364
|
+
this.skipWhitespace();
|
|
365
|
+
|
|
366
|
+
if (this.peek() === "{") {
|
|
367
|
+
// Expression attribute: name={ goExpr }
|
|
368
|
+
const exprTokens = this._readGoExprInBraces();
|
|
369
|
+
attrs.push({ kind: "expr", name: attrName, tokens: exprTokens });
|
|
370
|
+
} else if (this.peek() === '"') {
|
|
371
|
+
// Static string attribute
|
|
372
|
+
const value = this._readQuotedString();
|
|
373
|
+
attrs.push({ kind: "static", name: attrName, value });
|
|
374
|
+
} else {
|
|
375
|
+
// Unquoted attribute value
|
|
376
|
+
let val = "";
|
|
377
|
+
while (!this.eof() && !/[\s>]/.test(this.peek())) val += this.advance();
|
|
378
|
+
attrs.push({ kind: "static", name: attrName, value: val });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const kind = selfClose ? TT.HTML_SELF : TT.HTML_OPEN;
|
|
383
|
+
this.tokens.push(new Token(kind, { tag: tagName, attrs }, line, col));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_lexHtmlCloseTag() {
|
|
387
|
+
const line = this.line;
|
|
388
|
+
this.advance(); // <
|
|
389
|
+
this.advance(); // /
|
|
390
|
+
this.skipWhitespace();
|
|
391
|
+
const tagName = this._readHtmlName();
|
|
392
|
+
this.skipWhitespace();
|
|
393
|
+
if (this.peek() === ">") this.advance();
|
|
394
|
+
this.tokens.push(new Token(TT.HTML_CLOSE, tagName, line, this.col));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_skipHtmlComment() {
|
|
398
|
+
// <!-- ... -->
|
|
399
|
+
this.pos += 4; // <!--
|
|
400
|
+
while (!this.eof()) {
|
|
401
|
+
if (this.src.startsWith("-->", this.pos)) {
|
|
402
|
+
this.pos += 3;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (this.peek() === "\n") {
|
|
406
|
+
this.line++;
|
|
407
|
+
this.col = 1;
|
|
408
|
+
} else {
|
|
409
|
+
this.col++;
|
|
410
|
+
}
|
|
411
|
+
this.pos++;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Expression / control flow inside HTML ─────────────────────
|
|
416
|
+
|
|
417
|
+
_lexBraceExprOrControl() {
|
|
418
|
+
// { children... } → TEMPL_CHILDREN
|
|
419
|
+
// { goExpr } → TEMPL_EXPR
|
|
420
|
+
const saveLine = this.line;
|
|
421
|
+
const saveCol = this.col;
|
|
422
|
+
|
|
423
|
+
this.advance(); // {
|
|
424
|
+
this.skipWhitespace();
|
|
425
|
+
|
|
426
|
+
// Check for children...
|
|
427
|
+
if (this.src.startsWith("children...", this.pos)) {
|
|
428
|
+
this.pos += "children...".length;
|
|
429
|
+
this.skipWhitespace();
|
|
430
|
+
if (this.peek() === "}") this.advance();
|
|
431
|
+
this.tokens.push(new Token(TT.TEMPL_CHILDREN, null, saveLine, saveCol));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Otherwise lex the Go expression inside the braces
|
|
436
|
+
const exprTokens = this._readGoExprTokensUntilClose();
|
|
437
|
+
this.tokens.push(new Token(TT.TEMPL_EXPR, exprTokens, saveLine, saveCol));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_lexTemplComponent() {
|
|
441
|
+
const line = this.line;
|
|
442
|
+
const col = this.col;
|
|
443
|
+
this.advance(); // @
|
|
444
|
+
|
|
445
|
+
// Read the call expression (Go tokens until end of balanced parens)
|
|
446
|
+
const nameStart = this.pos;
|
|
447
|
+
while (!this.eof() && /[\w.]/.test(this.peek())) this.advance();
|
|
448
|
+
const name = this.src.slice(nameStart, this.pos);
|
|
449
|
+
if (!name) this.err("expected component name after @");
|
|
450
|
+
|
|
451
|
+
this.skipWhitespace();
|
|
452
|
+
if (this.peek() !== "(") this.err("expected '(' after @component name");
|
|
453
|
+
|
|
454
|
+
const argsStart = this.pos;
|
|
455
|
+
this._skipBalanced("(", ")");
|
|
456
|
+
const argsSrc = name + this.src.slice(argsStart, this.pos);
|
|
457
|
+
|
|
458
|
+
const callTokens = this._goTokens(argsSrc);
|
|
459
|
+
this.tokens.push(new Token(TT.TEMPL_COMP, callTokens, line, col));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
_lexTemplIf() {
|
|
463
|
+
const line = this.line;
|
|
464
|
+
const condTokens = this._lexGoExprBeforeBrace();
|
|
465
|
+
this.tokens.push(new Token(TT.TEMPL_IF, condTokens, line, this.col));
|
|
466
|
+
this._lexHtmlBody(1);
|
|
467
|
+
|
|
468
|
+
// Check for else
|
|
469
|
+
this.skipWhitespace();
|
|
470
|
+
if (this._peekKeyword("else")) {
|
|
471
|
+
this._readWord(); // consume `else`
|
|
472
|
+
this.skipWhitespace();
|
|
473
|
+
this.tokens.push(new Token(TT.TEMPL_ELSE, null, this.line, this.col));
|
|
474
|
+
if (this._peekKeyword("if")) {
|
|
475
|
+
// else if
|
|
476
|
+
this._lexTemplIf();
|
|
477
|
+
} else {
|
|
478
|
+
// else block
|
|
479
|
+
if (this.peek() !== "{") this.err("expected '{' after else");
|
|
480
|
+
this.advance(); // {
|
|
481
|
+
this._lexHtmlBody(1);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_lexTemplFor() {
|
|
487
|
+
const line = this.line;
|
|
488
|
+
const stmtTokens = this._lexGoExprBeforeBrace();
|
|
489
|
+
this.tokens.push(new Token(TT.TEMPL_FOR, stmtTokens, line, this.col));
|
|
490
|
+
this._lexHtmlBody(1);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
_lexTemplSwitch() {
|
|
494
|
+
const line = this.line;
|
|
495
|
+
this._readWord(); // consume "switch"
|
|
496
|
+
this.skipWhitespace();
|
|
497
|
+
const exprTokens = this._lexGoExprBeforeBrace();
|
|
498
|
+
this.tokens.push(new Token(TT.TEMPL_SWITCH, exprTokens, line, this.col));
|
|
499
|
+
|
|
500
|
+
// Lex case/default entries until the closing }
|
|
501
|
+
while (!this.eof()) {
|
|
502
|
+
this.skipWhitespace();
|
|
503
|
+
if (this.eof()) break;
|
|
504
|
+
|
|
505
|
+
if (this.peek() === "}") {
|
|
506
|
+
this.advance(); // consume }
|
|
507
|
+
this.tokens.push(new Token(TT.TEMPL_END, "}", this.line, this.col));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (this._peekKeyword("case")) {
|
|
512
|
+
const caseLine = this.line;
|
|
513
|
+
this._readWord(); // consume "case"
|
|
514
|
+
this.skipWhitespace();
|
|
515
|
+
// Read case expression up to ":"
|
|
516
|
+
const caseExprStart = this.pos;
|
|
517
|
+
// Skip to the colon, handling string literals
|
|
518
|
+
while (!this.eof() && this.peek() !== ":") {
|
|
519
|
+
const c = this.peek();
|
|
520
|
+
if (c === '"' || c === "'") {
|
|
521
|
+
this._skipGoString(c);
|
|
522
|
+
} else if (c === "`") {
|
|
523
|
+
this._skipRawString();
|
|
524
|
+
} else {
|
|
525
|
+
this.advance();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const caseExprSrc = this.src.slice(caseExprStart, this.pos).trimEnd();
|
|
529
|
+
if (this.peek() === ":") this.advance(); // :
|
|
530
|
+
const caseTokens = this._goTokens(caseExprSrc);
|
|
531
|
+
this.tokens.push(
|
|
532
|
+
new Token(TT.TEMPL_CASE, caseTokens, caseLine, this.col),
|
|
533
|
+
);
|
|
534
|
+
this._lexCaseBody();
|
|
535
|
+
} else if (this._peekKeyword("default")) {
|
|
536
|
+
const defaultLine = this.line;
|
|
537
|
+
this._readWord(); // consume "default"
|
|
538
|
+
this.skipWhitespace();
|
|
539
|
+
if (this.peek() === ":") this.advance(); // :
|
|
540
|
+
this.tokens.push(
|
|
541
|
+
new Token(TT.TEMPL_DEFAULT, null, defaultLine, this.col),
|
|
542
|
+
);
|
|
543
|
+
this._lexCaseBody();
|
|
544
|
+
} else {
|
|
545
|
+
// Unexpected token inside switch body — skip to avoid infinite loop
|
|
546
|
+
this.advance();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── HTML text content ─────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
_lexHtmlText() {
|
|
554
|
+
let text = "";
|
|
555
|
+
const line = this.line;
|
|
556
|
+
while (!this.eof()) {
|
|
557
|
+
const c = this.peek();
|
|
558
|
+
if (c === "<" || c === "{" || c === "@" || c === "}") break;
|
|
559
|
+
// Detect control-flow keywords at line start
|
|
560
|
+
if (
|
|
561
|
+
(c === "i" && this._peekKeyword("if")) ||
|
|
562
|
+
(c === "f" && this._peekKeyword("for")) ||
|
|
563
|
+
(c === "s" && this._peekKeyword("switch")) ||
|
|
564
|
+
(c === "c" && this._peekKeyword("case")) ||
|
|
565
|
+
(c === "d" && this._peekKeyword("default"))
|
|
566
|
+
)
|
|
567
|
+
break;
|
|
568
|
+
text += this.advance();
|
|
569
|
+
}
|
|
570
|
+
const trimmed = text.trim();
|
|
571
|
+
if (trimmed)
|
|
572
|
+
this.tokens.push(new Token(TT.HTML_TEXT, trimmed, line, this.col));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
_goTokens(src) {
|
|
578
|
+
return new Lexer(src, this.filename)
|
|
579
|
+
.tokenize()
|
|
580
|
+
.filter((t) => t.type !== T.EOF);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Consume text up to the opening `{`, advance past it, and return Go tokens.
|
|
584
|
+
_lexGoExprBeforeBrace() {
|
|
585
|
+
const start = this.pos;
|
|
586
|
+
this._skipToOpenBrace();
|
|
587
|
+
const src = this.src.slice(start, this.pos).trimEnd();
|
|
588
|
+
this.advance(); // consume {
|
|
589
|
+
return src ? this._goTokens(src) : [];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Read Go expression tokens until the closing }
|
|
593
|
+
_readGoExprInBraces() {
|
|
594
|
+
this.advance(); // {
|
|
595
|
+
return this._readGoExprTokensUntilClose();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
_readGoExprTokensUntilClose() {
|
|
599
|
+
const start = this.pos;
|
|
600
|
+
let depth = 1;
|
|
601
|
+
while (!this.eof() && depth > 0) {
|
|
602
|
+
const c = this.peek();
|
|
603
|
+
if (c === "{") depth++;
|
|
604
|
+
else if (c === "}") {
|
|
605
|
+
depth--;
|
|
606
|
+
if (depth === 0) break;
|
|
607
|
+
} else if (c === '"' || c === "'") {
|
|
608
|
+
this._skipGoString(c);
|
|
609
|
+
continue;
|
|
610
|
+
} else if (c === "`") {
|
|
611
|
+
this._skipRawString();
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
this.advance();
|
|
615
|
+
}
|
|
616
|
+
const exprSrc = this.src.slice(start, this.pos).trim();
|
|
617
|
+
if (this.peek() === "}") this.advance(); // consume }
|
|
618
|
+
if (!exprSrc) return [];
|
|
619
|
+
return this._goTokens(exprSrc);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
_readHtmlName() {
|
|
623
|
+
let name = "";
|
|
624
|
+
while (!this.eof() && /[a-zA-Z0-9_-]/.test(this.peek()))
|
|
625
|
+
name += this.advance();
|
|
626
|
+
return name;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_readHtmlAttrName() {
|
|
630
|
+
let name = "";
|
|
631
|
+
while (!this.eof() && /[a-zA-Z0-9_:.-]/.test(this.peek()))
|
|
632
|
+
name += this.advance();
|
|
633
|
+
return name;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
_readQuotedString() {
|
|
637
|
+
this.advance(); // opening "
|
|
638
|
+
let s = "";
|
|
639
|
+
while (!this.eof() && this.peek() !== '"') {
|
|
640
|
+
if (this.peek() === "\\") {
|
|
641
|
+
this.advance();
|
|
642
|
+
s += this.advance();
|
|
643
|
+
} else s += this.advance();
|
|
644
|
+
}
|
|
645
|
+
if (this.peek() === '"') this.advance();
|
|
646
|
+
return s;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
_readWord() {
|
|
650
|
+
let w = "";
|
|
651
|
+
while (!this.eof() && /\w/.test(this.peek())) w += this.advance();
|
|
652
|
+
return w;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
_skipToOpenBrace() {
|
|
656
|
+
// Skip text until we hit an unbalanced {, tracking parens/brackets
|
|
657
|
+
let paren = 0,
|
|
658
|
+
bracket = 0;
|
|
659
|
+
while (!this.eof()) {
|
|
660
|
+
const c = this.peek();
|
|
661
|
+
if (c === "(") paren++;
|
|
662
|
+
else if (c === ")") paren--;
|
|
663
|
+
else if (c === "[") bracket++;
|
|
664
|
+
else if (c === "]") bracket--;
|
|
665
|
+
else if (c === "{" && paren === 0 && bracket === 0) return;
|
|
666
|
+
else if (c === '"' || c === "'") {
|
|
667
|
+
this._skipGoString(c);
|
|
668
|
+
continue;
|
|
669
|
+
} else if (c === "`") {
|
|
670
|
+
this._skipRawString();
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
this.advance();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_skipBalanced(open, close) {
|
|
678
|
+
let depth = 0;
|
|
679
|
+
while (!this.eof()) {
|
|
680
|
+
const c = this.peek();
|
|
681
|
+
if (c === '"' || c === "'") {
|
|
682
|
+
this._skipGoString(c);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (c === "`") {
|
|
686
|
+
this._skipRawString();
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (c === open) depth++;
|
|
690
|
+
else if (c === close) {
|
|
691
|
+
depth--;
|
|
692
|
+
if (depth === 0) {
|
|
693
|
+
this.advance();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
this.advance();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
_skipGoString(quote) {
|
|
702
|
+
this.advance(); // opening quote
|
|
703
|
+
while (!this.eof()) {
|
|
704
|
+
const c = this.peek();
|
|
705
|
+
if (c === "\\") {
|
|
706
|
+
this.advance();
|
|
707
|
+
this.advance();
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (c === quote) {
|
|
711
|
+
this.advance();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
this.advance();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
_skipRawString() {
|
|
719
|
+
this.advance(); // `
|
|
720
|
+
while (!this.eof() && this.peek() !== "`") this.advance();
|
|
721
|
+
if (!this.eof()) this.advance();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Returns true if src[pos..] starts with `word` followed by a non-word char
|
|
725
|
+
_peekKeyword(word) {
|
|
726
|
+
if (!this.src.startsWith(word, this.pos)) return false;
|
|
727
|
+
const after = this.src[this.pos + word.length];
|
|
728
|
+
return !after || /\W/.test(after);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Consume a keyword word
|
|
732
|
+
_matchKeyword(word) {
|
|
733
|
+
if (!this._peekKeyword(word)) return false;
|
|
734
|
+
for (let i = 0; i < word.length; i++) this.advance();
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
}
|