coffeehaml 0.4.1 → 0.5.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 +28 -9
- package/dist/ast.d.ts +3 -1
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +2 -1
- package/dist/ast.js.map +1 -1
- package/dist/cli.js +131 -35
- package/dist/cli.js.map +1 -1
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +101 -50
- package/dist/emitter.js.map +1 -1
- package/dist/expressions.d.ts +7 -4
- package/dist/expressions.d.ts.map +1 -1
- package/dist/expressions.js +20 -7
- package/dist/expressions.js.map +1 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +26 -21
- package/dist/lexer.js.map +1 -1
- package/dist/parser.js +8 -1
- package/dist/parser.js.map +1 -1
- package/package.json +2 -2
- package/dist/parser.js.bak +0 -431
package/dist/parser.js.bak
DELETED
|
@@ -1,431 +0,0 @@
|
|
|
1
|
-
import { TokenType } from './lexer.js';
|
|
2
|
-
import { Document, Element, ImplicitDiv, Text, Output, ControlFlow, Comment, Filter, Doctype, Expression, } from './ast.js';
|
|
3
|
-
import { CompileError } from './types.js';
|
|
4
|
-
// ─── Parser State ──────────────────────────────────────────
|
|
5
|
-
class ParserState {
|
|
6
|
-
constructor(tokens, filename) {
|
|
7
|
-
this.tokens = tokens;
|
|
8
|
-
this.pos = 0;
|
|
9
|
-
this.filename = filename;
|
|
10
|
-
}
|
|
11
|
-
get current() {
|
|
12
|
-
return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
|
|
13
|
-
}
|
|
14
|
-
get peek() {
|
|
15
|
-
return this.pos + 1 < this.tokens.length ? this.tokens[this.pos + 1] : null;
|
|
16
|
-
}
|
|
17
|
-
advance() {
|
|
18
|
-
const token = this.current;
|
|
19
|
-
this.pos++;
|
|
20
|
-
return token;
|
|
21
|
-
}
|
|
22
|
-
expect(type) {
|
|
23
|
-
const token = this.current;
|
|
24
|
-
if (!token || token.type !== type) {
|
|
25
|
-
throw new CompileError(`Expected ${type} but got ${token?.type ?? 'EOF'}`, 'parser', 'UNEXPECTED_TOKEN', token?.location);
|
|
26
|
-
}
|
|
27
|
-
return this.advance();
|
|
28
|
-
}
|
|
29
|
-
skip(type) {
|
|
30
|
-
if (this.current?.type === type) {
|
|
31
|
-
this.advance();
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
isAt(type) {
|
|
37
|
-
return this.current?.type === type;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
// ─── Public API ────────────────────────────────────────────
|
|
41
|
-
export function parse(tokens, filename) {
|
|
42
|
-
const state = new ParserState(tokens, filename);
|
|
43
|
-
// Collect prologue (raw JS lines before the first HAML construct)
|
|
44
|
-
const prologue = [];
|
|
45
|
-
while (state.current?.type === TokenType.PROLOGUE) {
|
|
46
|
-
prologue.push(state.advance().value);
|
|
47
|
-
}
|
|
48
|
-
const children = parseBlock(state);
|
|
49
|
-
return new Document(children, prologue);
|
|
50
|
-
}
|
|
51
|
-
// ─── Block Parsing ─────────────────────────────────────────
|
|
52
|
-
/** Parse nodes until DEDENT or EOF. */
|
|
53
|
-
function parseBlock(state) {
|
|
54
|
-
const nodes = [];
|
|
55
|
-
while (state.current && state.current.type !== TokenType.DEDENT) {
|
|
56
|
-
const node = parseNode(state);
|
|
57
|
-
if (node)
|
|
58
|
-
nodes.push(node);
|
|
59
|
-
}
|
|
60
|
-
return nodes;
|
|
61
|
-
}
|
|
62
|
-
// ─── Node Dispatcher ───────────────────────────────────────
|
|
63
|
-
function parseNode(state) {
|
|
64
|
-
const token = state.current;
|
|
65
|
-
if (!token)
|
|
66
|
-
return null;
|
|
67
|
-
switch (token.type) {
|
|
68
|
-
case TokenType.TAG:
|
|
69
|
-
return parseElement(state);
|
|
70
|
-
case TokenType.CLASS:
|
|
71
|
-
case TokenType.ID:
|
|
72
|
-
return parseImplicitDiv(state);
|
|
73
|
-
case TokenType.OUTPUT:
|
|
74
|
-
case TokenType.OUTPUT_UNESC:
|
|
75
|
-
return parseOutput(state);
|
|
76
|
-
case TokenType.CONTROL:
|
|
77
|
-
return parseControlFlow(state);
|
|
78
|
-
case TokenType.COMMENT:
|
|
79
|
-
return parseComment(state);
|
|
80
|
-
case TokenType.HTML_COMMENT:
|
|
81
|
-
return parseHtmlComment(state);
|
|
82
|
-
case TokenType.FILTER:
|
|
83
|
-
return parseFilter(state);
|
|
84
|
-
case TokenType.DOCTYPE:
|
|
85
|
-
return parseDoctype(state);
|
|
86
|
-
case TokenType.TEXT:
|
|
87
|
-
return parseText(state);
|
|
88
|
-
case TokenType.INDENT:
|
|
89
|
-
state.advance(); // skip, handled by parseBlock
|
|
90
|
-
return null;
|
|
91
|
-
case TokenType.DEDENT:
|
|
92
|
-
return null; // handled by parseBlock loop
|
|
93
|
-
case TokenType.NEWLINE:
|
|
94
|
-
state.advance();
|
|
95
|
-
return null;
|
|
96
|
-
default:
|
|
97
|
-
state.advance();
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// ─── Element ───────────────────────────────────────────────
|
|
102
|
-
function parseElement(state) {
|
|
103
|
-
const tagToken = state.expect(TokenType.TAG);
|
|
104
|
-
const tag = tagToken.value;
|
|
105
|
-
const isComponent = /^[A-Z]/.test(tag);
|
|
106
|
-
let classes = [];
|
|
107
|
-
let id = null;
|
|
108
|
-
let attributes = [];
|
|
109
|
-
let isSelfClosing = false;
|
|
110
|
-
// Parse modifiers and attributes
|
|
111
|
-
while (state.current &&
|
|
112
|
-
(state.current.type === TokenType.CLASS ||
|
|
113
|
-
state.current.type === TokenType.ID ||
|
|
114
|
-
state.current.type === TokenType.ATTRS_BRACE ||
|
|
115
|
-
state.current.type === TokenType.ATTRS_PAREN ||
|
|
116
|
-
state.current.type === TokenType.SELF_CLOSE)) {
|
|
117
|
-
const tok = state.current;
|
|
118
|
-
if (tok.type === TokenType.CLASS) {
|
|
119
|
-
state.advance();
|
|
120
|
-
classes.push(tok.value);
|
|
121
|
-
}
|
|
122
|
-
else if (tok.type === TokenType.ID) {
|
|
123
|
-
state.advance();
|
|
124
|
-
id = tok.value; // last #id wins
|
|
125
|
-
}
|
|
126
|
-
else if (tok.type === TokenType.ATTRS_BRACE) {
|
|
127
|
-
state.advance();
|
|
128
|
-
attributes.push(...parseAttributeBlock(tok.value, '{}', tagToken.location));
|
|
129
|
-
}
|
|
130
|
-
else if (tok.type === TokenType.ATTRS_PAREN) {
|
|
131
|
-
state.advance();
|
|
132
|
-
attributes.push(...parseAttributeBlock(tok.value, '()', tagToken.location));
|
|
133
|
-
}
|
|
134
|
-
else if (tok.type === TokenType.SELF_CLOSE) {
|
|
135
|
-
state.advance();
|
|
136
|
-
isSelfClosing = true;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// Parse inline text or output if present
|
|
140
|
-
let children = [];
|
|
141
|
-
if (state.current && !isSelfClosing) {
|
|
142
|
-
if (state.current.type === TokenType.OUTPUT) {
|
|
143
|
-
const tok = state.advance();
|
|
144
|
-
children.push(new Output(new Expression(tok.value), 'escaped', tok.location));
|
|
145
|
-
}
|
|
146
|
-
else if (state.current.type === TokenType.OUTPUT_UNESC) {
|
|
147
|
-
const tok = state.advance();
|
|
148
|
-
children.push(new Output(new Expression(tok.value), 'unescaped', tok.location));
|
|
149
|
-
}
|
|
150
|
-
else if (state.current.type === TokenType.TEXT) {
|
|
151
|
-
const textToken = state.advance();
|
|
152
|
-
children.push(new Text(textToken.value, textToken.location));
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
// Parse child block if INDENT follows
|
|
156
|
-
if (state.current?.type === TokenType.INDENT && !isSelfClosing) {
|
|
157
|
-
state.advance(); // consume INDENT
|
|
158
|
-
children = children.concat(parseBlock(state));
|
|
159
|
-
state.expect(TokenType.DEDENT);
|
|
160
|
-
}
|
|
161
|
-
return new Element(tag, { classes, id, attributes, children, isComponent, isSelfClosing, location: tagToken.location });
|
|
162
|
-
}
|
|
163
|
-
// ─── ImplicitDiv ───────────────────────────────────────────
|
|
164
|
-
function parseImplicitDiv(state) {
|
|
165
|
-
let classes = [];
|
|
166
|
-
let id = null;
|
|
167
|
-
let attributes = [];
|
|
168
|
-
const firstToken = state.current; // for location
|
|
169
|
-
while (state.current &&
|
|
170
|
-
(state.current.type === TokenType.CLASS ||
|
|
171
|
-
state.current.type === TokenType.ID ||
|
|
172
|
-
state.current.type === TokenType.ATTRS_BRACE ||
|
|
173
|
-
state.current.type === TokenType.ATTRS_PAREN)) {
|
|
174
|
-
const tok = state.current;
|
|
175
|
-
if (tok.type === TokenType.CLASS) {
|
|
176
|
-
state.advance();
|
|
177
|
-
classes.push(tok.value);
|
|
178
|
-
}
|
|
179
|
-
else if (tok.type === TokenType.ID) {
|
|
180
|
-
state.advance();
|
|
181
|
-
id = tok.value;
|
|
182
|
-
}
|
|
183
|
-
else if (tok.type === TokenType.ATTRS_BRACE) {
|
|
184
|
-
state.advance();
|
|
185
|
-
attributes.push(...parseAttributeBlock(tok.value, '{}', firstToken.location));
|
|
186
|
-
}
|
|
187
|
-
else if (tok.type === TokenType.ATTRS_PAREN) {
|
|
188
|
-
state.advance();
|
|
189
|
-
attributes.push(...parseAttributeBlock(tok.value, '()', firstToken.location));
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
// Parse inline text
|
|
193
|
-
let children = [];
|
|
194
|
-
if (state.current && state.current.type === TokenType.TEXT) {
|
|
195
|
-
const textToken = state.advance();
|
|
196
|
-
children.push(new Text(textToken.value, textToken.location));
|
|
197
|
-
}
|
|
198
|
-
// Parse child block
|
|
199
|
-
if (state.current?.type === TokenType.INDENT) {
|
|
200
|
-
state.advance();
|
|
201
|
-
children = children.concat(parseBlock(state));
|
|
202
|
-
state.expect(TokenType.DEDENT);
|
|
203
|
-
}
|
|
204
|
-
return new ImplicitDiv({ classes, id, attributes, children, location: firstToken.location });
|
|
205
|
-
}
|
|
206
|
-
// ─── Output ────────────────────────────────────────────────
|
|
207
|
-
function parseOutput(state) {
|
|
208
|
-
const token = state.current;
|
|
209
|
-
const outputKind = token.type === TokenType.OUTPUT ? 'escaped' : 'unescaped';
|
|
210
|
-
state.advance();
|
|
211
|
-
const expr = new Expression(token.value, undefined);
|
|
212
|
-
return new Output(expr, outputKind, token.location);
|
|
213
|
-
}
|
|
214
|
-
// ─── Control Flow ──────────────────────────────────────────
|
|
215
|
-
function parseControlFlow(state) {
|
|
216
|
-
const token = state.expect(TokenType.CONTROL);
|
|
217
|
-
let source = token.value;
|
|
218
|
-
// Normalize "else if" → "if" so it chains naturally in ternary
|
|
219
|
-
if (/^\s*else\s+if\b/.test(source)) {
|
|
220
|
-
source = source.replace(/^\s*else\s+/, '');
|
|
221
|
-
}
|
|
222
|
-
// Parse the control kind from the expression
|
|
223
|
-
const controlKind = parseControlKind(source);
|
|
224
|
-
// Strip the keyword from the expression — store only the condition/iterable
|
|
225
|
-
const exprSource = stripControlKeyword(source, controlKind);
|
|
226
|
-
const expr = new Expression(exprSource, undefined);
|
|
227
|
-
// Parse body if INDENT follows
|
|
228
|
-
let children = [];
|
|
229
|
-
if (state.current?.type === TokenType.INDENT) {
|
|
230
|
-
state.advance();
|
|
231
|
-
children = parseBlock(state);
|
|
232
|
-
state.expect(TokenType.DEDENT);
|
|
233
|
-
}
|
|
234
|
-
// Check for chained else / else if
|
|
235
|
-
let next = null;
|
|
236
|
-
if (state.current?.type === TokenType.CONTROL) {
|
|
237
|
-
const nextSource = state.current.value.trimStart();
|
|
238
|
-
if (isElse(nextSource)) {
|
|
239
|
-
next = parseControlFlow(state);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return new ControlFlow(controlKind, expr, children, next, token.location);
|
|
243
|
-
}
|
|
244
|
-
function parseControlKind(source) {
|
|
245
|
-
const trimmed = source.trimStart();
|
|
246
|
-
const keyword = trimmed.split(/\s+/)[0];
|
|
247
|
-
switch (keyword) {
|
|
248
|
-
case 'if': return 'if';
|
|
249
|
-
case 'unless': return 'unless';
|
|
250
|
-
case 'for': return 'for';
|
|
251
|
-
case 'while': return 'while';
|
|
252
|
-
case 'else': return 'else';
|
|
253
|
-
default: return 'statement';
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
function stripControlKeyword(source, kind) {
|
|
257
|
-
const trimmed = source.trimStart();
|
|
258
|
-
if (kind === 'statement') {
|
|
259
|
-
return trimmed; // no keyword to strip — entire source is the statement
|
|
260
|
-
}
|
|
261
|
-
// Remove the keyword and any whitespace after it
|
|
262
|
-
const keyword = kind === 'else' ? 'else' : kind;
|
|
263
|
-
const re = new RegExp("^\s*" + keyword + "\b\s*");
|
|
264
|
-
return trimmed.replace(re, '');
|
|
265
|
-
}
|
|
266
|
-
function isElse(source) {
|
|
267
|
-
return /^\s*else\b/.test(source);
|
|
268
|
-
}
|
|
269
|
-
// ─── Comments ──────────────────────────────────────────────
|
|
270
|
-
function parseComment(state) {
|
|
271
|
-
const token = state.expect(TokenType.COMMENT);
|
|
272
|
-
return new Comment('haml', token.value, token.location);
|
|
273
|
-
}
|
|
274
|
-
function parseHtmlComment(state) {
|
|
275
|
-
const token = state.expect(TokenType.HTML_COMMENT);
|
|
276
|
-
return new Comment('html', token.value, token.location);
|
|
277
|
-
}
|
|
278
|
-
// ─── Filter ────────────────────────────────────────────────
|
|
279
|
-
function parseFilter(state) {
|
|
280
|
-
const token = state.expect(TokenType.FILTER);
|
|
281
|
-
const parts = token.value.split('\n');
|
|
282
|
-
const filterName = parts[0];
|
|
283
|
-
let content = parts.slice(1).join('\n');
|
|
284
|
-
// Consume indented children as filter body (Haml convention).
|
|
285
|
-
// After `:markdown`, all indented content belongs to the filter.
|
|
286
|
-
if (state.isAt(TokenType.INDENT)) {
|
|
287
|
-
state.advance(); // skip INDENT
|
|
288
|
-
const lines = [];
|
|
289
|
-
while (state.current && !state.isAt(TokenType.DEDENT)) {
|
|
290
|
-
const tok = state.current;
|
|
291
|
-
if (tok.type === TokenType.TEXT) {
|
|
292
|
-
lines.push(tok.value);
|
|
293
|
-
state.advance();
|
|
294
|
-
}
|
|
295
|
-
else if (tok.type === TokenType.NEWLINE) {
|
|
296
|
-
state.advance();
|
|
297
|
-
}
|
|
298
|
-
else {
|
|
299
|
-
// Unexpected token in filter body — break to avoid infinite loop
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
// Consume DEDENT
|
|
304
|
-
if (state.isAt(TokenType.DEDENT)) {
|
|
305
|
-
state.advance();
|
|
306
|
-
}
|
|
307
|
-
if (lines.length > 0) {
|
|
308
|
-
content = (content ? content + '\n' : '') + lines.join('\n');
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return new Filter(filterName, content, token.location);
|
|
312
|
-
}
|
|
313
|
-
// ─── Doctype ───────────────────────────────────────────────
|
|
314
|
-
function parseDoctype(state) {
|
|
315
|
-
const token = state.expect(TokenType.DOCTYPE);
|
|
316
|
-
return new Doctype(token.value || 'html', token.location);
|
|
317
|
-
}
|
|
318
|
-
// ─── Text ──────────────────────────────────────────────────
|
|
319
|
-
function parseText(state) {
|
|
320
|
-
const token = state.expect(TokenType.TEXT);
|
|
321
|
-
return new Text(token.value, token.location);
|
|
322
|
-
}
|
|
323
|
-
// ─── Attribute Block Parser ────────────────────────────────
|
|
324
|
-
/** Parse a CoffeeScript object literal into attributes.
|
|
325
|
-
* Uses a simple key-value parser that handles:
|
|
326
|
-
* {key: value, key2: value2}
|
|
327
|
-
* {shorthand}
|
|
328
|
-
* {key: "string", 'quoted-key': val, nested: {a: 1}}
|
|
329
|
-
*/
|
|
330
|
-
function parseAttributeBlock(source, _style, _location) {
|
|
331
|
-
const attrs = [];
|
|
332
|
-
if (!source.trim())
|
|
333
|
-
return attrs;
|
|
334
|
-
// Simple attribute parser: split on commas outside of brackets/strings
|
|
335
|
-
const pairs = splitAttributePairs(source);
|
|
336
|
-
for (const pair of pairs) {
|
|
337
|
-
const trimmed = pair.trim();
|
|
338
|
-
if (!trimmed)
|
|
339
|
-
continue;
|
|
340
|
-
// Spread attribute: props... (CoffeeScript form) or ...props (JSX form)
|
|
341
|
-
if (trimmed.endsWith('...')) {
|
|
342
|
-
const expr = trimmed.slice(0, -3).trim();
|
|
343
|
-
if (expr) {
|
|
344
|
-
attrs.push({ spread: true, expression: new Expression(expr) });
|
|
345
|
-
}
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
if (trimmed.startsWith('...')) {
|
|
349
|
-
const expr = trimmed.slice(3).trim();
|
|
350
|
-
if (expr) {
|
|
351
|
-
attrs.push({ spread: true, expression: new Expression(expr) });
|
|
352
|
-
}
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
const colonIdx = findColon(trimmed);
|
|
356
|
-
if (colonIdx === -1) {
|
|
357
|
-
// Shorthand: {foo} → foo={foo}
|
|
358
|
-
attrs.push({
|
|
359
|
-
name: trimmed,
|
|
360
|
-
value: new Expression(trimmed),
|
|
361
|
-
shorthand: true,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
else {
|
|
365
|
-
const name = trimmed.slice(0, colonIdx).trim();
|
|
366
|
-
const value = trimmed.slice(colonIdx + 1).trim();
|
|
367
|
-
// Strip quotes from key if present
|
|
368
|
-
const cleanName = name.replace(/^['"]|['"]$/g, '');
|
|
369
|
-
attrs.push({
|
|
370
|
-
name: cleanName,
|
|
371
|
-
value: new Expression(value),
|
|
372
|
-
shorthand: false,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
return attrs;
|
|
377
|
-
}
|
|
378
|
-
/** Split attribute string on commas, respecting nested brackets and string literals. */
|
|
379
|
-
function splitAttributePairs(source) {
|
|
380
|
-
const result = [];
|
|
381
|
-
let depth = 0;
|
|
382
|
-
let start = 0;
|
|
383
|
-
for (let i = 0; i < source.length; i++) {
|
|
384
|
-
const ch = source[i];
|
|
385
|
-
if (ch === '{' || ch === '[' || ch === '(')
|
|
386
|
-
depth++;
|
|
387
|
-
else if (ch === '}' || ch === ']' || ch === ')')
|
|
388
|
-
depth--;
|
|
389
|
-
else if ((ch === '"' || ch === "'") && depth === 0) {
|
|
390
|
-
// Skip over string literals
|
|
391
|
-
const q = ch;
|
|
392
|
-
i++;
|
|
393
|
-
while (i < source.length && source[i] !== q) {
|
|
394
|
-
if (source[i] === '\\')
|
|
395
|
-
i++;
|
|
396
|
-
i++;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
else if (ch === ',' && depth === 0) {
|
|
400
|
-
result.push(source.slice(start, i));
|
|
401
|
-
start = i + 1;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
result.push(source.slice(start));
|
|
405
|
-
return result;
|
|
406
|
-
}
|
|
407
|
-
/** Find the first colon that is not inside brackets or strings. */
|
|
408
|
-
function findColon(source) {
|
|
409
|
-
let depth = 0;
|
|
410
|
-
for (let i = 0; i < source.length; i++) {
|
|
411
|
-
const ch = source[i];
|
|
412
|
-
if (ch === '{' || ch === '[' || ch === '(')
|
|
413
|
-
depth++;
|
|
414
|
-
else if (ch === '}' || ch === ']' || ch === ')')
|
|
415
|
-
depth--;
|
|
416
|
-
else if ((ch === '"' || ch === "'") && depth === 0) {
|
|
417
|
-
const q = ch;
|
|
418
|
-
i++;
|
|
419
|
-
while (i < source.length && source[i] !== q) {
|
|
420
|
-
if (source[i] === '\\')
|
|
421
|
-
i++;
|
|
422
|
-
i++;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
else if (ch === ':' && depth === 0) {
|
|
426
|
-
return i;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return -1;
|
|
430
|
-
}
|
|
431
|
-
//# sourceMappingURL=parser.js.map
|