coffeehaml 0.4.2 → 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.
@@ -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