coffeehaml 0.7.7 → 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/README.md CHANGED
@@ -152,6 +152,8 @@ console.log(result.code);
152
152
  | `- code` | Arbitrary CoffeeScript statements; continuation via indented lines |
153
153
  | `- for x in xs` | Loop → `.map()` |
154
154
  | `- else`, `- else if` | Chain conditionals |
155
+ | `---` … `---` | Fenced CoffeeScript block — module-scope code (imports, helpers, setup) |
156
+ | `===` … `===` | Fenced yield block — CoffeeScript whose final value renders (escaped) |
155
157
  | `-# comment` | Haml comment (stripped) |
156
158
  | `/ comment` | HTML comment |
157
159
  | `.wrapper` | Implicit div |
@@ -173,7 +175,7 @@ Full grammar: [`docs/grammar.md`](docs/grammar.md)
173
175
 
174
176
  ## Status
175
177
 
176
- **v0.7.0 — production beta.** The compiler pipeline (Lexer → Parser → Emitter)
178
+ **v0.8.0 — production beta.** The compiler pipeline (Lexer → Parser → Emitter)
177
179
  is complete. The [Prettier plugin](src/prettier/) provides 16 deactivatable
178
180
  formatting options including attribute style preservation, statement merging,
179
181
  blank line handling, and CoffeeScript code formatting.
@@ -194,6 +196,8 @@ blank line handling, and CoffeeScript code formatting.
194
196
  | Haml/HTML comments | ✅ |
195
197
  | `:filter` blocks | ✅ |
196
198
  | Prologue passthrough (`import`, `@decorator`) | ✅ |
199
+ | `---` module-scope CoffeeScript block | ✅ |
200
+ | `===` yield block (fenced output) | ✅ |
197
201
  | Component / HOC wrapping (`wrap: 'observer'`) | ✅ |
198
202
  | Vite plugin with HMR | ✅ |
199
203
  | CLI `--wrap` flag | ✅ |
package/dist/ast.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  } from './types.js';
7
7
 
8
8
  // ─── Node Kinds ────────────────────────────────────────────
9
- export var NodeKind = ['Document', 'Element', 'ImplicitDiv', 'Text', 'Output', 'ControlFlow', 'Comment', 'Filter', 'Doctype'];
9
+ export var NodeKind = ['Document', 'Element', 'ImplicitDiv', 'Text', 'Output', 'ControlFlow', 'Comment', 'Filter', 'CoffeeBlock', 'CoffeeYield', 'Doctype'];
10
10
 
11
11
  export var ControlFlowKind = ['if', 'unless', 'for', 'while', 'else', 'statement'];
12
12
 
@@ -319,6 +319,48 @@ export var Filter = (function() {
319
319
 
320
320
  }).call(this);
321
321
 
322
+ export var CoffeeBlock = (function() {
323
+ // ─── CoffeeBlock ───────────────────────────────────────────
324
+ // A fenced raw CoffeeScript block (--- ... ---). Its content is a
325
+ // multi-line CoffeeScript program, compiled to executable JS statements
326
+ // and hoisted to module scope (imports, helpers, setup code).
327
+ class CoffeeBlock extends Node {
328
+ constructor(content, location = null) {
329
+ super(location);
330
+ this.content = content;
331
+ }
332
+
333
+ };
334
+
335
+ CoffeeBlock.prototype.kind = 'CoffeeBlock';
336
+
337
+ CoffeeBlock.prototype.content = '';
338
+
339
+ return CoffeeBlock;
340
+
341
+ }).call(this);
342
+
343
+ export var CoffeeYield = (function() {
344
+ // ─── CoffeeYield ───────────────────────────────────────────
345
+ // A fenced CoffeeScript yield block (=== ... ===). The body is a
346
+ // multi-line CoffeeScript program whose final value is yielded as
347
+ // content at that position (the multiline counterpart to `=`).
348
+ class CoffeeYield extends Node {
349
+ constructor(content, location = null) {
350
+ super(location);
351
+ this.content = content;
352
+ }
353
+
354
+ };
355
+
356
+ CoffeeYield.prototype.kind = 'CoffeeYield';
357
+
358
+ CoffeeYield.prototype.content = '';
359
+
360
+ return CoffeeYield;
361
+
362
+ }).call(this);
363
+
322
364
  export var Doctype = (function() {
323
365
  // ─── Doctype ───────────────────────────────────────────────
324
366
  class Doctype extends Node {
package/dist/cli.js CHANGED
File without changes
package/dist/emitter.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Generated by CoffeeScript 2.7.0
2
2
  // emitter.coffee — AST → JSX/JS code generation
3
- var EmitState, VOID_ELEMENTS, attrNameToJs, buildAttributes, compileComponentBody, emitArrowOutput, emitArrowOutputToJs, emitChildToJs, emitComment, emitConditional, emitControlFlow, emitControlFlowToJs, emitDoctype, emitElement, emitElseBranch, emitFilter, emitFilterToJs, emitImplicitDiv, emitLoop, emitNode, emitNodes, emitOutput, emitStatement, emitText, escapeString, mapSourceLocation, splitJsStatements, tagToJs;
3
+ var EmitState, VOID_ELEMENTS, attrNameToJs, buildAttributes, collectCoffeeBlocks, compileComponentBody, emitArrowOutput, emitArrowOutputToJs, emitChildToJs, emitCoffeeYield, emitComment, emitConditional, emitControlFlow, emitControlFlowToJs, emitDoctype, emitElement, emitElseBranch, emitFilter, emitFilterToJs, emitImplicitDiv, emitLoop, emitNode, emitNodes, emitOutput, emitStatement, emitText, escapeString, mapSourceLocation, splitJsStatements, tagToJs;
4
4
 
5
5
  import {
6
6
  Document,
@@ -11,6 +11,8 @@ import {
11
11
  ControlFlow,
12
12
  Comment,
13
13
  Filter,
14
+ CoffeeBlock,
15
+ CoffeeYield,
14
16
  Doctype,
15
17
  Node,
16
18
  Expression
@@ -18,7 +20,8 @@ import {
18
20
 
19
21
  import {
20
22
  compileExpression,
21
- compileStatement
23
+ compileStatement,
24
+ compileYield
22
25
  } from './expressions.js';
23
26
 
24
27
  import {
@@ -110,7 +113,7 @@ EmitState = (function() {
110
113
 
111
114
  // ─── Public API ────────────────────────────────────────────
112
115
  export var emit = function(ast, options = {}) {
113
- var bodyExpr, hocs, inner, j, len, line, name, ref, result, state, wrap, wrapped;
116
+ var b, blocks, bodyExpr, hocs, inner, j, k, len, len1, line, name, ref, result, state, wrap, wrapped;
114
117
  state = new EmitState(options);
115
118
  state.emitLine(`import { jsx, jsxs, Fragment } from \"${options.jsxRuntime || 'react/jsx-runtime'}\";`);
116
119
  // Emit prologue lines
@@ -122,6 +125,17 @@ export var emit = function(ast, options = {}) {
122
125
  }
123
126
  }
124
127
  state.emitLine();
128
+ // Emit fenced CoffeeScript blocks (--- ... ---) at module scope.
129
+ // Imports, helpers and setup code must live outside any component
130
+ // wrapper, so they are collected here and emitted before the body.
131
+ blocks = collectCoffeeBlocks(ast.children, []);
132
+ if (blocks.length > 0) {
133
+ for (k = 0, len1 = blocks.length; k < len1; k++) {
134
+ b = blocks[k];
135
+ state.emitLine(compileStatement(b.content, b.location));
136
+ }
137
+ state.emitLine();
138
+ }
125
139
  wrap = options.wrap;
126
140
  if (wrap && wrap !== 'none') {
127
141
  name = options.componentName || 'Component';
@@ -231,6 +245,33 @@ splitJsStatements = function(body) {
231
245
  };
232
246
 
233
247
  // ─── Node Emitters ─────────────────────────────────────────
248
+
249
+ // Recursively collect fenced CoffeeScript blocks for module-level hoisting,
250
+ // removing them from the render tree in place (they contribute no markup).
251
+ collectCoffeeBlocks = function(nodes, acc) {
252
+ var i, node, ref;
253
+ if (!(nodes != null ? nodes.length : void 0)) {
254
+ return acc;
255
+ }
256
+ i = 0;
257
+ while (i < nodes.length) {
258
+ node = nodes[i];
259
+ if (node instanceof CoffeeBlock) {
260
+ acc.push(node);
261
+ nodes.splice(i, 1);
262
+ continue;
263
+ }
264
+ if (((ref = node.children) != null ? ref.length : void 0) > 0) {
265
+ collectCoffeeBlocks(node.children, acc);
266
+ }
267
+ if (node.next != null) {
268
+ collectCoffeeBlocks([node.next], acc);
269
+ }
270
+ i++;
271
+ }
272
+ return acc;
273
+ };
274
+
234
275
  emitNodes = function(nodes, state, isRoot = false) {
235
276
  var i, node, remaining;
236
277
  i = 0;
@@ -248,6 +289,12 @@ emitNodes = function(nodes, state, isRoot = false) {
248
289
  };
249
290
 
250
291
  emitNode = function(node, state, isRoot) {
292
+ if (node instanceof CoffeeBlock) { // hoisted to module scope above
293
+ return;
294
+ }
295
+ if (node instanceof CoffeeYield) {
296
+ return emitCoffeeYield(node, state);
297
+ }
251
298
  if (node instanceof Element) {
252
299
  return emitElement(node, state);
253
300
  } else if (node instanceof ImplicitDiv) {
@@ -456,6 +503,9 @@ emitChildToJs = function(node, state) {
456
503
  if (node instanceof Filter) {
457
504
  return emitFilterToJs(node, state);
458
505
  }
506
+ if (node instanceof CoffeeYield) {
507
+ return compileYield(node.content, node.location);
508
+ }
459
509
  return 'null';
460
510
  };
461
511
 
@@ -683,6 +733,12 @@ emitOutput = function(out, state) {
683
733
  return state.emitLine(result + ';');
684
734
  };
685
735
 
736
+ emitCoffeeYield = function(node, state) {
737
+ var js;
738
+ js = compileYield(node.content, node.location);
739
+ return state.emitLine(`jsx(Fragment, { children: ${js} });`);
740
+ };
741
+
686
742
  emitArrowOutput = function(exprSource, elementChildren, out, state) {
687
743
  var arrowArgs, arrowFn, arrowMatch, body, callTarget, compiledTarget, result, stripped;
688
744
  stripped = exprSource.replace(/\s*->\s*$/, '').trim();
@@ -1,6 +1,6 @@
1
1
  // Generated by CoffeeScript 2.7.0
2
2
  // expressions.coffee — CoffeeScript expression compilation bridge
3
- var _coffeeScriptAvailable, _coffeeScriptUnavailableReason, _require, checkCoffeeScript, collapseWs, loadCoffeeScript, objectTarget, splitTopLevelStatements, stripCoffeeWrapper, stripToConst,
3
+ var _coffeeScriptAvailable, _coffeeScriptUnavailableReason, _require, checkCoffeeScript, collapseWs, isSingleExpression, loadCoffeeScript, objectTarget, splitTopLevelStatements, stripCoffeeWrapper, stripToConst,
4
4
  indexOf = [].indexOf;
5
5
 
6
6
  import {
@@ -179,6 +179,55 @@ export var compileStatement = function(source, location = null) {
179
179
  return source;
180
180
  };
181
181
 
182
+ // ─── Compile yield block to JS expression ──────────────────
183
+ // Compiles a fenced `=== ... ===` body to a single JS expression whose
184
+ // value is the body's final result (the multiline counterpart to `=`).
185
+ // A single expression compiles cleanly; multiple statements are wrapped
186
+ // in an IIFE so CoffeeScript's implicit return becomes the yielded value.
187
+ export var compileYield = function(source, location = null) {
188
+ var CoffeeScript, e, indented, js, msg, wrapped;
189
+ if (source.trim() === '') {
190
+ return '';
191
+ }
192
+ try {
193
+ CoffeeScript = loadCoffeeScript();
194
+ if (!(CoffeeScript && typeof CoffeeScript.compile === 'function')) {
195
+ return source;
196
+ }
197
+ if (isSingleExpression(source)) {
198
+ return compileExpression(new Expression(source), location);
199
+ }
200
+ indented = source.replace(/^/gm, ' ');
201
+ wrapped = `(->\n${indented}\n)()`;
202
+ js = CoffeeScript.compile(wrapped, {
203
+ bare: true,
204
+ inlineMap: false
205
+ });
206
+ return collapseWs(js.trim()).replace(/;\s*$/, '');
207
+ } catch (error) {
208
+ e = error;
209
+ msg = e instanceof Error ? e.message : String(e);
210
+ if (location) {
211
+ throw new CompileError(`CoffeeScript yield error: ${msg}`, 'emitter', 'YIELD_ERROR', location, `Check block: ${source.slice(0, 80)}`);
212
+ }
213
+ }
214
+ return source;
215
+ };
216
+
217
+ isSingleExpression = function(source) {
218
+ var CoffeeScript, ref, ref1, root;
219
+ try {
220
+ CoffeeScript = loadCoffeeScript();
221
+ if (CoffeeScript && typeof CoffeeScript.nodes === 'function') {
222
+ root = CoffeeScript.nodes(source);
223
+ return (root != null ? (ref = root.body) != null ? (ref1 = ref.expressions) != null ? ref1.length : void 0 : void 0 : void 0) === 1;
224
+ }
225
+ } catch (error) {
226
+ false;
227
+ }
228
+ return false;
229
+ };
230
+
182
231
  // ─── Convert var → const/let ───────────────────────────────
183
232
 
184
233
  // Extract the bound variable name from one entry of an object
@@ -194,7 +243,7 @@ objectTarget = function(pair) {
194
243
  };
195
244
 
196
245
  stripToConst = function(js) {
197
- var allConsumed, assignments, body, consumed, inner, j, len, m, n, name, names, s, stmts, varIdx, varNames;
246
+ var allConsumed, body, constReplacements, consumed, idx, inner, j, len, m, n, name, names, s, stmts, varIdx, varNames;
198
247
  stmts = splitTopLevelStatements(js);
199
248
  if (stmts.length === 0) {
200
249
  return js.replace(/;\s*$/, '');
@@ -220,10 +269,10 @@ stripToConst = function(js) {
220
269
  }
221
270
  return results;
222
271
  })();
223
- assignments = [];
272
+ constReplacements = {};
224
273
  consumed = [];
225
- for (j = 0, len = body.length; j < len; j++) {
226
- s = body[j];
274
+ for (idx = j = 0, len = body.length; j < len; idx = ++j) {
275
+ s = body[idx];
227
276
  m = s.match(/^\[([^\]]*)\]\s*=/);
228
277
  if (m != null) {
229
278
  // Array destructuring: [a, b] = expr
@@ -240,7 +289,7 @@ stripToConst = function(js) {
240
289
  return results;
241
290
  })();
242
291
  consumed = consumed.concat(names);
243
- assignments.push('const ' + s);
292
+ constReplacements[idx] = 'const ' + s;
244
293
  continue;
245
294
  }
246
295
  m = s.match(/^\(\s*\{([^}]*)\}\s*=.*\)$/);
@@ -260,7 +309,7 @@ stripToConst = function(js) {
260
309
  return results;
261
310
  })();
262
311
  consumed = consumed.concat(names);
263
- assignments.push('const ' + inner);
312
+ constReplacements[idx] = 'const ' + inner;
264
313
  continue;
265
314
  }
266
315
  name = varNames.find(function(n) {
@@ -268,14 +317,24 @@ stripToConst = function(js) {
268
317
  });
269
318
  if (name != null) {
270
319
  consumed.push(name);
271
- assignments.push('const ' + s);
320
+ constReplacements[idx] = 'const ' + s;
272
321
  }
273
322
  }
274
323
  allConsumed = varNames.every(function(n) {
275
324
  return indexOf.call(consumed, n) >= 0;
276
325
  });
277
- if (assignments.length > 0 && allConsumed) {
278
- return assignments.join('; ');
326
+ if (allConsumed && Object.keys(constReplacements).length > 0) {
327
+ return ((function() {
328
+ var k, len1, ref, results;
329
+ results = [];
330
+ for (idx = k = 0, len1 = body.length; k < len1; idx = ++k) {
331
+ s = body[idx];
332
+ // Preserve non-assignment statements (imports, calls, declarations)
333
+ // in their original order, converting only bound assignments to const.
334
+ results.push((ref = constReplacements[idx]) != null ? ref : s);
335
+ }
336
+ return results;
337
+ })()).join('; ');
279
338
  }
280
339
  return ((function() {
281
340
  var k, len1, results;
package/dist/lexer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Generated by CoffeeScript 2.7.0
2
2
  // lexer.coffee — CoffeeHaml tokenizer
3
3
  // Converts raw .chaml source into a token stream with INDENT/DEDENT
4
- var countIndent, dedentToken, extractBracketed, indentToken, isHamlConstruct, looksLikeBareAttributes, parseModifiersAndAttrs, preMergeBracketLines, tokenizeLine;
4
+ var countIndent, dedentLines, dedentToken, extractBracketed, indentToken, isHamlConstruct, looksLikeBareAttributes, parseModifiersAndAttrs, preMergeBracketLines, stripIndent, tokenizeLine;
5
5
 
6
6
  import {
7
7
  SourceLocation
@@ -21,6 +21,8 @@ export var TokenType = {
21
21
  COMMENT: 'COMMENT',
22
22
  HTML_COMMENT: 'HTML_COMMENT',
23
23
  FILTER: 'FILTER',
24
+ COFFEE_BLOCK: 'COFFEE_BLOCK',
25
+ COFFEE_YIELD: 'COFFEE_YIELD',
24
26
  DOCTYPE: 'DOCTYPE',
25
27
  TEXT: 'TEXT',
26
28
  PROLOGUE: 'PROLOGUE',
@@ -75,21 +77,23 @@ preMergeBracketLines = function(lines) {
75
77
  };
76
78
 
77
79
  export var tokenize = function(source, filename = null) {
78
- var content, currentIndent, inPrologue, indent, indentStack, j, len, line, lineIndex, lineLength, lineStartOffset, lineTokens, lines, offset, rawLine, tokens;
80
+ var body, bodyLine, content, currentIndent, fence, fenceIndent, fenceStartLine, fenceStartOffset, i, inPrologue, indent, indentStack, line, lineIndex, lineLength, lineStartOffset, lineTokens, lines, offset, rawLine, tokens;
79
81
  tokens = [];
80
82
  lines = preMergeBracketLines(source.split('\n'));
81
83
  indentStack = [0];
82
84
  offset = 0;
83
85
  lineIndex = 0;
84
86
  inPrologue = true;
85
- for (j = 0, len = lines.length; j < len; j++) {
86
- rawLine = lines[j];
87
+ i = 0;
88
+ while (i < lines.length) {
89
+ rawLine = lines[i];
87
90
  line = rawLine.replace(/\r$/, '');
88
91
  lineLength = rawLine.length + 1;
89
92
  // Skip empty lines
90
93
  if (line.trim() === '') {
91
94
  offset += lineLength;
92
95
  lineIndex++;
96
+ i++;
93
97
  continue;
94
98
  }
95
99
  indent = countIndent(line);
@@ -97,6 +101,73 @@ export var tokenize = function(source, filename = null) {
97
101
  if (content === '') {
98
102
  offset += lineLength;
99
103
  lineIndex++;
104
+ i++;
105
+ continue;
106
+ }
107
+ // ─── Multiline CoffeeScript fences: --- ... --- / === ... === ───
108
+ // A line consisting solely of `---` or `===` opens a raw CoffeeScript
109
+ // area that escapes HAML indentation. `---` compiles the body to code
110
+ // hoisted to module scope (COFFEE_BLOCK); `===` yields the body's final
111
+ // value as content at that position (COFFEE_YIELD). The body runs until
112
+ // the matching closing fence line; it is dedented to column 0 and
113
+ // tokenized as a single token (no INDENT/DEDENT inside).
114
+ fence = content.trim();
115
+ if (fence === '---' || fence === '===') {
116
+ inPrologue = false;
117
+ fenceIndent = indent;
118
+ fenceStartLine = lineIndex;
119
+ fenceStartOffset = offset + indent;
120
+ // Handle indentation change (same as a normal line)
121
+ currentIndent = indentStack[indentStack.length - 1];
122
+ if (fenceIndent > currentIndent) {
123
+ indentStack.push(fenceIndent);
124
+ tokens.push(indentToken(fenceIndent, fenceStartOffset, fenceIndent, filename, fenceStartLine));
125
+ } else if (fenceIndent < currentIndent) {
126
+ while (indentStack.length > 1 && fenceIndent < indentStack[indentStack.length - 1]) {
127
+ indentStack.pop();
128
+ tokens.push(dedentToken(fenceStartOffset, filename, fenceStartLine));
129
+ }
130
+ if (fenceIndent !== indentStack[indentStack.length - 1]) {
131
+ indentStack.push(fenceIndent);
132
+ tokens.push(indentToken(fenceIndent, fenceStartOffset, fenceIndent, filename, fenceStartLine));
133
+ }
134
+ }
135
+ // Consume opening fence
136
+ offset += lineLength;
137
+ lineIndex++;
138
+ i++;
139
+ // Collect body until closing fence
140
+ body = [];
141
+ while (i < lines.length) {
142
+ bodyLine = lines[i].replace(/\r$/, '');
143
+ if (bodyLine.trim() === fence) {
144
+ offset += lines[i].length + 1;
145
+ lineIndex++;
146
+ i++;
147
+ break;
148
+ }
149
+ body.push(bodyLine);
150
+ offset += lines[i].length + 1;
151
+ lineIndex++;
152
+ i++;
153
+ }
154
+ tokens.push({
155
+ type: (fence === '===' ? TokenType.COFFEE_YIELD : TokenType.COFFEE_BLOCK),
156
+ value: (dedentLines(body)).join('\n'),
157
+ location: {
158
+ start: {
159
+ line: fenceStartLine,
160
+ column: fenceIndent
161
+ },
162
+ end: {
163
+ line: lineIndex,
164
+ column: 0
165
+ },
166
+ offset: fenceStartOffset,
167
+ length: offset - fenceStartOffset,
168
+ file: filename
169
+ }
170
+ });
100
171
  continue;
101
172
  }
102
173
  // Prologue detection: non-indented JS before first HAML construct
@@ -120,6 +191,7 @@ export var tokenize = function(source, filename = null) {
120
191
  });
121
192
  offset += lineLength;
122
193
  lineIndex++;
194
+ i++;
123
195
  continue;
124
196
  }
125
197
  inPrologue = false;
@@ -144,6 +216,7 @@ export var tokenize = function(source, filename = null) {
144
216
  tokens.push(...lineTokens);
145
217
  offset += lineLength;
146
218
  lineIndex++;
219
+ i++;
147
220
  }
148
221
  // Emit remaining DEDENT tokens at EOF
149
222
  while (indentStack.length > 1) {
@@ -535,6 +608,52 @@ countIndent = function(line) {
535
608
  return count;
536
609
  };
537
610
 
611
+ // Strip up to `amount` visual columns of leading whitespace (space=1, tab=2).
612
+ stripIndent = function(line, amount) {
613
+ var ch, i, seen;
614
+ seen = 0;
615
+ i = 0;
616
+ while (i < line.length && seen < amount) {
617
+ ch = line[i];
618
+ if (ch === ' ') {
619
+ seen += 1;
620
+ } else if (ch === '\t') {
621
+ seen += 2;
622
+ } else {
623
+ break;
624
+ }
625
+ i++;
626
+ }
627
+ return line.slice(i);
628
+ };
629
+
630
+ // Normalize a fenced block's body to column 0 by removing the minimum
631
+ // indentation found across its non-empty lines.
632
+ dedentLines = function(lines) {
633
+ var amount, ind, j, k, l, len, len1, min, results;
634
+ min = null;
635
+ for (j = 0, len = lines.length; j < len; j++) {
636
+ l = lines[j];
637
+ if (l.trim() === '') {
638
+ continue;
639
+ }
640
+ ind = countIndent(l);
641
+ if (min === null || ind < min) {
642
+ min = ind;
643
+ }
644
+ }
645
+ amount = min === null ? 0 : min;
646
+ if (amount === 0) {
647
+ return lines;
648
+ }
649
+ results = [];
650
+ for (k = 0, len1 = lines.length; k < len1; k++) {
651
+ l = lines[k];
652
+ results.push(stripIndent(l, amount));
653
+ }
654
+ return results;
655
+ };
656
+
538
657
  indentToken = function(_indent, offset, level, filename = null, line = 0) {
539
658
  return {
540
659
  type: TokenType.INDENT,
package/dist/parser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Generated by CoffeeScript 2.7.0
2
2
  // parser.coffee — recursive descent parser
3
3
  // Converts token stream into AST
4
- var ParserState, cleanAttrName, findColon, findEquals, isElse, parseAttributeBlock, parseBareAttributes, parseBlock, parseComment, parseControlFlow, parseControlKind, parseDoctype, parseElement, parseFilter, parseHtmlComment, parseImplicitDiv, parseNode, parseOutput, parseText, splitAttributePairs, splitBareAttributePairs, stripControlKeyword;
4
+ var ParserState, cleanAttrName, findColon, findEquals, isElse, parseAttributeBlock, parseBareAttributes, parseBlock, parseCoffeeBlock, parseCoffeeYield, parseComment, parseControlFlow, parseControlKind, parseDoctype, parseElement, parseFilter, parseHtmlComment, parseImplicitDiv, parseNode, parseOutput, parseText, splitAttributePairs, splitBareAttributePairs, stripControlKeyword;
5
5
 
6
6
  import {
7
7
  TokenType
@@ -16,6 +16,8 @@ import {
16
16
  ControlFlow,
17
17
  Comment,
18
18
  Filter,
19
+ CoffeeBlock,
20
+ CoffeeYield,
19
21
  Doctype,
20
22
  Expression,
21
23
  Attribute,
@@ -79,7 +81,7 @@ ParserState = (function() {
79
81
  var t;
80
82
  while (this.current()) {
81
83
  t = this.current().type;
82
- if (t === TokenType.TAG || t === TokenType.CLASS || t === TokenType.ID || t === TokenType.OUTPUT || t === TokenType.OUTPUT_UNESC || t === TokenType.CONTROL || t === TokenType.FILTER || t === TokenType.COMMENT || t === TokenType.HTML_COMMENT || t === TokenType.DOCTYPE || t === TokenType.INDENT || t === TokenType.DEDENT) {
84
+ if (t === TokenType.TAG || t === TokenType.CLASS || t === TokenType.ID || t === TokenType.OUTPUT || t === TokenType.OUTPUT_UNESC || t === TokenType.CONTROL || t === TokenType.FILTER || t === TokenType.COFFEE_BLOCK || t === TokenType.COFFEE_YIELD || t === TokenType.COMMENT || t === TokenType.HTML_COMMENT || t === TokenType.DOCTYPE || t === TokenType.INDENT || t === TokenType.DEDENT) {
83
85
  return;
84
86
  }
85
87
  this.advance();
@@ -168,6 +170,10 @@ parseNode = function(state) {
168
170
  return parseHtmlComment(state);
169
171
  case TokenType.FILTER:
170
172
  return parseFilter(state);
173
+ case TokenType.COFFEE_BLOCK:
174
+ return parseCoffeeBlock(state);
175
+ case TokenType.COFFEE_YIELD:
176
+ return parseCoffeeYield(state);
171
177
  case TokenType.DOCTYPE:
172
178
  return parseDoctype(state);
173
179
  case TokenType.TEXT:
@@ -461,6 +467,25 @@ parseFilter = function(state) {
461
467
  return new Filter(filterName, content, token.location);
462
468
  };
463
469
 
470
+ // ─── CoffeeBlock ───────────────────────────────────────────
471
+ parseCoffeeBlock = function(state) {
472
+ var token;
473
+ token = state.expect(TokenType.COFFEE_BLOCK);
474
+ if (!token) {
475
+ return new CoffeeBlock('');
476
+ }
477
+ return new CoffeeBlock(token.value, token.location);
478
+ };
479
+
480
+ parseCoffeeYield = function(state) {
481
+ var token;
482
+ token = state.expect(TokenType.COFFEE_YIELD);
483
+ if (!token) {
484
+ return new CoffeeYield('');
485
+ }
486
+ return new CoffeeYield(token.value, token.location);
487
+ };
488
+
464
489
  // ─── Doctype ───────────────────────────────────────────────
465
490
  parseDoctype = function(state) {
466
491
  var token;
@@ -81,7 +81,7 @@ export var options = {
81
81
  attributeMultilineThreshold: {
82
82
  category: CAT.ATTRIBUTES,
83
83
  type: 'int',
84
- description: 'Break attributes across lines when count >= this. 0 = always multiline.'
84
+ description: 'Force attributes across lines when count >= this. 0 = never force (fold only when exceeding printWidth).'
85
85
  },
86
86
  attributeSort: {
87
87
  category: CAT.ATTRIBUTES,
@@ -218,7 +218,7 @@ export var defaultOptions = {
218
218
  inlineThreshold: -1,
219
219
  voidElementStyle: 'explicit',
220
220
  attributeStyle: 'preserve',
221
- attributeMultilineThreshold: 1,
221
+ attributeMultilineThreshold: 0,
222
222
  attributeSort: 'none',
223
223
  quoteStyle: 'preserve',
224
224
  coffeeScriptFormat: true,
@@ -1,7 +1,7 @@
1
1
  // Generated by CoffeeScript 2.7.0
2
2
  // printer.coffee — AST → Prettier Doc IR
3
3
  // Core formatting engine for CoffeeHaml Prettier plugin
4
- var MergedStatements, VOID_ELEMENTS, _prettier, _require, analyzeBlankLines, defaultOpts, detectStyle, formatAttributes, formatTag, group, hardline, indent, isSingleInlineChild, isVoidElement, join, joinChildrenWithBlanks, mergeAllStatements, opts, printComment, printControlFlow, printDoc, printDoctype, printElement, printFilter, printFlow, printImplicitDiv, printMergedFlow, printNode, printOutput, reflowCommentText;
4
+ var MergedStatements, VOID_ELEMENTS, _prettier, _require, analyzeBlankLines, defaultOpts, detectStyle, formatAttributes, formatTag, group, hardline, indent, isSingleInlineChild, isVoidElement, join, joinChildrenWithBlanks, line, mergeAllStatements, opts, printCoffeeBlock, printCoffeeYield, printComment, printControlFlow, printDoc, printDoctype, printElement, printFence, printFilter, printFlow, printImplicitDiv, printMergedFlow, printNode, printOutput, reflowCommentText;
5
5
 
6
6
  import {
7
7
  Document,
@@ -12,6 +12,8 @@ import {
12
12
  ControlFlow,
13
13
  Comment,
14
14
  Filter,
15
+ CoffeeBlock,
16
+ CoffeeYield,
15
17
  Doctype,
16
18
  Node,
17
19
  Expression,
@@ -32,7 +34,7 @@ _require = createRequire(import.meta.url);
32
34
 
33
35
  _prettier = _require('prettier');
34
36
 
35
- ({group, indent, hardline, join} = _prettier.doc.builders);
37
+ ({group, indent, hardline, line, join} = _prettier.doc.builders);
36
38
 
37
39
  // ─── Format Options ────────────────────────────────────────
38
40
  // CoffeeHamlFormatOptions shape (runtime):
@@ -49,7 +51,7 @@ defaultOpts = {
49
51
  inlineThreshold: -1,
50
52
  voidElementStyle: 'self-closing',
51
53
  attributeStyle: 'preserve',
52
- attributeMultilineThreshold: 1,
54
+ attributeMultilineThreshold: 0,
53
55
  attributeSort: 'none',
54
56
  quoteStyle: 'preserve',
55
57
  coffeeScriptFormat: true,
@@ -225,6 +227,12 @@ printNode = function(path, o, printFn) {
225
227
  if (node instanceof Filter) {
226
228
  return printFilter(path, o, printFn);
227
229
  }
230
+ if (node instanceof CoffeeBlock) {
231
+ return printCoffeeBlock(path, o, printFn);
232
+ }
233
+ if (node instanceof CoffeeYield) {
234
+ return printCoffeeYield(path, o, printFn);
235
+ }
228
236
  if (node instanceof Doctype) {
229
237
  return printDoctype(path, o);
230
238
  }
@@ -259,7 +267,7 @@ printDoc = function(path, o, printFn) {
259
267
 
260
268
  // ─── Element ───────────────────────────────────────────────
261
269
  printElement = function(path, o, printFn) {
262
- var attrStyle, attrs, childDoc, childDocs, children, classes, header, id, isVoid, joined, node, ref, ref1, ref2, ref3, selfClose, tag;
270
+ var attrStyle, attrs, childDoc, childDocs, children, classes, header, id, isVoid, joined, node, ref, ref1, ref2, ref3, selfClose, sep, tag;
263
271
  node = path.node;
264
272
  tag = formatTag(node.tag, o);
265
273
  // Classes and ID
@@ -282,16 +290,18 @@ printElement = function(path, o, printFn) {
282
290
  } else if (node.isSelfClosing) {
283
291
  selfClose = '/';
284
292
  }
285
- header = `%${tag}${classes}${id}${attrs}${selfClose}`;
293
+ header = [`%${tag}${classes}${id}`, attrs, selfClose];
286
294
  // Children
287
295
  children = (ref = node.children) != null ? ref : [];
288
296
  if (children.length === 0) {
289
297
  return header;
290
298
  }
291
299
  // Inline child
292
- if (isSingleInlineChild(children) && o.controlFlowInline !== false) {
300
+ if (isSingleInlineChild(children)) {
293
301
  childDoc = (ref1 = (ref2 = path.map(printFn, 'children')) != null ? ref2[0] : void 0) != null ? ref1 : '';
294
- return `${header} ${childDoc}`;
302
+ // Output (= expr) carries its own separator; Text needs a space
303
+ sep = children[0] instanceof Output ? '' : ' ';
304
+ return [header, sep, childDoc];
295
305
  }
296
306
  childDocs = (ref3 = path.map(printFn, 'children')) != null ? ref3 : [];
297
307
  joined = joinChildrenWithBlanks(children, childDocs, o);
@@ -300,7 +310,7 @@ printElement = function(path, o, printFn) {
300
310
 
301
311
  // ─── ImplicitDiv ───────────────────────────────────────────
302
312
  printImplicitDiv = function(path, o, printFn) {
303
- var attrStyle, attrs, childDoc, childDocs, children, classes, header, id, joined, node, ref, ref1, ref2, ref3;
313
+ var attrStyle, attrs, childDoc, childDocs, children, classes, header, id, joined, node, ref, ref1, ref2, ref3, sep;
304
314
  node = path.node;
305
315
  classes = node.classes.map(function(c) {
306
316
  return `.${c}`;
@@ -308,14 +318,15 @@ printImplicitDiv = function(path, o, printFn) {
308
318
  id = node.id ? `#${node.id}` : '';
309
319
  attrStyle = detectStyle(node, o);
310
320
  attrs = formatAttributes(node, attrStyle, o);
311
- header = `${classes}${id}${attrs}`;
321
+ header = [`${classes}${id}`, attrs];
312
322
  children = (ref = node.children) != null ? ref : [];
313
323
  if (children.length === 0) {
314
324
  return header;
315
325
  }
316
326
  if (isSingleInlineChild(children)) {
317
327
  childDoc = (ref1 = (ref2 = path.map(printFn, 'children')) != null ? ref2[0] : void 0) != null ? ref1 : '';
318
- return `${header} ${childDoc}`;
328
+ sep = children[0] instanceof Output ? '' : ' ';
329
+ return [header, sep, childDoc];
319
330
  }
320
331
  childDocs = (ref3 = path.map(printFn, 'children')) != null ? ref3 : [];
321
332
  joined = joinChildrenWithBlanks(children, childDocs, o);
@@ -473,6 +484,34 @@ printFilter = function(path, o, _printFn) {
473
484
  }
474
485
  };
475
486
 
487
+ // ─── CoffeeBlock ───────────────────────────────────────────
488
+
489
+ // Build a fenced block doc using `hardline` so Prettier can re-indent the
490
+ // dedented body to the correct nesting level (raw `\n` strings would not).
491
+ printFence = function(fence, content) {
492
+ var k, len, parts, ref;
493
+ if (content) {
494
+ parts = [fence];
495
+ ref = content.split('\n');
496
+ for (k = 0, len = ref.length; k < len; k++) {
497
+ line = ref[k];
498
+ parts.push(hardline, line);
499
+ }
500
+ parts.push(hardline, fence);
501
+ return parts;
502
+ } else {
503
+ return [fence, hardline, fence];
504
+ }
505
+ };
506
+
507
+ printCoffeeBlock = function(path, o, _printFn) {
508
+ return printFence('---', path.node.content);
509
+ };
510
+
511
+ printCoffeeYield = function(path, o, _printFn) {
512
+ return printFence('===', path.node.content);
513
+ };
514
+
476
515
  // ─── Doctype ───────────────────────────────────────────────
477
516
  printDoctype = function(path, o) {
478
517
  var node;
@@ -501,12 +540,14 @@ detectStyle = function(node, o) {
501
540
  };
502
541
 
503
542
  formatAttributes = function(node, style, o) {
504
- var attr, attrs, bareParts, expr, k, len, name, parts, ref, ref1, ref2, val;
543
+ var attr, attrs, bareParts, close, expr, forceBreak, k, len, name, open, parts, ref, ref1, ref2, sep, val;
505
544
  attrs = (ref = node.attributes) != null ? ref : [];
506
545
  if (!(attrs.length > 0)) {
507
546
  return '';
508
547
  }
509
- // Bare style: HTML-style key="val" key2={expr} — space-separated
548
+ // Bare style: HTML-style key="val" key2={expr} — space-separated.
549
+ // Never folded: an indented continuation line would parse as a child
550
+ // element, silently changing the tree. Bare attributes stay inline.
510
551
  if (style === 'bare') {
511
552
  bareParts = (function() {
512
553
  var k, len, ref1, ref2, results;
@@ -528,38 +569,44 @@ formatAttributes = function(node, style, o) {
528
569
  return results;
529
570
  })();
530
571
  return ` ${bareParts.join(' ')}`;
531
- } else {
532
- parts = [];
533
- for (k = 0, len = attrs.length; k < len; k++) {
534
- attr = attrs[k];
535
- if (attr.spread) {
536
- expr = (ref1 = attr.expression.source) != null ? ref1 : '';
537
- if (o.coffeeScriptFormat) {
538
- expr = formatCoffeeScript(expr, o);
539
- }
540
- parts.push(style === 'parens' ? `...${expr}` : `{${expr}...}`);
541
- continue;
542
- }
543
- name = attr.name;
544
- val = (ref2 = attr.value.source) != null ? ref2 : '';
572
+ }
573
+ parts = [];
574
+ for (k = 0, len = attrs.length; k < len; k++) {
575
+ attr = attrs[k];
576
+ if (attr.spread) {
577
+ expr = (ref1 = attr.expression.source) != null ? ref1 : '';
545
578
  if (o.coffeeScriptFormat) {
546
- val = formatCoffeeScript(val, o);
547
- }
548
- if (attr.shorthand) {
549
- parts.push(name);
550
- } else {
551
- parts.push(`${name}: ${val}`);
579
+ expr = formatCoffeeScript(expr, o);
552
580
  }
581
+ parts.push(style === 'parens' ? `...${expr}` : `{${expr}...}`);
582
+ continue;
553
583
  }
554
- switch (style) {
555
- case 'braces':
556
- return `{ ${parts.join(', ')} }`;
557
- case 'parens':
558
- return `( ${parts.join(', ')} )`;
559
- default:
560
- return parts.join(', ');
584
+ name = attr.name;
585
+ val = (ref2 = attr.value.source) != null ? ref2 : '';
586
+ if (o.coffeeScriptFormat) {
587
+ val = formatCoffeeScript(val, o);
588
+ }
589
+ if (attr.shorthand) {
590
+ parts.push(name);
591
+ } else {
592
+ parts.push(`${name}: ${val}`);
561
593
  }
562
594
  }
595
+ // Count-based forcing: a positive threshold breaks even when the
596
+ // attributes would otherwise fit within printWidth.
597
+ forceBreak = o.attributeMultilineThreshold > 0 && attrs.length >= o.attributeMultilineThreshold;
598
+ sep = forceBreak ? hardline : line;
599
+ if (style === 'braces') {
600
+ open = '{';
601
+ close = '}';
602
+ } else if (style === 'parens') {
603
+ open = '(';
604
+ close = ')';
605
+ } else {
606
+ // Unknown style — inline fallback, no folding.
607
+ return parts.join(', ');
608
+ }
609
+ return group([open, indent([sep, join([',', sep], parts)]), sep, close]);
563
610
  };
564
611
 
565
612
  // ─── CoffeeScript Embed (for Prettier embed) ───────────────
package/docs/grammar.md CHANGED
@@ -292,6 +292,72 @@ depending on configuration.
292
292
 
293
293
  ---
294
294
 
295
+ ## CoffeeScript Blocks (`---`)
296
+
297
+ ```
298
+ CoffeeBlock := '---' Newline
299
+ CoffeeBody
300
+ '---' Newline
301
+
302
+ CoffeeBody := Raw CoffeeScript lines, dedented to column 0.
303
+ ```
304
+
305
+ A fenced raw CoffeeScript area. The body between the two `---` lines is
306
+ taken verbatim (dedented by its minimum indentation) and compiled to
307
+ executable JavaScript via the CoffeeScript bridge, then hoisted to module
308
+ scope — outside any component wrapper. This is how imports, helper
309
+ functions and one-time setup code are expressed without fighting HAML's
310
+ indentation tree.
311
+
312
+ ```haml
313
+ ---
314
+ import { blah } from 'ok'
315
+ answer = 42
316
+ ---
317
+ %p= answer
318
+ ```
319
+
320
+ Semantics:
321
+
322
+ - **Escapes indentation**: body lines are dedented to column 0, so a block
323
+ nested under an element still writes CoffeeScript flush-left.
324
+ - **Module scope**: the compiled statements run once at module load, before
325
+ any JSX. `import`/`export` therefore stay valid (they are not wrapped in
326
+ a component function).
327
+ - **No markup**: the block contributes nothing to the render tree.
328
+
329
+ Note: `---` is a CoffeeHaml fence marker, not an element. For a horizontal
330
+ rule use `%hr` — mapping `---` to `<hr>` is a Markdown/YAML convention, not
331
+ Haml.
332
+
333
+ ---
334
+
335
+ ## Yield Blocks (`===`)
336
+
337
+ ```
338
+ CoffeeYield := '===' Newline
339
+ CoffeeBody
340
+ '===' Newline
341
+ ```
342
+
343
+ The `===` fence is the multiline counterpart to `=` (output): its body is
344
+ CoffeeScript, dedented to column 0 exactly like a `---` block, but instead
345
+ of being hoisted to module scope its **final value is yielded as content**
346
+ at that position in the render tree (escaped, matching `=`).
347
+
348
+ ```haml
349
+ %p
350
+ ===
351
+ name = computeName()
352
+ "Hello, #{name}"
353
+ ===
354
+ ```
355
+
356
+ A single-expression body compiles directly; a multi-statement body is
357
+ wrapped in an IIFE so the last expression is the yielded value.
358
+
359
+ ---
360
+
295
361
  ## Raw Text / Passthrough
296
362
 
297
363
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coffeehaml",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "Haml structure. CoffeeScript semantics. React runtime. Zero runtime. Vite-native.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,7 +62,7 @@
62
62
  "scripts": {
63
63
  "build": "coffee -c -o dist/ src/*.coffee && coffee -c -o dist/prettier/ src/prettier/*.coffee",
64
64
  "dev": "coffee -c -w -o dist/ src/*.coffee & coffee -c -w -o dist/prettier/ src/prettier/*.coffee",
65
- "test": "node test/run-compiler-tests.mjs && node test/prettier/run-integration-tests.mjs && node test/prettier/run-coffeescript-tests.mjs && node test/prettier/run-statement-merge-tests.mjs",
65
+ "test": "node test/run-compiler-tests.mjs && node test/run-coffee-block-tests.mjs && node test/prettier/run-integration-tests.mjs && node test/prettier/run-coffeescript-tests.mjs && node test/prettier/run-statement-merge-tests.mjs",
66
66
  "prepublishOnly": "npm run build",
67
67
  "fmt": "prettier --write --plugin=. **/*.chaml"
68
68
  },