@chilts/weave 0.6.0 → 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.
@@ -1,6 +1,6 @@
1
1
  import { lex } from '../lexer/index.js';
2
2
  import { parse } from '../parser/index.js';
3
- import { chunk, joinChunks, offsetMappings, lineCount, buildSourceMap } from './sourcemap.js';
3
+ import { chunk, concat, joinChunks, mark, offsetMappings, lineCount, buildSourceMap, sliceCaptured, verbatim, } from './sourcemap.js';
4
4
  export class CodegenError extends Error {
5
5
  line;
6
6
  col;
@@ -14,43 +14,119 @@ export class CodegenError extends Error {
14
14
  function capitalize(s) {
15
15
  return s.charAt(0).toUpperCase() + s.slice(1);
16
16
  }
17
+ // Appends a compiler-supplied argument (`attrs`, `blocks`, ...) to a
18
+ // captured argument or parameter list. A multi-line list may already end in
19
+ // a trailing comma, and when its ')' sat on a line of its own the extra goes
20
+ // on its own line too, so the output keeps the shape the source was written in.
21
+ // `ownLine` is false for an extra that opens a multi-line literal of its own.
22
+ function appendArg(args, extra, ownLine = true) {
23
+ if (args.code === '')
24
+ return concat(extra);
25
+ const body = args.code.trimEnd();
26
+ const tail = args.code.slice(body.length);
27
+ // Mappings past the body would sit in the trailing space, which moves.
28
+ const bodyLines = lineCount(body);
29
+ const lastLine = body.slice(body.lastIndexOf('\n') + 1);
30
+ const head = {
31
+ code: body,
32
+ mappings: args.mappings.filter(m => m.genLine < bodyLines || (m.genLine === bodyLines && m.genCol < lastLine.length)),
33
+ };
34
+ if (ownLine && tail.includes('\n')) {
35
+ const indent = /^ */.exec(lastLine)[0];
36
+ return concat(head, body.endsWith(',') ? '' : ',', `\n${indent}`, extra, `,${tail}`);
37
+ }
38
+ return concat(head, body.endsWith(',') ? ' ' : ', ', extra);
39
+ }
40
+ // Indents the continuation lines of a multi-line captured expression to sit
41
+ // under the output line it starts on. Captured text never holds a string that
42
+ // spans lines, so every newline is safe to indent after.
43
+ function reindent(text, prefix) {
44
+ return prefix === '' ? text : text.replace(/\n(?!\n|$)/g, `\n${prefix}`);
45
+ }
46
+ // `reindent` for a chunk: mappings on an indented line move right with it.
47
+ function reindentChunk(c, prefix) {
48
+ if (prefix === '')
49
+ return c;
50
+ const lines = c.code.split('\n');
51
+ const shifted = (line) => line > 0 && lines[line] !== '';
52
+ return {
53
+ code: reindent(c.code, prefix),
54
+ mappings: c.mappings.map(m => shifted(m.genLine) ? { ...m, genCol: m.genCol + prefix.length } : m),
55
+ };
56
+ }
57
+ // Wraps generated code around child chunks, mapping the wrapper — at the start
58
+ // and after each child — to the node, so only the children's own text maps
59
+ // elsewhere.
60
+ function within(loc, ...parts) {
61
+ return concat(mark(loc), ...parts.flatMap((p) => (typeof p === 'string' ? [p] : [p, mark(loc)])));
62
+ }
17
63
  function camelToKebab(name) {
18
64
  return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
19
65
  }
20
- function templateHasBlocks(node) {
66
+ // Every `block` in `nodes`, in source order. Mixin definitions are not
67
+ // descended into: their blocks belong to the mixin, not the caller.
68
+ function collectBlocks(nodes) {
69
+ const found = [];
21
70
  function walk(n) {
22
- if (n.type === 'Block')
23
- return true;
71
+ if (n.type === 'Block') {
72
+ found.push(n);
73
+ n.children.forEach(walk);
74
+ }
24
75
  if (n.type === 'Element')
25
- return n.children.some(walk);
76
+ n.children.forEach(walk);
77
+ if (n.type === 'MixinCall')
78
+ n.block.forEach(walk);
26
79
  if (n.type === 'Conditional') {
27
- if (n.consequent.some(walk))
28
- return true;
29
- for (const alt of n.alternates) {
30
- if (alt.body.some(walk))
31
- return true;
32
- }
33
- if (n.fallback?.some(walk))
34
- return true;
80
+ n.consequent.forEach(walk);
81
+ for (const alt of n.alternates)
82
+ alt.body.forEach(walk);
83
+ n.fallback?.forEach(walk);
84
+ }
85
+ if (n.type === 'Iteration') {
86
+ n.body.forEach(walk);
87
+ n.fallback?.forEach(walk);
35
88
  }
36
- if (n.type === 'Iteration')
37
- return n.body.some(walk);
38
89
  if (n.type === 'Extends') {
39
90
  // The entries in `blocks` are fills (the slots this template fills in
40
91
  // its parent), not slots of its own. A mid-layer template defines its
41
92
  // own slots by nesting `block` nodes inside that fill content — those
42
93
  // are what require a `blocks` parameter on this function.
43
- return n.blocks.some(fill => fill.children.some(walk));
94
+ for (const fill of n.blocks)
95
+ fill.children.forEach(walk);
44
96
  }
45
- return false;
46
97
  }
47
- return node.body.some(walk);
98
+ nodes.forEach(walk);
99
+ return found;
100
+ }
101
+ /** The named slots a template defines, first of each name, in source order. */
102
+ function templateSlots(node) {
103
+ const slots = new Map();
104
+ for (const b of collectBlocks(node.body)) {
105
+ if (b.name !== '' && !slots.has(b.name))
106
+ slots.set(b.name, b);
107
+ }
108
+ return [...slots.values()];
109
+ }
110
+ /** True when a mixin renders its call-site content with a bare `block`. */
111
+ function mixinHasBlock(node) {
112
+ return collectBlocks(node.body).some(b => b.name === '');
113
+ }
114
+ // The `<Name>Blocks` interface for a template's slots. Each slot is optional
115
+ // (an unfilled slot renders nothing, or its default) and typed like a mixin's
116
+ // call-site content. Each member maps back to the `block` that defines it.
117
+ function blocksInterface(node, slots) {
118
+ const members = slots.map(b => ` ${b.name}?: () => unknown`);
119
+ const code = `export interface ${capitalize(node.name)}Blocks {\n${members.join('\n')}\n}`;
120
+ const mappings = [mapping(node.loc), ...slots.map((b, i) => mapping(b.loc, i + 1))];
121
+ return { code, mappings };
48
122
  }
49
123
  function mapping(loc, genLine = 0) {
50
124
  return { genLine, genCol: 0, srcLine: loc.line - 1, srcCol: loc.col - 1 };
51
125
  }
52
126
  class CodeGenerator {
53
127
  formatted;
128
+ // Set while generating a mixin body, where `block` means call-site content.
129
+ inMixin = false;
54
130
  constructor(formatted = false) {
55
131
  this.formatted = formatted;
56
132
  }
@@ -63,23 +139,19 @@ class CodeGenerator {
63
139
  * Non-comment expressions get trailing commas only if more non-comment expressions follow.
64
140
  */
65
141
  joinWithCommentChunks(chunks, indent) {
66
- const allMappings = [];
67
- const lines = [];
68
- let currentLine = 0;
142
+ const parts = [];
69
143
  for (let i = 0; i < chunks.length; i++) {
70
144
  const c = chunks[i];
71
- const isComment = c.code.startsWith('//');
72
- if (isComment) {
73
- lines.push(`${indent}${c.code}`);
74
- }
75
- else {
145
+ if (i > 0)
146
+ parts.push('\n');
147
+ parts.push(indent, c);
148
+ if (!c.code.startsWith('//')) {
76
149
  const hasMore = chunks.slice(i + 1).some(e => !e.code.startsWith('//'));
77
- lines.push(hasMore ? `${indent}${c.code},` : `${indent}${c.code}`);
150
+ if (hasMore)
151
+ parts.push(',');
78
152
  }
79
- allMappings.push(...offsetMappings(c.mappings, currentLine));
80
- currentLine += lineCount(c.code) + 1;
81
153
  }
82
- return { code: lines.join('\n'), mappings: allMappings };
154
+ return concat(...parts);
83
155
  }
84
156
  genNode(node, indent = 0) {
85
157
  switch (node.type) {
@@ -110,9 +182,10 @@ class CodeGenerator {
110
182
  case 'MixinDefinition':
111
183
  return this.genMixinDefinition(node, indent);
112
184
  case 'MixinCall':
113
- return this.genMixinCall(node);
185
+ return this.genMixinCall(node, indent);
114
186
  case 'Include':
115
- return this.genInclude(node);
187
+ // Hoisted: generate() emits the import once it knows the names.
188
+ return null;
116
189
  case 'Block':
117
190
  return this.genBlock(node, indent);
118
191
  case 'Extends':
@@ -125,53 +198,53 @@ class CodeGenerator {
125
198
  genConditional(node, indent) {
126
199
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
127
200
  const consequentChunk = this.genFragment(node.consequent, indent + 1);
128
- let code = `${node.condition}\n${bi}? ${consequentChunk.code}`;
129
- const mappings = [
130
- mapping(node.loc),
131
- ...offsetMappings(consequentChunk.mappings, 1),
201
+ const condition = verbatim({ text: node.condition, loc: node.conditionLoc });
202
+ const parts = [
203
+ node.negated ? concat('!(', condition, ')') : condition,
204
+ `\n${bi}? `, consequentChunk,
132
205
  ];
133
206
  for (const alt of node.alternates) {
134
207
  const altBodyChunk = this.genFragment(alt.body, indent + 1);
135
- const prevLines = lineCount(code);
136
- code += `\n${bi}: ${alt.condition}\n${bi}? ${altBodyChunk.code}`;
137
- mappings.push(...offsetMappings(altBodyChunk.mappings, prevLines + 2));
208
+ parts.push(`\n${bi}: `, verbatim({ text: alt.condition, loc: alt.conditionLoc }), `\n${bi}? `, altBodyChunk);
138
209
  }
139
210
  if (node.fallback !== null) {
140
- const fallbackChunk = this.genFragment(node.fallback, indent + 1);
141
- const prevLines = lineCount(code);
142
- code += `\n${bi}: ${fallbackChunk.code}`;
143
- mappings.push(...offsetMappings(fallbackChunk.mappings, prevLines + 1));
211
+ parts.push(`\n${bi}: `, this.genFragment(node.fallback, indent + 1));
144
212
  }
145
213
  else {
146
- code += `\n${bi}: null`;
214
+ parts.push(`\n${bi}: null`);
147
215
  }
148
- return { code, mappings };
216
+ return concat(mark(node.loc), ...parts);
149
217
  }
150
218
  // --- Iteration generation ---
151
219
  genIteration(node, indent) {
152
- const { variable, iterable } = this.parseForParams(node.params, node.loc);
220
+ const { variable, iterable } = this.parseForParams({ text: node.params, loc: node.paramsLoc }, node.loc);
153
221
  const bodyChunk = this.genFragment(node.body, indent + 1);
154
222
  // Wrap destructured variables in parens for arrow function
155
- const param = variable.startsWith('{') || variable.startsWith('[')
156
- ? `(${variable})`
157
- : variable;
223
+ const param = variable.text.startsWith('{') || variable.text.startsWith('[')
224
+ ? concat('(', verbatim(variable), ')')
225
+ : verbatim(variable);
158
226
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
159
227
  const pi = this.formatted ? this.ind(indent) : '';
160
- const code = `${iterable}.map(${param} =>\n${bi}${bodyChunk.code}\n${pi})`;
161
- const mappings = [
162
- mapping(node.loc),
163
- ...offsetMappings(bodyChunk.mappings, 1),
164
- ];
165
- return { code, mappings };
228
+ // `each()` rather than `.map()`, so any iterable works (Set, Map,
229
+ // generators, `xs.entries()`) and an `else` can render when it's empty.
230
+ const head = reindentChunk(concat('each(', verbatim(iterable), ', ', param, ' =>'), this.formatted ? this.ind(indent) : '');
231
+ const parts = [mark(node.loc), head, `\n${bi}`, bodyChunk];
232
+ if (node.fallback) {
233
+ parts.push(`\n${pi}, () =>\n${bi}`, this.genFragment(node.fallback, indent + 1));
234
+ }
235
+ parts.push(`\n${pi})`);
236
+ return concat(...parts);
166
237
  }
167
238
  parseForParams(params, loc) {
168
- const ofIndex = params.indexOf(' of ');
169
- if (ofIndex === -1) {
170
- throw new CodegenError(`Invalid for params: only "for...of" is supported, got "${params}"`, loc.line, loc.col);
171
- }
172
- const binding = params.slice(0, ofIndex).trim();
173
- const iterable = params.slice(ofIndex + 4).trim();
174
- const variable = binding.replace(/^(const|let)\s+/, '');
239
+ // A multi-line header may put `of` at the start or end of a line.
240
+ const of = /\s+of\s+/.exec(params.text);
241
+ if (of === null) {
242
+ throw new CodegenError(`Invalid for params: only "for...of" is supported, got "${params.text}"`, loc.line, loc.col);
243
+ }
244
+ const binding = sliceCaptured(params, 0, of.index);
245
+ const iterable = sliceCaptured(params, of.index + of[0].length);
246
+ const keyword = /^(const|let)\s+/.exec(binding.text);
247
+ const variable = keyword ? sliceCaptured(binding, keyword[0].length) : binding;
175
248
  return { variable, iterable };
176
249
  }
177
250
  // --- Fragment helper ---
@@ -194,14 +267,12 @@ class CodeGenerator {
194
267
  .filter((e) => e !== null);
195
268
  if (!this.formatted) {
196
269
  const inner = this.joinWithCommentChunks(chunks, ' ');
197
- const code = `h(Fragment, {},\n${inner.code}\n)`;
198
- return { code, mappings: offsetMappings(inner.mappings, 1) };
270
+ return concat('h(Fragment, {},\n', inner, '\n)');
199
271
  }
200
272
  const ci = this.ind(indent + 1);
201
273
  const pi = this.ind(indent);
202
274
  const inner = this.joinWithCommentChunks(chunks, ci);
203
- const code = `h(Fragment, {},\n${inner.code}\n${pi})`;
204
- return { code, mappings: offsetMappings(inner.mappings, 1) };
275
+ return concat('h(Fragment, {},\n', inner, `\n${pi})`);
205
276
  }
206
277
  // --- Element generation ---
207
278
  genElement(node, indent) {
@@ -210,31 +281,23 @@ class CodeGenerator {
210
281
  .map(c => this.genNode(c, indent + 1))
211
282
  .filter((e) => e !== null);
212
283
  if (children.length === 0) {
213
- return chunk(`h("${node.tag}", ${attrs})`, node.loc);
284
+ return within(node.loc, `h("${node.tag}", `, attrs, ')');
214
285
  }
215
286
  const hasComments = children.some(c => c.code.startsWith('//'));
216
- if (!this.formatted && !hasComments) {
217
- const childrenStr = children.map(c => c.code).join(', ');
218
- return chunk(`h("${node.tag}", ${attrs}, ${childrenStr})`, node.loc);
219
- }
287
+ const inline = () => within(node.loc, `h("${node.tag}", `, attrs, ...children.flatMap(c => [', ', c]), ')');
288
+ if (!this.formatted && !hasComments)
289
+ return inline();
220
290
  // Keep inline when all children are leaf nodes (text, expressions) and no comments
221
291
  const leafTypes = new Set(['Text', 'BufferedCode', 'Pipe']);
222
292
  const allLeaf = !hasComments && node.children
223
293
  .filter(c => c.type !== 'Comment' && c.type !== 'PassthroughComment')
224
294
  .every(c => leafTypes.has(c.type));
225
- if (allLeaf) {
226
- const childrenStr = children.map(c => c.code).join(', ');
227
- return chunk(`h("${node.tag}", ${attrs}, ${childrenStr})`, node.loc);
228
- }
295
+ if (allLeaf)
296
+ return inline();
229
297
  const ci = this.ind(indent + 1);
230
298
  const pi = this.ind(indent);
231
299
  const inner = this.joinWithCommentChunks(children, ci);
232
- const code = `h("${node.tag}", ${attrs},\n${inner.code}\n${pi})`;
233
- const mappings = [
234
- mapping(node.loc),
235
- ...offsetMappings(inner.mappings, 1),
236
- ];
237
- return { code, mappings };
300
+ return within(node.loc, `h("${node.tag}", `, attrs, ',\n', inner, `\n${pi})`);
238
301
  }
239
302
  genAttrs(node) {
240
303
  const parts = [];
@@ -243,42 +306,59 @@ class CodeGenerator {
243
306
  // Classes: merge shorthand + explicit into one
244
307
  if (node.classes.length > 0 && explicitClass && explicitClass.value !== null) {
245
308
  const prefix = node.classes.join(' ');
246
- parts.push(`class: ${this.mergeClassValue(prefix, explicitClass.value)}`);
309
+ parts.push(concat(this.attrName(explicitClass), ': ', this.mergeClassValue(prefix, explicitClass)));
247
310
  }
248
311
  else if (node.classes.length > 0) {
249
- parts.push(`class: "${node.classes.join(' ')}"`);
312
+ parts.push(chunk(`class: "${node.classes.join(' ')}"`));
250
313
  }
251
314
  // ID from shorthand
252
315
  if (node.id !== null) {
253
- parts.push(`id: "${node.id}"`);
316
+ parts.push(chunk(`id: "${node.id}"`));
254
317
  }
255
318
  // Explicit attributes (skip class if already merged)
256
319
  for (const attr of node.attrs) {
257
320
  if (attr === explicitClass && node.classes.length > 0)
258
321
  continue;
259
- if (attr.spread) {
260
- parts.push(`...${attr.name}`);
261
- }
262
- else {
263
- // Every non-spread attribute carries a value (the parser rejects
264
- // valueless attributes); booleans arrive as the verbatim
265
- // expression `true` / `false`.
266
- const key = this.attrKey(attr.name);
267
- parts.push(`${key}: ${attr.value}`);
268
- }
322
+ parts.push(this.genAttr(attr));
269
323
  }
270
- if (parts.length === 0)
271
- return '{}';
272
- return `{ ${parts.join(', ')} }`;
324
+ return this.attrObject(parts);
273
325
  }
274
- mergeClassValue(prefix, value) {
275
- if (value.startsWith('`')) {
276
- return `\`${prefix} ${value.slice(1)}`;
277
- }
278
- if (value.startsWith('"')) {
279
- return `"${prefix} ${value.slice(1)}`;
280
- }
281
- return `\`${prefix} \${${value}}\``;
326
+ // `{ a, b }` from attribute entries, or `{}` for none.
327
+ attrObject(parts) {
328
+ if (parts.length === 0)
329
+ return chunk('{}');
330
+ return concat('{ ', ...parts.flatMap((p, i) => (i === 0 ? [p] : [', ', p])), ' }');
331
+ }
332
+ genAttr(attr) {
333
+ if (attr.spread)
334
+ return chunk(`...${attr.name}`);
335
+ // Every non-spread attribute carries a value (the parser rejects
336
+ // valueless attributes); booleans arrive as the verbatim
337
+ // expression `true` / `false`.
338
+ return concat(this.attrName(attr), ': ', verbatim({ text: attr.value, loc: attr.valueLoc }));
339
+ }
340
+ // The object key for an attribute, mapped to its name: written `name=value`
341
+ // with nothing between, the name ends just before the value.
342
+ attrName(attr) {
343
+ const key = this.attrKey(attr.name);
344
+ const valueStart = attr.valueLoc?.[0];
345
+ if (!valueStart)
346
+ return chunk(key);
347
+ return chunk(key, { line: valueStart.line, col: valueStart.col - attr.name.length - 1 });
348
+ }
349
+ mergeClassValue(prefix, attr) {
350
+ const value = attr.value;
351
+ const captured = { text: value, loc: attr.valueLoc };
352
+ // String and template literals are always strings, so merge them now.
353
+ // Anything else may be undefined/null/false at render time, which must
354
+ // add nothing rather than stringify into the class list.
355
+ if (value === '""' || value === "''" || value === '``') {
356
+ return chunk(`"${prefix}"`);
357
+ }
358
+ if (value.startsWith('`') || value.startsWith('"') || value.startsWith("'")) {
359
+ return concat(verbatim(sliceCaptured(captured, 0, 1)), `${prefix} `, verbatim(sliceCaptured(captured, 1)));
360
+ }
361
+ return concat(`mergeClass("${prefix}", `, verbatim(captured), ')');
282
362
  }
283
363
  attrKey(name) {
284
364
  if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name))
@@ -287,29 +367,31 @@ class CodeGenerator {
287
367
  }
288
368
  // --- Text generation ---
289
369
  genText(node) {
290
- return chunk(this.genSegments(node.segments), node.loc);
370
+ return concat(mark(node.loc), this.genSegments(node.segments));
291
371
  }
292
372
  genPipe(node) {
293
373
  if (node.segments.length === 0) {
294
374
  return chunk('" "', node.loc);
295
375
  }
296
- return chunk(this.genSegments(node.segments), node.loc);
376
+ return concat(mark(node.loc), this.genSegments(node.segments));
297
377
  }
298
378
  genSegments(segments) {
299
379
  if (segments.length === 1 && segments[0].type === 'text') {
300
- return `"${this.escapeString(segments[0].value)}"`;
380
+ return chunk(`"${this.escapeString(segments[0].value)}"`);
301
381
  }
302
- let result = '`';
382
+ // Tagged, so each value renders as an element child would: `raw()`
383
+ // and mixin output stay unescaped and null/undefined render nothing.
384
+ const parts = ['interpolate`'];
303
385
  for (const seg of segments) {
304
386
  if (seg.type === 'text') {
305
- result += this.escapeTemplateLiteral(seg.value);
387
+ parts.push(this.escapeTemplateLiteral(seg.value));
306
388
  }
307
389
  else {
308
- result += `\${${seg.expression}}`;
390
+ parts.push('${', verbatim({ text: seg.expression, loc: seg.loc ? [seg.loc] : undefined }), '}');
309
391
  }
310
392
  }
311
- result += '`';
312
- return result;
393
+ parts.push('`');
394
+ return concat(...parts);
313
395
  }
314
396
  escapeString(s) {
315
397
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
@@ -321,34 +403,38 @@ class CodeGenerator {
321
403
  genImport(node) {
322
404
  return chunk(node.statement, node.loc);
323
405
  }
406
+ // A template with slots (a layout, or a mid-layer template that both fills
407
+ // its parent's slots and exposes its own) is preceded by the interface its
408
+ // `blocks` parameter is typed with.
324
409
  genTemplate(node, indent) {
410
+ const slots = templateSlots(node);
411
+ const fn = this.genTemplateFunction(node, slots.length > 0, indent);
412
+ if (slots.length === 0)
413
+ return fn;
414
+ const iface = blocksInterface(node, slots);
415
+ return {
416
+ code: `${iface.code}\n\n${fn.code}`,
417
+ mappings: [...iface.mappings, ...offsetMappings(fn.mappings, lineCount(iface.code) + 2)],
418
+ };
419
+ }
420
+ genTemplateFunction(node, hasBlocks, indent) {
325
421
  const i1 = this.ind(indent + 1);
422
+ const declared = verbatim({ text: node.params, loc: node.paramsLoc });
423
+ const params = hasBlocks
424
+ ? appendArg(declared, `blocks: ${capitalize(node.name)}Blocks = {}`)
425
+ : declared;
426
+ const signature = concat(mark(node.loc), `export function ${node.name}(`, params, ') {\n');
326
427
  // Check if body contains an ExtendsNode (page template)
327
428
  const extendsNode = node.body.find((c) => c.type === 'Extends');
328
429
  if (extendsNode) {
329
430
  const extendsChunk = this.genExtends(extendsNode, indent + 1);
330
- // A mid-layer template both fills its parent's slots and exposes its
331
- // own (nested `block` nodes), so it needs a `blocks` parameter too.
332
- const params = templateHasBlocks(node)
333
- ? `${node.params}, blocks: ${capitalize(node.name)}Blocks = {}`
334
- : node.params;
335
- const code = `export function ${node.name}(${params}) {\n${i1}return ${extendsChunk.code}\n}`;
336
- const mappings = [
337
- mapping(node.loc),
338
- ...offsetMappings(extendsChunk.mappings, 1),
339
- ];
340
- return { code, mappings };
341
- }
342
- // Check if body tree contains BlockNodes (layout template)
343
- const hasBlocks = templateHasBlocks(node);
344
- const params = hasBlocks
345
- ? `${node.params}, blocks: ${capitalize(node.name)}Blocks = {}`
346
- : node.params;
431
+ return concat(signature, `${i1}return `, extendsChunk, '\n}');
432
+ }
347
433
  const bodyChunks = node.body
348
- .map(child => this.genNode(child, indent + 2))
434
+ .map(child => this.genNode(child, indent + 1))
349
435
  .filter((e) => e !== null);
350
436
  if (bodyChunks.length === 0) {
351
- return chunk(`export function ${node.name}(${params}) {\n${i1}return ''\n}`, node.loc);
437
+ return concat(signature, `${i1}return ''\n}`);
352
438
  }
353
439
  // Separate comments from code chunks
354
440
  const commentChunks = bodyChunks.filter(c => c.code.startsWith('//'));
@@ -363,104 +449,87 @@ class CodeGenerator {
363
449
  else {
364
450
  bodyChunk = this.genFragment(node.body, indent + 1);
365
451
  }
366
- // Prepend comments before the return statement
367
- const commentPrefix = commentChunks.length > 0
368
- ? commentChunks.map(c => `${i1}${c.code}`).join('\n') + '\n'
369
- : '';
370
- const commentLines = commentChunks.length;
371
- const code = `export function ${node.name}(${params}) {\n${commentPrefix}${i1}return ${bodyChunk.code}\n}`;
372
- const mappings = [
373
- mapping(node.loc),
374
- ...offsetMappings(bodyChunk.mappings, 1 + commentLines), // body starts after fn sig + comments
375
- ];
376
- return { code, mappings };
452
+ // Comments go before the return statement
453
+ const comments = commentChunks.flatMap(c => [i1, c, '\n']);
454
+ return concat(signature, ...comments, `${i1}return `, bodyChunk, '\n}');
377
455
  }
378
456
  // --- Mixin generation ---
379
457
  genMixinDefinition(node, indent) {
380
458
  const i1 = this.ind(indent + 1);
381
- const params = node.params
382
- ? `${node.params}, attrs: Attrs = {}`
383
- : 'attrs: Attrs = {}';
384
- const bodyChunks = node.body
385
- .map(child => this.genNode(child, indent + 1))
386
- .filter((e) => e !== null);
387
- const bodyChunk = this.genFragment(bodyChunks.length > 0 ? node.body : [], indent + 1);
388
- const code = `export function ${node.name}(${params}) {\n${i1}return ${bodyChunk.code}\n}`;
389
- const mappings = [
390
- mapping(node.loc),
391
- ...offsetMappings(bodyChunk.mappings, 1),
392
- ];
393
- return { code, mappings };
394
- }
395
- genMixinCall(node) {
396
- const argParts = [];
397
- if (node.args) {
398
- argParts.push(node.args);
399
- }
400
- if (node.attrs.length > 0) {
401
- argParts.push(this.genCallAttrs(node.attrs));
402
- }
403
- return chunk(`${node.name}(${argParts.join(', ')})`, node.loc);
459
+ this.inMixin = true;
460
+ let bodyChunk;
461
+ try {
462
+ const bodyChunks = node.body
463
+ .map(child => this.genNode(child, indent + 1))
464
+ .filter((e) => e !== null);
465
+ bodyChunk = this.genFragment(bodyChunks.length > 0 ? node.body : [], indent + 1);
466
+ }
467
+ finally {
468
+ this.inMixin = false;
469
+ }
470
+ // `attrs` is always accepted so every call site can pass extra
471
+ // attributes, but a body that never mentions it would trip
472
+ // `noUnusedParameters`; the `_` prefix silences that. We can't parse the
473
+ // TS in the body, so this is a textual check: a stray match (e.g. the
474
+ // word in a string) just keeps the plain name, which is harmless.
475
+ const attrsParam = /\battrs\b/.test(bodyChunk.code) ? 'attrs' : '_attrs';
476
+ let params = appendArg(verbatim({ text: node.params, loc: node.paramsLoc }), `${attrsParam}: Attrs = {}`);
477
+ // A bare `block` renders whatever is indented under the call site,
478
+ // passed as a callback after `attrs` (which callers then always pass).
479
+ if (mixinHasBlock(node))
480
+ params = appendArg(params, 'block?: () => unknown');
481
+ return concat(mark(node.loc), `export function ${node.name}(`, params, `) {\n${i1}return `, bodyChunk, '\n}');
482
+ }
483
+ genMixinCall(node, indent = 0) {
484
+ const hasBlock = countEffective(node.block) > 0;
485
+ const given = verbatim({ text: node.args, loc: node.argsLoc });
486
+ // Call-site content follows `attrs`, so `{}` holds its place when none are given.
487
+ const args = node.attrs.length > 0 || hasBlock
488
+ ? appendArg(given, this.genCallAttrs(node.attrs))
489
+ : given;
490
+ const prefix = this.formatted ? this.ind(indent) : '';
491
+ if (!hasBlock)
492
+ return reindentChunk(within(node.loc, `${node.name}(`, args, ')'), prefix);
493
+ const head = concat(`${node.name}(`, reindentChunk(args, prefix));
494
+ const blockChunk = this.genFragment(node.block, indent);
495
+ return within(node.loc, appendArg(head, concat('() => ', blockChunk), false), ')');
404
496
  }
405
497
  genCallAttrs(attrs) {
406
- const parts = [];
407
- for (const attr of attrs) {
408
- if (attr.spread) {
409
- parts.push(`...${attr.name}`);
410
- }
411
- else {
412
- // Every non-spread attribute carries a value (the parser rejects
413
- // valueless attributes); booleans arrive as the verbatim
414
- // expression `true` / `false`.
415
- const key = this.attrKey(attr.name);
416
- parts.push(`${key}: ${attr.value}`);
417
- }
418
- }
419
- return `{ ${parts.join(', ')} }`;
498
+ return this.attrObject(attrs.map(attr => this.genAttr(attr)));
420
499
  }
421
500
  // --- Block and extends generation ---
422
501
  genBlock(node, indent) {
502
+ if (node.name === '' && !this.inMixin) {
503
+ throw new CodegenError('A bare "block" is only allowed inside a mixin; name the block', node.loc.line, node.loc.col);
504
+ }
505
+ if (node.name !== '' && this.inMixin) {
506
+ throw new CodegenError(`Named block "${node.name}" is not allowed inside a mixin; use a bare "block" for call-site content`, node.loc.line, node.loc.col);
507
+ }
508
+ // A layout slot lives on the `blocks` parameter; a mixin's call-site
509
+ // content is its `block` parameter.
510
+ const ref = node.name === '' ? 'block' : `blocks.${node.name}`;
423
511
  if (node.children.length === 0) {
424
- return chunk(`blocks.${node.name}?.()`, node.loc);
512
+ return chunk(`${ref}?.()`, node.loc);
425
513
  }
426
514
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
427
515
  const defaultContentChunk = this.genFragment(node.children, indent + 1);
428
- const code = `blocks.${node.name}\n${bi}? blocks.${node.name}()\n${bi}: ${defaultContentChunk.code}`;
429
- const mappings = [
430
- mapping(node.loc),
431
- ...offsetMappings(defaultContentChunk.mappings, 2),
432
- ];
433
- return { code, mappings };
516
+ return concat(mark(node.loc), `${ref}\n${bi}? ${ref}()\n${bi}: `, defaultContentChunk);
434
517
  }
435
518
  genExtends(node, indent) {
436
519
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
437
520
  const pi = this.formatted ? this.ind(indent) : '';
438
- const blockChunks = [];
439
- for (const block of node.blocks) {
440
- const bodyChunk = this.genFragment(block.children, indent + 1);
441
- const blockCode = `${bi}${block.name}: () => ${bodyChunk.code}`;
442
- const blockMappings = [
443
- mapping(block.loc),
444
- ...offsetMappings(bodyChunk.mappings, 0),
445
- ];
446
- blockChunks.push({ code: blockCode, mappings: blockMappings });
447
- }
521
+ const blockChunks = node.blocks.map(block => concat(mark(block.loc), `${bi}${block.name}: () => `, this.genFragment(block.children, indent + 1)));
448
522
  const joinedBlocks = joinChunks(blockChunks, ',\n');
449
- const code = `${node.target}(${node.args}, {\n${joinedBlocks.code}\n${pi}})`;
450
- const mappings = [
451
- mapping(node.loc),
452
- ...offsetMappings(joinedBlocks.mappings, 1),
453
- ];
454
- return { code, mappings };
523
+ const args = reindentChunk(verbatim({ text: node.args, loc: node.argsLoc }), pi);
524
+ const head = concat(`${node.target}(`, appendArg(args, '{', false));
525
+ return concat(mark(node.loc), head, '\n', joinedBlocks, `\n${pi}})`);
455
526
  }
456
- genInclude(node) {
457
- const segments = node.path.split('/');
458
- const name = segments[segments.length - 1];
459
- return chunk(`import { ${name} } from '${node.path}.js'`, node.loc);
527
+ genInclude(node, names) {
528
+ return chunk(`import { ${names.join(', ')} } from '${node.path}.js'`, node.loc);
460
529
  }
461
530
  // --- Simple generators ---
462
531
  genBufferedCode(node) {
463
- return chunk(node.expression, node.loc);
532
+ return concat(mark(node.loc), verbatim({ text: node.expression, loc: node.expressionLoc }));
464
533
  }
465
534
  genDoctype(node) {
466
535
  return chunk('doctype()', node.loc);
@@ -470,12 +539,21 @@ class CodeGenerator {
470
539
  function countEffective(nodes) {
471
540
  return nodes.filter(n => n.type !== 'Comment' && n.type !== 'PassthroughComment').length;
472
541
  }
542
+ /** True when shorthand classes merge with a `class=` value that is not a string literal. */
543
+ function needsRuntimeClassMerge(node) {
544
+ const explicitClass = node.attrs.find(a => a.name === 'class' && !a.spread);
545
+ if (!explicitClass || explicitClass.value === null)
546
+ return false;
547
+ return !/^["'`]/.test(explicitClass.value);
548
+ }
473
549
  function scanUsedHelpers(node) {
474
550
  const helpers = new Set();
475
551
  function walk(n) {
476
552
  switch (n.type) {
477
553
  case 'Element':
478
554
  helpers.add('h');
555
+ if (n.classes.length > 0 && needsRuntimeClassMerge(n))
556
+ helpers.add('mergeClass');
479
557
  if (n.rawBlock)
480
558
  helpers.add('raw');
481
559
  for (const c of n.children)
@@ -512,12 +590,21 @@ function scanUsedHelpers(node) {
512
590
  }
513
591
  break;
514
592
  case 'Iteration':
593
+ helpers.add('each');
515
594
  if (countEffective(n.body) > 1) {
516
595
  helpers.add('h');
517
596
  helpers.add('Fragment');
518
597
  }
519
598
  for (const c of n.body)
520
599
  walk(c);
600
+ if (n.fallback) {
601
+ if (countEffective(n.fallback) > 1) {
602
+ helpers.add('h');
603
+ helpers.add('Fragment');
604
+ }
605
+ for (const c of n.fallback)
606
+ walk(c);
607
+ }
521
608
  break;
522
609
  case 'Template': {
523
610
  // Template body wraps in a Fragment when it has more than one
@@ -540,6 +627,10 @@ function scanUsedHelpers(node) {
540
627
  walk(c);
541
628
  break;
542
629
  case 'MixinCall':
630
+ if (countEffective(n.block) > 1) {
631
+ helpers.add('h');
632
+ helpers.add('Fragment');
633
+ }
543
634
  for (const c of n.block)
544
635
  walk(c);
545
636
  break;
@@ -561,6 +652,11 @@ function scanUsedHelpers(node) {
561
652
  walk(c);
562
653
  }
563
654
  break;
655
+ case 'Text':
656
+ case 'Pipe':
657
+ if (n.segments.some(s => s.type === 'interpolation'))
658
+ helpers.add('interpolate');
659
+ break;
564
660
  case 'Document':
565
661
  for (const c of n.children)
566
662
  walk(c);
@@ -570,6 +666,125 @@ function scanUsedHelpers(node) {
570
666
  walk(node);
571
667
  return helpers;
572
668
  }
669
+ // --- Include resolution ---
670
+ /** Names of the mixins a document defines at top level. */
671
+ export function definedMixins(doc) {
672
+ return doc.children.flatMap(c => (c.type === 'MixinDefinition' ? [c.name] : []));
673
+ }
674
+ /** Every mixin call in the document, first call of each name, in source order. */
675
+ function collectMixinCalls(doc) {
676
+ const calls = new Map();
677
+ function walkAll(nodes) {
678
+ for (const n of nodes)
679
+ walk(n);
680
+ }
681
+ function walk(n) {
682
+ switch (n.type) {
683
+ case 'MixinCall':
684
+ if (!calls.has(n.name))
685
+ calls.set(n.name, n);
686
+ walkAll(n.block);
687
+ break;
688
+ case 'Element':
689
+ walkAll(n.children);
690
+ break;
691
+ case 'Conditional':
692
+ walkAll(n.consequent);
693
+ for (const alt of n.alternates)
694
+ walkAll(alt.body);
695
+ if (n.fallback)
696
+ walkAll(n.fallback);
697
+ break;
698
+ case 'Iteration':
699
+ walkAll(n.body);
700
+ if (n.fallback)
701
+ walkAll(n.fallback);
702
+ break;
703
+ case 'Template':
704
+ case 'MixinDefinition':
705
+ walkAll(n.body);
706
+ break;
707
+ case 'Block':
708
+ walkAll(n.children);
709
+ break;
710
+ case 'Extends':
711
+ for (const block of n.blocks)
712
+ walkAll(block.children);
713
+ break;
714
+ }
715
+ }
716
+ walkAll(doc.children);
717
+ return calls;
718
+ }
719
+ /**
720
+ * Value bindings a pass-through `import` statement introduces. A loose read,
721
+ * not a TS parse: it only needs to spot a mixin imported by hand.
722
+ */
723
+ function importedBindings(statement) {
724
+ if (/^import\s+type\b/.test(statement))
725
+ return [];
726
+ const clause = /^import\s+([\s\S]*?)\s+from\b/.exec(statement)?.[1];
727
+ if (!clause)
728
+ return [];
729
+ const names = [];
730
+ const defaultName = /^([A-Za-z_$][\w$]*)/.exec(clause)?.[1];
731
+ if (defaultName)
732
+ names.push(defaultName);
733
+ const namespace = /\*\s*as\s+([A-Za-z_$][\w$]*)/.exec(clause)?.[1];
734
+ if (namespace)
735
+ names.push(namespace);
736
+ const named = /\{([^}]*)\}/.exec(clause)?.[1];
737
+ if (named) {
738
+ for (const spec of named.split(',')) {
739
+ const s = spec.trim();
740
+ if (s === '' || /^type\s/.test(s))
741
+ continue;
742
+ names.push(s.split(/\s+as\s+/).pop().trim());
743
+ }
744
+ }
745
+ return names;
746
+ }
747
+ /** Which called mixins each include must import, in first-call order. */
748
+ function resolveIncludes(doc, resolveInclude) {
749
+ const includes = doc.children.filter((c) => c.type === 'Include');
750
+ const picks = new Map(includes.map(inc => [inc, []]));
751
+ if (includes.length === 0)
752
+ return picks;
753
+ const provided = new Set(definedMixins(doc));
754
+ for (const child of doc.children) {
755
+ if (child.type === 'Import') {
756
+ for (const name of importedBindings(child.statement))
757
+ provided.add(name);
758
+ }
759
+ }
760
+ const needed = [...collectMixinCalls(doc).values()].filter(call => !provided.has(call.name));
761
+ if (!resolveInclude) {
762
+ if (includes.length > 1 && needed.length > 0) {
763
+ const inc = includes[1];
764
+ throw new CodegenError(`Cannot tell which include defines mixin "${needed[0].name}" without an include resolver`, inc.loc.line, inc.loc.col);
765
+ }
766
+ picks.set(includes[0], needed.map(call => call.name));
767
+ return picks;
768
+ }
769
+ const available = new Map();
770
+ for (const inc of includes) {
771
+ try {
772
+ available.set(inc, new Set(resolveInclude(inc.path)));
773
+ }
774
+ catch (err) {
775
+ const msg = err instanceof Error ? err.message : String(err);
776
+ throw new CodegenError(`Cannot include ${inc.path}: ${msg}`, inc.loc.line, inc.loc.col);
777
+ }
778
+ }
779
+ for (const call of needed) {
780
+ const inc = includes.find(i => available.get(i).has(call.name));
781
+ if (!inc) {
782
+ throw new CodegenError(`Mixin "${call.name}" is not defined in this file or any include`, call.loc.line, call.loc.col);
783
+ }
784
+ picks.get(inc).push(call.name);
785
+ }
786
+ return picks;
787
+ }
573
788
  // --- Public API ---
574
789
  /** Generate a TypeScript expression from a single AST node. */
575
790
  export function generateExpr(node) {
@@ -580,14 +795,14 @@ export function generateExpr(node) {
580
795
  return result.code;
581
796
  }
582
797
  /** Generate TypeScript source from a DocumentNode. */
583
- export function generate(doc) {
798
+ export function generate(doc, options = {}) {
584
799
  const gen = new CodeGenerator(true);
585
800
  const parts = [];
586
801
  // Scan for used runtime helpers
587
802
  const helpers = scanUsedHelpers(doc);
588
803
  // Emit runtime import (canonical order)
589
804
  if (helpers.size > 0) {
590
- const order = ['h', 'doctype', 'Fragment', 'raw'];
805
+ const order = ['h', 'doctype', 'Fragment', 'raw', 'mergeClass', 'each', 'interpolate'];
591
806
  const used = order.filter(h => helpers.has(h));
592
807
  parts.push(chunk(`import { ${used.join(', ')} } from "@chilts/weave"`));
593
808
  }
@@ -596,33 +811,16 @@ export function generate(doc) {
596
811
  if (hasMixinDefs) {
597
812
  parts.push(chunk('import type { Attrs } from "@chilts/weave"'));
598
813
  }
599
- // Pre-scan: find layout templates that need blocks type augmentation
600
- let blocksTypeName = null;
814
+ // Emit user imports
601
815
  for (const child of doc.children) {
602
- if (child.type === 'Template' && templateHasBlocks(child)) {
603
- blocksTypeName = capitalize(child.name) + 'Blocks';
604
- }
605
- }
606
- // Emit user imports (augmented with blocks type if needed)
607
- for (const child of doc.children) {
608
- if (child.type === 'Import') {
609
- let statement = child.statement;
610
- if (blocksTypeName && statement.includes('import type')) {
611
- const idx = statement.indexOf(' }');
612
- if (idx !== -1) {
613
- statement = statement.slice(0, idx) + `, ${blocksTypeName}` + statement.slice(idx);
614
- }
615
- }
616
- parts.push(chunk(statement, child.loc));
617
- }
816
+ if (child.type === 'Import')
817
+ parts.push(gen.genImport(child));
618
818
  }
619
- // Emit include imports
620
- for (const child of doc.children) {
621
- if (child.type === 'Include') {
622
- const result = gen.genNode(child, 0);
623
- if (result !== null)
624
- parts.push(result);
625
- }
819
+ // Emit include imports: only the mixins this file calls, so an include
820
+ // that provides nothing used emits nothing.
821
+ for (const [include, names] of resolveIncludes(doc, options.resolveInclude)) {
822
+ if (names.length > 0)
823
+ parts.push(gen.genInclude(include, names));
626
824
  }
627
825
  // Emit extends imports (auto-import layout function)
628
826
  for (const child of doc.children) {
@@ -647,16 +845,16 @@ export function generate(doc) {
647
845
  return { code: final.code + '\n', mappings: final.mappings };
648
846
  }
649
847
  /** Full pipeline: source string → TypeScript source. */
650
- export function compile(source) {
848
+ export function compile(source, options = {}) {
651
849
  const tokens = lex(source);
652
850
  const doc = parse(tokens);
653
- return generate(doc).code;
851
+ return generate(doc, options).code;
654
852
  }
655
853
  /** Full pipeline with source map generation. */
656
854
  export function compileWithSourceMap(source, opts) {
657
855
  const tokens = lex(source);
658
856
  const doc = parse(tokens);
659
- const result = generate(doc);
857
+ const result = generate(doc, opts);
660
858
  const sourceMap = buildSourceMap(result.mappings, opts.file, opts.sourceFile);
661
859
  return { code: result.code, sourceMap };
662
860
  }