coffeehaml 0.1.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/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/ast.d.ts +107 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +125 -0
- package/dist/ast.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +53 -0
- package/dist/cli.js.map +1 -0
- package/dist/compiler.d.ts +10 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +83 -0
- package/dist/compiler.js.map +1 -0
- package/dist/emitter.d.ts +4 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +464 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -0
- package/dist/expressions.d.ts +10 -0
- package/dist/expressions.d.ts.map +1 -0
- package/dist/expressions.js +70 -0
- package/dist/expressions.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer.d.ts +29 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +315 -0
- package/dist/lexer.js.map +1 -0
- package/dist/parser.d.ts +4 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +408 -0
- package/dist/parser.js.map +1 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/vite-plugin.d.ts +7 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +44 -0
- package/dist/vite-plugin.js.map +1 -0
- package/docs/README.md +110 -0
- package/docs/architecture.md +619 -0
- package/docs/ast.md +413 -0
- package/docs/examples.md +719 -0
- package/docs/grammar.md +432 -0
- package/docs/index.html +129 -0
- package/package.json +61 -0
package/docs/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# CoffeeHaml
|
|
2
|
+
|
|
3
|
+
> *Haml structure. CoffeeScript semantics. React runtime.*
|
|
4
|
+
|
|
5
|
+
CoffeeHaml is a compiler that transforms an indentation-based, Haml-inspired
|
|
6
|
+
authoring language into JavaScript using the modern React JSX runtime
|
|
7
|
+
(`react/jsx-runtime`).
|
|
8
|
+
|
|
9
|
+
It is **not** a UI framework. React remains the runtime. CoffeeHaml replaces JSX.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Philosophy
|
|
14
|
+
|
|
15
|
+
| Concern | Approach |
|
|
16
|
+
|---------|----------|
|
|
17
|
+
| **Structure** | Haml — significant indentation, `%tag`, `.class`, `#id` |
|
|
18
|
+
| **Semantics** | CoffeeScript — expression-oriented, `->`, `for...in`, implicit return |
|
|
19
|
+
| **Runtime** | React — `jsx()` / `jsxs()` calls, zero CoffeeHaml runtime |
|
|
20
|
+
| **Output** | JavaScript (ES2020+) with `react/jsx-runtime` imports |
|
|
21
|
+
|
|
22
|
+
## Five-Second Glimpse
|
|
23
|
+
|
|
24
|
+
```haml
|
|
25
|
+
%SimulatorPanel{source: "gyro", width: "auto", height: 500}
|
|
26
|
+
- for servo in servos
|
|
27
|
+
%ServoGraph{channel: servo.channel}
|
|
28
|
+
%ServoPanel{servo: servo}
|
|
29
|
+
|
|
30
|
+
%Button{onClick: save, disabled: !connected}
|
|
31
|
+
Save
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Compiles to:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
38
|
+
jsxs(SimulatorPanel, { source: "gyro", width: "auto", height: 500 },
|
|
39
|
+
...servos.map(servo => jsxs(Fragment, {},
|
|
40
|
+
jsx(ServoGraph, { channel: servo.channel }),
|
|
41
|
+
jsx(ServoPanel, { servo })
|
|
42
|
+
)),
|
|
43
|
+
jsx(Button, { onClick: save, disabled: !connected }, "Save")
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Design Constraints
|
|
48
|
+
|
|
49
|
+
- **Maximum compatibility with Haml** — existing muscle memory transfers
|
|
50
|
+
- **CoffeeScript expression syntax** — not Ruby, not JSX
|
|
51
|
+
- **Token-efficient** — fewer characters than JSX for equivalent output
|
|
52
|
+
- **No invention** — reuse Haml and CoffeeScript conventions
|
|
53
|
+
- **Zero runtime** — the compiler is the only artifact
|
|
54
|
+
- **Excellent source maps** — debug in CoffeeHaml, not generated JS
|
|
55
|
+
- **Incremental compilation** — Vite plugin with HMR support
|
|
56
|
+
- **Emitter-agnostic AST** — future backends (Solid, Vue, Mithril) possible
|
|
57
|
+
|
|
58
|
+
## Token Efficiency & The Indentation Renaissance
|
|
59
|
+
|
|
60
|
+
CoffeeHaml is designed at the dawn of a shift in how code is generated. Modern
|
|
61
|
+
LLMs are increasingly trained on indentation-based languages — Python, YAML,
|
|
62
|
+
Haml, Sass, Stylus — and their tokenizers have internalized significant
|
|
63
|
+
whitespace as structural signal. Where JSX wastes tokens on `<`, `</`, `>`, `/>`,
|
|
64
|
+
`{`, `}`, and closing tags that echo the opening tag name verbatim, CoffeeHaml
|
|
65
|
+
lets the indentation *be* the structure. No closing tags. No angle brackets.
|
|
66
|
+
No delimiter pairs that must be matched.
|
|
67
|
+
|
|
68
|
+
### Token count comparison (equivalent output)
|
|
69
|
+
|
|
70
|
+
| Syntax | Tokens |
|
|
71
|
+
|--------|--------|
|
|
72
|
+
| `<div className="box"><h1>Hello</h1><p>World</p></div>` | 17 |
|
|
73
|
+
| `%div.box%h1 Hello%p World` | 8 |
|
|
74
|
+
| **50%+ reduction** in structural tokens | |
|
|
75
|
+
|
|
76
|
+
Every character in CoffeeHaml carries semantic weight. There are no syntactic
|
|
77
|
+
ceremonies — only meaning. This is not merely an aesthetic preference; in an era
|
|
78
|
+
where LLMs both consume and produce code, token efficiency directly impacts
|
|
79
|
+
context window utilization, inference cost, and generation speed.
|
|
80
|
+
|
|
81
|
+
### The LLM training flywheel
|
|
82
|
+
|
|
83
|
+
Indentation-based languages create a virtuous cycle:
|
|
84
|
+
|
|
85
|
+
1. **LLMs are trained on them** → tokenizers learn to treat indentation as
|
|
86
|
+
structural, not ornamental
|
|
87
|
+
2. **LLMs generate them** → more indentation-based code enters the training
|
|
88
|
+
corpus
|
|
89
|
+
3. **Tooling improves** → better completions, lower error rates, tighter
|
|
90
|
+
feedback loops
|
|
91
|
+
|
|
92
|
+
CoffeeHaml enters this cycle at precisely the right moment. React developers
|
|
93
|
+
have been writing JSX for a decade — the community is ready for a leap in
|
|
94
|
+
expressiveness that removes boilerplate rather than adding it.
|
|
95
|
+
|
|
96
|
+
### Why not just use Haml with Ruby?
|
|
97
|
+
|
|
98
|
+
Because React is JavaScript. Bridging Ruby semantics to React components
|
|
99
|
+
introduces impedance mismatch. CoffeeScript, by contrast, compiles directly to
|
|
100
|
+
JavaScript and shares its runtime model. `->` becomes `() =>`. `for...in`
|
|
101
|
+
becomes `.map()`. The expressions you write in CoffeeHaml attributes *are*
|
|
102
|
+
CoffeeScript — no foreign runtime, no translation layer, no surprises.
|
|
103
|
+
|
|
104
|
+
## Status
|
|
105
|
+
|
|
106
|
+
**Design phase.** See:
|
|
107
|
+
- [Grammar](docs/grammar.md)
|
|
108
|
+
- [AST Design](docs/ast.md)
|
|
109
|
+
- [Architecture](docs/architecture.md)
|
|
110
|
+
- [Syntax Examples](docs/examples.md)
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
# CoffeeHaml Architecture
|
|
2
|
+
|
|
3
|
+
## Compiler Pipeline
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
CoffeeHaml Source
|
|
7
|
+
│
|
|
8
|
+
▼
|
|
9
|
+
┌──────────┐
|
|
10
|
+
│ Lexer │ Tokenizes source into Token stream
|
|
11
|
+
└──────────┘ (INDENT/DEDENT inserted by IndentProcessor)
|
|
12
|
+
│
|
|
13
|
+
▼
|
|
14
|
+
┌──────────┐
|
|
15
|
+
│ Parser │ Recursive descent, produces CoffeeHaml AST
|
|
16
|
+
└──────────┘ Delegates expressions to CoffeeScript parser
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
┌──────────┐
|
|
20
|
+
│ Resolver │ Resolves imports, validates identifiers
|
|
21
|
+
└──────────┘ (optional: type-checking pass)
|
|
22
|
+
│
|
|
23
|
+
▼
|
|
24
|
+
┌──────────┐
|
|
25
|
+
│ Emitter │ Walks AST, emits JavaScript with jsx()/jsxs()
|
|
26
|
+
└──────────┘ Produces final .js/.jsx output + source map
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
JavaScript + Source Map
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Phase 1: Lexer
|
|
35
|
+
|
|
36
|
+
### Responsibilities
|
|
37
|
+
|
|
38
|
+
- Scan raw CoffeeHaml source into a stream of tokens
|
|
39
|
+
- Handle significant whitespace (INDENT/DEDENT)
|
|
40
|
+
- Extract raw source for expression blocks (`{...}`, `(...)`, `= expr`)
|
|
41
|
+
- Preserve source locations for every token
|
|
42
|
+
|
|
43
|
+
### Token Stream
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Source: %div.container#main\n %p Hello\n
|
|
47
|
+
|
|
48
|
+
Tokens:
|
|
49
|
+
TAG "%div" @ 1:1
|
|
50
|
+
CLASS ".container" @ 1:4
|
|
51
|
+
ID "#main" @ 1:14
|
|
52
|
+
NEWLINE @ 1:19
|
|
53
|
+
INDENT @ 2:1
|
|
54
|
+
TAG "%p" @ 2:3
|
|
55
|
+
TEXT "Hello" @ 2:6
|
|
56
|
+
NEWLINE @ 2:11
|
|
57
|
+
DEDENT @ EOF
|
|
58
|
+
EOF @ EOF
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Indentation Processor
|
|
62
|
+
|
|
63
|
+
The lexer contains an internal `IndentStack` that tracks indentation
|
|
64
|
+
levels. On each non-blank line:
|
|
65
|
+
|
|
66
|
+
1. Compute the line's indent depth (spaces or tabs)
|
|
67
|
+
2. Compare to the top of the stack
|
|
68
|
+
3. If deeper → push, emit `INDENT`
|
|
69
|
+
4. If equal → continue
|
|
70
|
+
5. If shallower → pop and emit `DEDENT` for each level until match
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
IndentStack: [0]
|
|
74
|
+
Line " text" (depth 2) → push 2, emit INDENT
|
|
75
|
+
Line " more" (depth 4) → push 4, emit INDENT
|
|
76
|
+
Line " text" (depth 2) → pop 4 (DEDENT), match 2
|
|
77
|
+
Line "" (blank) → skip
|
|
78
|
+
EOF → pop 2 (DEDENT), pop 0 (DEDENT)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Expression Extraction
|
|
82
|
+
|
|
83
|
+
When the lexer encounters `{`, it enters a **brace-scanning mode**:
|
|
84
|
+
|
|
85
|
+
1. Track nesting depth of `{`/`}`
|
|
86
|
+
2. Track string boundaries (`"`, `'`, `"""`, `'''`, `"` backtick)
|
|
87
|
+
3. Track comment boundaries (`#` line comments, `###` block comments)
|
|
88
|
+
4. Track regex boundaries (`///`)
|
|
89
|
+
5. When depth reaches 0, emit the entire span as one token
|
|
90
|
+
|
|
91
|
+
Similar logic for `(...)` attribute blocks.
|
|
92
|
+
|
|
93
|
+
After `=` or `-`, the lexer scans to end-of-line and emits the
|
|
94
|
+
expression source as a token.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Source: %div{class: "hello}"}
|
|
98
|
+
↑
|
|
99
|
+
This brace is inside a string → ignored
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Token Types (Full Enumeration)
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
enum TokenKind {
|
|
106
|
+
// Structural
|
|
107
|
+
TAG, // %div
|
|
108
|
+
CLASS, // .container
|
|
109
|
+
ID, // #main
|
|
110
|
+
|
|
111
|
+
// Attributes
|
|
112
|
+
ATTR_BRACE_OPEN, // { (with extracted content)
|
|
113
|
+
ATTR_BRACE_CLOSE, // }
|
|
114
|
+
ATTR_PAREN_OPEN, // ( (with extracted content)
|
|
115
|
+
ATTR_PAREN_CLOSE, // )
|
|
116
|
+
|
|
117
|
+
// Inline
|
|
118
|
+
TEXT, // literal text
|
|
119
|
+
OUTPUT, // =
|
|
120
|
+
OUTPUT_RAW, // ==
|
|
121
|
+
|
|
122
|
+
// Control
|
|
123
|
+
CONTROL, // -
|
|
124
|
+
COMMENT_HAML, // -#
|
|
125
|
+
COMMENT_HTML, // /
|
|
126
|
+
FILTER, // :css, :javascript, etc.
|
|
127
|
+
DOCTYPE, // !!!
|
|
128
|
+
|
|
129
|
+
// Meta
|
|
130
|
+
NEWLINE,
|
|
131
|
+
INDENT,
|
|
132
|
+
DEDENT,
|
|
133
|
+
EOF,
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Phase 2: Parser
|
|
140
|
+
|
|
141
|
+
### Architecture
|
|
142
|
+
|
|
143
|
+
The parser is a **recursive descent** parser with one token of lookahead.
|
|
144
|
+
It consumes the token stream from the lexer and produces the CoffeeHaml
|
|
145
|
+
AST.
|
|
146
|
+
|
|
147
|
+
For CoffeeScript expressions (attributes, outputs, control conditions),
|
|
148
|
+
the parser delegates to the **CoffeeScript compiler's parser** —
|
|
149
|
+
specifically `CoffeeScript.parse()` — to obtain a CoffeeScript AST for
|
|
150
|
+
the expression source. This avoids re-implementing CoffeeScript's
|
|
151
|
+
expression grammar.
|
|
152
|
+
|
|
153
|
+
### Parser State
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
class CoffeeHamlParser {
|
|
157
|
+
private tokens: Token[];
|
|
158
|
+
private pos: number;
|
|
159
|
+
private indentStack: number[];
|
|
160
|
+
|
|
161
|
+
constructor(source: string);
|
|
162
|
+
|
|
163
|
+
// Entry point
|
|
164
|
+
parse(): Document;
|
|
165
|
+
|
|
166
|
+
// Node parsers
|
|
167
|
+
private parseNode(): Node;
|
|
168
|
+
private parseElement(): Element;
|
|
169
|
+
private parseImplicitDiv(): ImplicitDiv;
|
|
170
|
+
private parseText(): Text;
|
|
171
|
+
private parseOutput(): Output;
|
|
172
|
+
private parseControlFlow(): ControlFlow;
|
|
173
|
+
private parseComment(): Comment;
|
|
174
|
+
private parseFilter(): Filter;
|
|
175
|
+
private parseDoctype(): Doctype;
|
|
176
|
+
private parseChildren(): Node[];
|
|
177
|
+
|
|
178
|
+
// Helpers
|
|
179
|
+
private peek(): Token;
|
|
180
|
+
private advance(): Token;
|
|
181
|
+
private expect(kind: TokenKind): Token;
|
|
182
|
+
private parseExpression(raw: string): Expression;
|
|
183
|
+
private parseAttributes(raw: string): Attribute[];
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Parsing Algorithm
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
parse():
|
|
191
|
+
create Document node
|
|
192
|
+
while not EOF:
|
|
193
|
+
skip blank lines
|
|
194
|
+
node = parseNode()
|
|
195
|
+
append node to document.children
|
|
196
|
+
return document
|
|
197
|
+
|
|
198
|
+
parseNode():
|
|
199
|
+
token = peek()
|
|
200
|
+
switch:
|
|
201
|
+
case "!!!" → parseDoctype()
|
|
202
|
+
case "%" → parseElement()
|
|
203
|
+
case "." → parseImplicitDiv()
|
|
204
|
+
case "#" → parseImplicitDiv()
|
|
205
|
+
case "-#" → parseComment()
|
|
206
|
+
case "/" → parseComment() or parseFilter()? (context)
|
|
207
|
+
case "-" → parseControlFlow()
|
|
208
|
+
case "=" → parseOutput()
|
|
209
|
+
case ":" → parseFilter()
|
|
210
|
+
default → parseText()
|
|
211
|
+
|
|
212
|
+
parseElement():
|
|
213
|
+
advance TAG
|
|
214
|
+
parse tag modifiers (.class, #id) while peek is CLASS or ID
|
|
215
|
+
if peek is ATTR_BRACE_OPEN: parse attribute block
|
|
216
|
+
if peek is TEXT: parse inline text
|
|
217
|
+
if inline text contains '=': parse inline output suffix
|
|
218
|
+
if peek is SELF_CLOSE + NEWLINE: mark self-closing
|
|
219
|
+
advance NEWLINE
|
|
220
|
+
if next token is INDENT:
|
|
221
|
+
children = parseChildren()
|
|
222
|
+
return Element { ... }
|
|
223
|
+
|
|
224
|
+
parseChildren():
|
|
225
|
+
advance INDENT
|
|
226
|
+
nodes = []
|
|
227
|
+
while not DEDENT and not EOF:
|
|
228
|
+
nodes.push(parseNode())
|
|
229
|
+
advance DEDENT
|
|
230
|
+
return nodes
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Expression Delegation
|
|
234
|
+
|
|
235
|
+
When the parser needs to parse a CoffeeScript expression (e.g., the
|
|
236
|
+
content of `{...}` attributes), it:
|
|
237
|
+
|
|
238
|
+
1. Extracts the raw source string from the token
|
|
239
|
+
2. Calls `CoffeeScript.parse(source)` to get a CoffeeScript AST
|
|
240
|
+
3. Wraps it in our `Expression` node
|
|
241
|
+
4. For attribute blocks, walks the CoffeeScript object literal AST
|
|
242
|
+
to extract individual `Attribute` nodes
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
parseAttributes(raw: string): Attribute[] {
|
|
246
|
+
// Wrap in braces and parse as CoffeeScript object
|
|
247
|
+
const objAst = CoffeeScript.parse(`{${raw}}`);
|
|
248
|
+
// Walk the object literal AST to extract key-value pairs
|
|
249
|
+
return extractAttributes(objAst);
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Error Recovery
|
|
254
|
+
|
|
255
|
+
The parser uses a simple **panic mode** recovery:
|
|
256
|
+
|
|
257
|
+
1. On parse error, skip tokens until NEWLINE or DEDENT
|
|
258
|
+
2. Record the error in a diagnostic list
|
|
259
|
+
3. Continue parsing subsequent nodes
|
|
260
|
+
|
|
261
|
+
This enables reporting multiple errors in one pass.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Phase 3: AST Resolver (Optional)
|
|
266
|
+
|
|
267
|
+
Before emission, a resolver pass may:
|
|
268
|
+
|
|
269
|
+
1. **Validate component references**: Check that capitalized tags resolve
|
|
270
|
+
to identifiers in scope (requires import analysis)
|
|
271
|
+
2. **Resolve filter processors**: Map filter names to handler functions
|
|
272
|
+
3. **Optimize static subtrees**: Mark subtrees that are fully static
|
|
273
|
+
(no expressions, no control flow) for potential hoisting
|
|
274
|
+
|
|
275
|
+
This phase is optional for initial implementation but important for
|
|
276
|
+
producing good diagnostics.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Phase 4: Emitter (React JSX Runtime Backend)
|
|
281
|
+
|
|
282
|
+
### Architecture
|
|
283
|
+
|
|
284
|
+
The emitter walks the CoffeeHaml AST and generates JavaScript source
|
|
285
|
+
code that uses `react/jsx-runtime`.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
class ReactJSRuntimeEmitter {
|
|
289
|
+
private sourceMap: SourceMapGenerator;
|
|
290
|
+
private indentLevel: number;
|
|
291
|
+
|
|
292
|
+
emit(document: Document): { code: string; map: SourceMap };
|
|
293
|
+
|
|
294
|
+
private emitNode(node: Node): string;
|
|
295
|
+
private emitElement(node: Element): string;
|
|
296
|
+
private emitChildren(children: Node[]): string;
|
|
297
|
+
private emitControlFlow(node: ControlFlow): string;
|
|
298
|
+
// ...
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Core Emission Rules
|
|
303
|
+
|
|
304
|
+
#### Element → `jsx()` or `jsxs()`
|
|
305
|
+
|
|
306
|
+
An element with **no children** (and no inline text/output) emits `jsx()`:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
// %br/
|
|
310
|
+
jsx("br", null)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
An element with **one child** may also use `jsx()`:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
// %div Hello
|
|
317
|
+
jsx("div", { children: "Hello" })
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
An element with **multiple children** uses `jsxs()`:
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
// %div
|
|
324
|
+
// %p A
|
|
325
|
+
// %p B
|
|
326
|
+
jsxs("div", {}, jsx("p", {}, "A"), jsx("p", {}, "B"))
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
#### Component vs HTML element
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
// %MyComponent{prop: val}
|
|
333
|
+
jsx(MyComponent, { prop: val })
|
|
334
|
+
|
|
335
|
+
// %div{id: "main"}
|
|
336
|
+
jsx("div", { id: "main" })
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
#### Attributes merging
|
|
340
|
+
|
|
341
|
+
`.class` and `#id` modifiers merge with explicit attributes:
|
|
342
|
+
|
|
343
|
+
```haml
|
|
344
|
+
%div.container#main{id: "override", "data-x": "y"}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```js
|
|
348
|
+
jsx("div", { className: "container", id: "override", "data-x": "y" })
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
If both `#id` and `{id: ...}` are present, the explicit attribute wins.
|
|
352
|
+
|
|
353
|
+
Multiple `.class` modifiers concatenate with spaces.
|
|
354
|
+
|
|
355
|
+
#### Inline text
|
|
356
|
+
|
|
357
|
+
```haml
|
|
358
|
+
%p Hello, = name
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
jsx("p", {}, "Hello, ", name)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
#### Control flow children
|
|
366
|
+
|
|
367
|
+
This is the most complex emission case. Control flow bodies become
|
|
368
|
+
arrays spread into the parent's children:
|
|
369
|
+
|
|
370
|
+
```haml
|
|
371
|
+
%div
|
|
372
|
+
- for item in items
|
|
373
|
+
%ItemCard{item: item}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
```js
|
|
377
|
+
jsxs("div", {},
|
|
378
|
+
...items.map(item => jsx(ItemCard, { item }))
|
|
379
|
+
);
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The general pattern:
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
- for PATTERN in EXPR → ...EXPR.map((PATTERN) => BODY)
|
|
386
|
+
- if COND → ...(COND ? [BODY] : [])
|
|
387
|
+
- if COND ... - else → ...(COND ? [BODY] : [ALTERNATE])
|
|
388
|
+
- unless COND → ...(!COND ? [BODY] : [])
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**For loop with index**:
|
|
392
|
+
|
|
393
|
+
```haml
|
|
394
|
+
- for item, idx in items
|
|
395
|
+
%Row{item: item, key: idx}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```js
|
|
399
|
+
...items.map((item, idx) => jsx(Row, { item, key: idx }))
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Nested control flow**:
|
|
403
|
+
|
|
404
|
+
```haml
|
|
405
|
+
%div
|
|
406
|
+
- if user
|
|
407
|
+
%WelcomeBanner{user: user}
|
|
408
|
+
- for post in user.posts
|
|
409
|
+
%PostCard{post: post}
|
|
410
|
+
- else
|
|
411
|
+
%LoginPrompt
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
```js
|
|
415
|
+
jsxs("div", {},
|
|
416
|
+
...(user ? [
|
|
417
|
+
jsx(WelcomeBanner, { user }),
|
|
418
|
+
...user.posts.map(post => jsx(PostCard, { post }))
|
|
419
|
+
] : [
|
|
420
|
+
jsx(LoginPrompt, {})
|
|
421
|
+
])
|
|
422
|
+
);
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
#### Arbitrary CoffeeScript statements
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
- console.log("rendering")
|
|
429
|
+
%div Hello
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
console.log("rendering");
|
|
434
|
+
jsx("div", {}, "Hello");
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Statements that produce no value are emitted as-is and do not affect
|
|
438
|
+
the parent's child array.
|
|
439
|
+
|
|
440
|
+
#### Output (`=`, `==`)
|
|
441
|
+
|
|
442
|
+
```haml
|
|
443
|
+
= user.name
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
```js
|
|
447
|
+
user.name
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Output nodes compile directly to their CoffeeScript expression compiled
|
|
451
|
+
to JavaScript. The parent element wraps them as children.
|
|
452
|
+
|
|
453
|
+
#### Filters
|
|
454
|
+
|
|
455
|
+
```haml
|
|
456
|
+
:css
|
|
457
|
+
body { margin: 0 }
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
```js
|
|
461
|
+
// Compiled at build time; options:
|
|
462
|
+
// 1. Inline as <style> tag (via jsx):
|
|
463
|
+
jsx("style", {}, "body { margin: 0 }")
|
|
464
|
+
// 2. Extract to CSS file (Vite plugin integrates with CSS pipeline)
|
|
465
|
+
// 3. Inline as string for CSS-in-JS libraries
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The filter handler is pluggable; the emitter delegates to registered
|
|
469
|
+
filter processors.
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
### Module Wrapper
|
|
474
|
+
|
|
475
|
+
The emitter wraps output in a module that imports from
|
|
476
|
+
`react/jsx-runtime`:
|
|
477
|
+
|
|
478
|
+
```js
|
|
479
|
+
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
|
|
480
|
+
export default function CoffeeHamlComponent() {
|
|
481
|
+
// emitted nodes
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
The component name and export style are configurable (default export,
|
|
486
|
+
named export, arrow function, etc.).
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## Vite Plugin Integration
|
|
491
|
+
|
|
492
|
+
```
|
|
493
|
+
Vite config
|
|
494
|
+
│
|
|
495
|
+
▼
|
|
496
|
+
┌─────────────────┐
|
|
497
|
+
│ vite-plugin- │ Intercepts .haml / .coffeehaml imports
|
|
498
|
+
│ coffeehaml │
|
|
499
|
+
└─────────────────┘
|
|
500
|
+
│
|
|
501
|
+
▼
|
|
502
|
+
┌─────────────────┐
|
|
503
|
+
│ CoffeeHaml │ Compiles source → JS + source map
|
|
504
|
+
│ Compiler │
|
|
505
|
+
└─────────────────┘
|
|
506
|
+
│
|
|
507
|
+
▼
|
|
508
|
+
┌─────────────────┐
|
|
509
|
+
│ Vite HMR │ File change → recompile → HMR update
|
|
510
|
+
│ pipeline │
|
|
511
|
+
└─────────────────┘
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
The Vite plugin:
|
|
515
|
+
|
|
516
|
+
1. Matches `.haml` and `.coffeehaml` file extensions
|
|
517
|
+
2. Compiles CoffeeHaml → JavaScript using the compiler
|
|
518
|
+
3. Returns compiled JS + source map to Vite
|
|
519
|
+
4. Handles HMR by recompiling on file change
|
|
520
|
+
5. Optionally processes filter blocks (`:css` → CSS extraction)
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Incremental Compilation
|
|
525
|
+
|
|
526
|
+
For incremental builds, the compiler caches:
|
|
527
|
+
|
|
528
|
+
- The parsed AST (keyed by file hash)
|
|
529
|
+
- Resolved import maps
|
|
530
|
+
- Compiled filter output
|
|
531
|
+
|
|
532
|
+
On file change:
|
|
533
|
+
1. Check if the file's hash changed
|
|
534
|
+
2. If yes, re-lex and re-parse only that file
|
|
535
|
+
3. Re-emit only the changed file
|
|
536
|
+
4. Invalidate dependent files (files that import the changed file)
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## Source Maps
|
|
541
|
+
|
|
542
|
+
Every emission step records mappings:
|
|
543
|
+
|
|
544
|
+
```
|
|
545
|
+
CoffeeHaml source position → JavaScript output position
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
The emitter uses the `loc` fields on AST nodes to create source mappings.
|
|
549
|
+
For CoffeeScript expressions, the CoffeeScript compiler provides its own
|
|
550
|
+
source maps, which are composed with the CoffeeHaml source maps.
|
|
551
|
+
|
|
552
|
+
This yields a **composed source map** chain:
|
|
553
|
+
|
|
554
|
+
```
|
|
555
|
+
Browser JS position
|
|
556
|
+
→ CoffeeHaml JS output position
|
|
557
|
+
→ CoffeeHaml source position
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
For CoffeeScript expressions, there's an intermediate step:
|
|
561
|
+
|
|
562
|
+
```
|
|
563
|
+
Browser JS position
|
|
564
|
+
→ CoffeeHaml JS output position
|
|
565
|
+
→ CoffeeScript source position (within attribute/expression)
|
|
566
|
+
→ CoffeeHaml source position (the expression as a whole)
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Error Reporting
|
|
572
|
+
|
|
573
|
+
Errors reference CoffeeHaml source locations:
|
|
574
|
+
|
|
575
|
+
```
|
|
576
|
+
Error: Unknown component "MyCopmonent" (did you mean "MyComponent"?)
|
|
577
|
+
at src/components/Dashboard.coffeehaml:12:3
|
|
578
|
+
11 |
|
|
579
|
+
12 | %MyCopmonent{data: items}
|
|
580
|
+
^
|
|
581
|
+
13 |
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The compiler produces structured diagnostics:
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
interface Diagnostic {
|
|
588
|
+
severity: "error" | "warning" | "info";
|
|
589
|
+
message: string;
|
|
590
|
+
loc: SourceLocation;
|
|
591
|
+
hint?: string; // suggested fix
|
|
592
|
+
code?: string; // error code for documentation
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## Implementation Language
|
|
599
|
+
|
|
600
|
+
The compiler is implemented in **TypeScript** for:
|
|
601
|
+
|
|
602
|
+
- Type safety and self-documenting interfaces
|
|
603
|
+
- First-class Node.js/Vite ecosystem integration
|
|
604
|
+
- Access to the CoffeeScript compiler's JS API
|
|
605
|
+
- Ease of contribution from the React community
|
|
606
|
+
|
|
607
|
+
The CoffeeScript dependency is used solely for **expression parsing** —
|
|
608
|
+
the CoffeeHaml compiler itself does not re-implement CoffeeScript.
|
|
609
|
+
|
|
610
|
+
```json
|
|
611
|
+
{
|
|
612
|
+
"dependencies": {
|
|
613
|
+
"coffeescript": "^2.7.0"
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
Future: A self-hosting CoffeeHaml compiler written in CoffeeHaml + a
|
|
619
|
+
Node.js backend would be poetically satisfying, but is not a v1 goal.
|