@chilts/weave 0.7.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;
@@ -20,16 +20,22 @@ function capitalize(s) {
20
20
  // on its own line too, so the output keeps the shape the source was written in.
21
21
  // `ownLine` is false for an extra that opens a multi-line literal of its own.
22
22
  function appendArg(args, extra, ownLine = true) {
23
- if (args === '')
24
- return extra;
25
- const body = args.trimEnd();
26
- const tail = args.slice(body.length);
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
+ };
27
34
  if (ownLine && tail.includes('\n')) {
28
- const lastLine = body.slice(body.lastIndexOf('\n') + 1);
29
35
  const indent = /^ */.exec(lastLine)[0];
30
- return `${body}${body.endsWith(',') ? '' : ','}\n${indent}${extra},${tail}`;
36
+ return concat(head, body.endsWith(',') ? '' : ',', `\n${indent}`, extra, `,${tail}`);
31
37
  }
32
- return `${body}${body.endsWith(',') ? ' ' : ', '}${extra}`;
38
+ return concat(head, body.endsWith(',') ? ' ' : ', ', extra);
33
39
  }
34
40
  // Indents the continuation lines of a multi-line captured expression to sit
35
41
  // under the output line it starts on. Captured text never holds a string that
@@ -37,43 +43,90 @@ function appendArg(args, extra, ownLine = true) {
37
43
  function reindent(text, prefix) {
38
44
  return prefix === '' ? text : text.replace(/\n(?!\n|$)/g, `\n${prefix}`);
39
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
+ }
40
63
  function camelToKebab(name) {
41
64
  return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
42
65
  }
43
- 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 = [];
44
70
  function walk(n) {
45
- if (n.type === 'Block')
46
- return true;
71
+ if (n.type === 'Block') {
72
+ found.push(n);
73
+ n.children.forEach(walk);
74
+ }
47
75
  if (n.type === 'Element')
48
- return n.children.some(walk);
76
+ n.children.forEach(walk);
77
+ if (n.type === 'MixinCall')
78
+ n.block.forEach(walk);
49
79
  if (n.type === 'Conditional') {
50
- if (n.consequent.some(walk))
51
- return true;
52
- for (const alt of n.alternates) {
53
- if (alt.body.some(walk))
54
- return true;
55
- }
56
- if (n.fallback?.some(walk))
57
- 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);
58
88
  }
59
- if (n.type === 'Iteration')
60
- return n.body.some(walk);
61
89
  if (n.type === 'Extends') {
62
90
  // The entries in `blocks` are fills (the slots this template fills in
63
91
  // its parent), not slots of its own. A mid-layer template defines its
64
92
  // own slots by nesting `block` nodes inside that fill content — those
65
93
  // are what require a `blocks` parameter on this function.
66
- return n.blocks.some(fill => fill.children.some(walk));
94
+ for (const fill of n.blocks)
95
+ fill.children.forEach(walk);
67
96
  }
68
- return false;
69
97
  }
70
- 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 };
71
122
  }
72
123
  function mapping(loc, genLine = 0) {
73
124
  return { genLine, genCol: 0, srcLine: loc.line - 1, srcCol: loc.col - 1 };
74
125
  }
75
126
  class CodeGenerator {
76
127
  formatted;
128
+ // Set while generating a mixin body, where `block` means call-site content.
129
+ inMixin = false;
77
130
  constructor(formatted = false) {
78
131
  this.formatted = formatted;
79
132
  }
@@ -86,23 +139,19 @@ class CodeGenerator {
86
139
  * Non-comment expressions get trailing commas only if more non-comment expressions follow.
87
140
  */
88
141
  joinWithCommentChunks(chunks, indent) {
89
- const allMappings = [];
90
- const lines = [];
91
- let currentLine = 0;
142
+ const parts = [];
92
143
  for (let i = 0; i < chunks.length; i++) {
93
144
  const c = chunks[i];
94
- const isComment = c.code.startsWith('//');
95
- if (isComment) {
96
- lines.push(`${indent}${c.code}`);
97
- }
98
- else {
145
+ if (i > 0)
146
+ parts.push('\n');
147
+ parts.push(indent, c);
148
+ if (!c.code.startsWith('//')) {
99
149
  const hasMore = chunks.slice(i + 1).some(e => !e.code.startsWith('//'));
100
- lines.push(hasMore ? `${indent}${c.code},` : `${indent}${c.code}`);
150
+ if (hasMore)
151
+ parts.push(',');
101
152
  }
102
- allMappings.push(...offsetMappings(c.mappings, currentLine));
103
- currentLine += lineCount(c.code) + 1;
104
153
  }
105
- return { code: lines.join('\n'), mappings: allMappings };
154
+ return concat(...parts);
106
155
  }
107
156
  genNode(node, indent = 0) {
108
157
  switch (node.type) {
@@ -149,55 +198,53 @@ class CodeGenerator {
149
198
  genConditional(node, indent) {
150
199
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
151
200
  const consequentChunk = this.genFragment(node.consequent, indent + 1);
152
- let code = `${node.condition}\n${bi}? ${consequentChunk.code}`;
153
- const mappings = [
154
- mapping(node.loc),
155
- ...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,
156
205
  ];
157
206
  for (const alt of node.alternates) {
158
207
  const altBodyChunk = this.genFragment(alt.body, indent + 1);
159
- const prevLines = lineCount(code);
160
- code += `\n${bi}: ${alt.condition}\n${bi}? ${altBodyChunk.code}`;
161
- mappings.push(...offsetMappings(altBodyChunk.mappings, prevLines + 2));
208
+ parts.push(`\n${bi}: `, verbatim({ text: alt.condition, loc: alt.conditionLoc }), `\n${bi}? `, altBodyChunk);
162
209
  }
163
210
  if (node.fallback !== null) {
164
- const fallbackChunk = this.genFragment(node.fallback, indent + 1);
165
- const prevLines = lineCount(code);
166
- code += `\n${bi}: ${fallbackChunk.code}`;
167
- mappings.push(...offsetMappings(fallbackChunk.mappings, prevLines + 1));
211
+ parts.push(`\n${bi}: `, this.genFragment(node.fallback, indent + 1));
168
212
  }
169
213
  else {
170
- code += `\n${bi}: null`;
214
+ parts.push(`\n${bi}: null`);
171
215
  }
172
- return { code, mappings };
216
+ return concat(mark(node.loc), ...parts);
173
217
  }
174
218
  // --- Iteration generation ---
175
219
  genIteration(node, indent) {
176
- const { variable, iterable } = this.parseForParams(node.params, node.loc);
220
+ const { variable, iterable } = this.parseForParams({ text: node.params, loc: node.paramsLoc }, node.loc);
177
221
  const bodyChunk = this.genFragment(node.body, indent + 1);
178
222
  // Wrap destructured variables in parens for arrow function
179
- const param = variable.startsWith('{') || variable.startsWith('[')
180
- ? `(${variable})`
181
- : variable;
223
+ const param = variable.text.startsWith('{') || variable.text.startsWith('[')
224
+ ? concat('(', verbatim(variable), ')')
225
+ : verbatim(variable);
182
226
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
183
227
  const pi = this.formatted ? this.ind(indent) : '';
184
- const head = reindent(`${iterable}.map(${param} =>`, this.formatted ? this.ind(indent) : '');
185
- const code = `${head}\n${bi}${bodyChunk.code}\n${pi})`;
186
- const mappings = [
187
- mapping(node.loc),
188
- ...offsetMappings(bodyChunk.mappings, 1 + lineCount(head)),
189
- ];
190
- 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);
191
237
  }
192
238
  parseForParams(params, loc) {
193
239
  // A multi-line header may put `of` at the start or end of a line.
194
- const of = /\s+of\s+/.exec(params);
240
+ const of = /\s+of\s+/.exec(params.text);
195
241
  if (of === null) {
196
- throw new CodegenError(`Invalid for params: only "for...of" is supported, got "${params}"`, loc.line, loc.col);
242
+ throw new CodegenError(`Invalid for params: only "for...of" is supported, got "${params.text}"`, loc.line, loc.col);
197
243
  }
198
- const binding = params.slice(0, of.index).trim();
199
- const iterable = params.slice(of.index + of[0].length).trim();
200
- const variable = binding.replace(/^(const|let)\s+/, '');
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;
201
248
  return { variable, iterable };
202
249
  }
203
250
  // --- Fragment helper ---
@@ -220,14 +267,12 @@ class CodeGenerator {
220
267
  .filter((e) => e !== null);
221
268
  if (!this.formatted) {
222
269
  const inner = this.joinWithCommentChunks(chunks, ' ');
223
- const code = `h(Fragment, {},\n${inner.code}\n)`;
224
- return { code, mappings: offsetMappings(inner.mappings, 1) };
270
+ return concat('h(Fragment, {},\n', inner, '\n)');
225
271
  }
226
272
  const ci = this.ind(indent + 1);
227
273
  const pi = this.ind(indent);
228
274
  const inner = this.joinWithCommentChunks(chunks, ci);
229
- const code = `h(Fragment, {},\n${inner.code}\n${pi})`;
230
- return { code, mappings: offsetMappings(inner.mappings, 1) };
275
+ return concat('h(Fragment, {},\n', inner, `\n${pi})`);
231
276
  }
232
277
  // --- Element generation ---
233
278
  genElement(node, indent) {
@@ -236,31 +281,23 @@ class CodeGenerator {
236
281
  .map(c => this.genNode(c, indent + 1))
237
282
  .filter((e) => e !== null);
238
283
  if (children.length === 0) {
239
- return chunk(`h("${node.tag}", ${attrs})`, node.loc);
284
+ return within(node.loc, `h("${node.tag}", `, attrs, ')');
240
285
  }
241
286
  const hasComments = children.some(c => c.code.startsWith('//'));
242
- if (!this.formatted && !hasComments) {
243
- const childrenStr = children.map(c => c.code).join(', ');
244
- return chunk(`h("${node.tag}", ${attrs}, ${childrenStr})`, node.loc);
245
- }
287
+ const inline = () => within(node.loc, `h("${node.tag}", `, attrs, ...children.flatMap(c => [', ', c]), ')');
288
+ if (!this.formatted && !hasComments)
289
+ return inline();
246
290
  // Keep inline when all children are leaf nodes (text, expressions) and no comments
247
291
  const leafTypes = new Set(['Text', 'BufferedCode', 'Pipe']);
248
292
  const allLeaf = !hasComments && node.children
249
293
  .filter(c => c.type !== 'Comment' && c.type !== 'PassthroughComment')
250
294
  .every(c => leafTypes.has(c.type));
251
- if (allLeaf) {
252
- const childrenStr = children.map(c => c.code).join(', ');
253
- return chunk(`h("${node.tag}", ${attrs}, ${childrenStr})`, node.loc);
254
- }
295
+ if (allLeaf)
296
+ return inline();
255
297
  const ci = this.ind(indent + 1);
256
298
  const pi = this.ind(indent);
257
299
  const inner = this.joinWithCommentChunks(children, ci);
258
- const code = `h("${node.tag}", ${attrs},\n${inner.code}\n${pi})`;
259
- const mappings = [
260
- mapping(node.loc),
261
- ...offsetMappings(inner.mappings, 1),
262
- ];
263
- return { code, mappings };
300
+ return within(node.loc, `h("${node.tag}", `, attrs, ',\n', inner, `\n${pi})`);
264
301
  }
265
302
  genAttrs(node) {
266
303
  const parts = [];
@@ -269,45 +306,59 @@ class CodeGenerator {
269
306
  // Classes: merge shorthand + explicit into one
270
307
  if (node.classes.length > 0 && explicitClass && explicitClass.value !== null) {
271
308
  const prefix = node.classes.join(' ');
272
- parts.push(`class: ${this.mergeClassValue(prefix, explicitClass.value)}`);
309
+ parts.push(concat(this.attrName(explicitClass), ': ', this.mergeClassValue(prefix, explicitClass)));
273
310
  }
274
311
  else if (node.classes.length > 0) {
275
- parts.push(`class: "${node.classes.join(' ')}"`);
312
+ parts.push(chunk(`class: "${node.classes.join(' ')}"`));
276
313
  }
277
314
  // ID from shorthand
278
315
  if (node.id !== null) {
279
- parts.push(`id: "${node.id}"`);
316
+ parts.push(chunk(`id: "${node.id}"`));
280
317
  }
281
318
  // Explicit attributes (skip class if already merged)
282
319
  for (const attr of node.attrs) {
283
320
  if (attr === explicitClass && node.classes.length > 0)
284
321
  continue;
285
- if (attr.spread) {
286
- parts.push(`...${attr.name}`);
287
- }
288
- else {
289
- // Every non-spread attribute carries a value (the parser rejects
290
- // valueless attributes); booleans arrive as the verbatim
291
- // expression `true` / `false`.
292
- const key = this.attrKey(attr.name);
293
- parts.push(`${key}: ${attr.value}`);
294
- }
322
+ parts.push(this.genAttr(attr));
295
323
  }
296
- if (parts.length === 0)
297
- return '{}';
298
- return `{ ${parts.join(', ')} }`;
324
+ return this.attrObject(parts);
299
325
  }
300
- mergeClassValue(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 };
301
352
  // String and template literals are always strings, so merge them now.
302
353
  // Anything else may be undefined/null/false at render time, which must
303
354
  // add nothing rather than stringify into the class list.
304
355
  if (value === '""' || value === "''" || value === '``') {
305
- return `"${prefix}"`;
356
+ return chunk(`"${prefix}"`);
306
357
  }
307
358
  if (value.startsWith('`') || value.startsWith('"') || value.startsWith("'")) {
308
- return `${value[0]}${prefix} ${value.slice(1)}`;
359
+ return concat(verbatim(sliceCaptured(captured, 0, 1)), `${prefix} `, verbatim(sliceCaptured(captured, 1)));
309
360
  }
310
- return `mergeClass("${prefix}", ${value})`;
361
+ return concat(`mergeClass("${prefix}", `, verbatim(captured), ')');
311
362
  }
312
363
  attrKey(name) {
313
364
  if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name))
@@ -316,29 +367,31 @@ class CodeGenerator {
316
367
  }
317
368
  // --- Text generation ---
318
369
  genText(node) {
319
- return chunk(this.genSegments(node.segments), node.loc);
370
+ return concat(mark(node.loc), this.genSegments(node.segments));
320
371
  }
321
372
  genPipe(node) {
322
373
  if (node.segments.length === 0) {
323
374
  return chunk('" "', node.loc);
324
375
  }
325
- return chunk(this.genSegments(node.segments), node.loc);
376
+ return concat(mark(node.loc), this.genSegments(node.segments));
326
377
  }
327
378
  genSegments(segments) {
328
379
  if (segments.length === 1 && segments[0].type === 'text') {
329
- return `"${this.escapeString(segments[0].value)}"`;
380
+ return chunk(`"${this.escapeString(segments[0].value)}"`);
330
381
  }
331
- 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`'];
332
385
  for (const seg of segments) {
333
386
  if (seg.type === 'text') {
334
- result += this.escapeTemplateLiteral(seg.value);
387
+ parts.push(this.escapeTemplateLiteral(seg.value));
335
388
  }
336
389
  else {
337
- result += `\${${seg.expression}}`;
390
+ parts.push('${', verbatim({ text: seg.expression, loc: seg.loc ? [seg.loc] : undefined }), '}');
338
391
  }
339
392
  }
340
- result += '`';
341
- return result;
393
+ parts.push('`');
394
+ return concat(...parts);
342
395
  }
343
396
  escapeString(s) {
344
397
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
@@ -350,34 +403,38 @@ class CodeGenerator {
350
403
  genImport(node) {
351
404
  return chunk(node.statement, node.loc);
352
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.
353
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) {
354
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');
355
427
  // Check if body contains an ExtendsNode (page template)
356
428
  const extendsNode = node.body.find((c) => c.type === 'Extends');
357
429
  if (extendsNode) {
358
430
  const extendsChunk = this.genExtends(extendsNode, indent + 1);
359
- // A mid-layer template both fills its parent's slots and exposes its
360
- // own (nested `block` nodes), so it needs a `blocks` parameter too.
361
- const params = templateHasBlocks(node)
362
- ? appendArg(node.params, `blocks: ${capitalize(node.name)}Blocks = {}`)
363
- : node.params;
364
- const code = `export function ${node.name}(${params}) {\n${i1}return ${extendsChunk.code}\n}`;
365
- const mappings = [
366
- mapping(node.loc),
367
- ...offsetMappings(extendsChunk.mappings, 1 + lineCount(params)),
368
- ];
369
- return { code, mappings };
370
- }
371
- // Check if body tree contains BlockNodes (layout template)
372
- const hasBlocks = templateHasBlocks(node);
373
- const params = hasBlocks
374
- ? appendArg(node.params, `blocks: ${capitalize(node.name)}Blocks = {}`)
375
- : node.params;
431
+ return concat(signature, `${i1}return `, extendsChunk, '\n}');
432
+ }
376
433
  const bodyChunks = node.body
377
- .map(child => this.genNode(child, indent + 2))
434
+ .map(child => this.genNode(child, indent + 1))
378
435
  .filter((e) => e !== null);
379
436
  if (bodyChunks.length === 0) {
380
- return chunk(`export function ${node.name}(${params}) {\n${i1}return ''\n}`, node.loc);
437
+ return concat(signature, `${i1}return ''\n}`);
381
438
  }
382
439
  // Separate comments from code chunks
383
440
  const commentChunks = bodyChunks.filter(c => c.code.startsWith('//'));
@@ -392,99 +449,87 @@ class CodeGenerator {
392
449
  else {
393
450
  bodyChunk = this.genFragment(node.body, indent + 1);
394
451
  }
395
- // Prepend comments before the return statement
396
- const commentPrefix = commentChunks.length > 0
397
- ? commentChunks.map(c => `${i1}${c.code}`).join('\n') + '\n'
398
- : '';
399
- const commentLines = commentChunks.length;
400
- const code = `export function ${node.name}(${params}) {\n${commentPrefix}${i1}return ${bodyChunk.code}\n}`;
401
- const mappings = [
402
- mapping(node.loc),
403
- ...offsetMappings(bodyChunk.mappings, 1 + lineCount(params) + commentLines), // body starts after fn sig + comments
404
- ];
405
- 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}');
406
455
  }
407
456
  // --- Mixin generation ---
408
457
  genMixinDefinition(node, indent) {
409
458
  const i1 = this.ind(indent + 1);
410
- const params = appendArg(node.params, 'attrs: Attrs = {}');
411
- const bodyChunks = node.body
412
- .map(child => this.genNode(child, indent + 1))
413
- .filter((e) => e !== null);
414
- const bodyChunk = this.genFragment(bodyChunks.length > 0 ? node.body : [], indent + 1);
415
- const code = `export function ${node.name}(${params}) {\n${i1}return ${bodyChunk.code}\n}`;
416
- const mappings = [
417
- mapping(node.loc),
418
- ...offsetMappings(bodyChunk.mappings, 1 + lineCount(params)),
419
- ];
420
- return { code, mappings };
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}');
421
482
  }
422
483
  genMixinCall(node, indent = 0) {
423
- const args = node.attrs.length > 0
424
- ? appendArg(node.args, this.genCallAttrs(node.attrs))
425
- : node.args;
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;
426
490
  const prefix = this.formatted ? this.ind(indent) : '';
427
- return chunk(reindent(`${node.name}(${args})`, prefix), node.loc);
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), ')');
428
496
  }
429
497
  genCallAttrs(attrs) {
430
- const parts = [];
431
- for (const attr of attrs) {
432
- if (attr.spread) {
433
- parts.push(`...${attr.name}`);
434
- }
435
- else {
436
- // Every non-spread attribute carries a value (the parser rejects
437
- // valueless attributes); booleans arrive as the verbatim
438
- // expression `true` / `false`.
439
- const key = this.attrKey(attr.name);
440
- parts.push(`${key}: ${attr.value}`);
441
- }
442
- }
443
- return `{ ${parts.join(', ')} }`;
498
+ return this.attrObject(attrs.map(attr => this.genAttr(attr)));
444
499
  }
445
500
  // --- Block and extends generation ---
446
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}`;
447
511
  if (node.children.length === 0) {
448
- return chunk(`blocks.${node.name}?.()`, node.loc);
512
+ return chunk(`${ref}?.()`, node.loc);
449
513
  }
450
514
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
451
515
  const defaultContentChunk = this.genFragment(node.children, indent + 1);
452
- const code = `blocks.${node.name}\n${bi}? blocks.${node.name}()\n${bi}: ${defaultContentChunk.code}`;
453
- const mappings = [
454
- mapping(node.loc),
455
- ...offsetMappings(defaultContentChunk.mappings, 2),
456
- ];
457
- return { code, mappings };
516
+ return concat(mark(node.loc), `${ref}\n${bi}? ${ref}()\n${bi}: `, defaultContentChunk);
458
517
  }
459
518
  genExtends(node, indent) {
460
519
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
461
520
  const pi = this.formatted ? this.ind(indent) : '';
462
- const blockChunks = [];
463
- for (const block of node.blocks) {
464
- const bodyChunk = this.genFragment(block.children, indent + 1);
465
- const blockCode = `${bi}${block.name}: () => ${bodyChunk.code}`;
466
- const blockMappings = [
467
- mapping(block.loc),
468
- ...offsetMappings(bodyChunk.mappings, 0),
469
- ];
470
- blockChunks.push({ code: blockCode, mappings: blockMappings });
471
- }
521
+ const blockChunks = node.blocks.map(block => concat(mark(block.loc), `${bi}${block.name}: () => `, this.genFragment(block.children, indent + 1)));
472
522
  const joinedBlocks = joinChunks(blockChunks, ',\n');
473
- const args = reindent(node.args, pi);
474
- const head = `${node.target}(${appendArg(args, '{', false)}`;
475
- const code = `${head}\n${joinedBlocks.code}\n${pi}})`;
476
- const mappings = [
477
- mapping(node.loc),
478
- ...offsetMappings(joinedBlocks.mappings, 1 + lineCount(head)),
479
- ];
480
- 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}})`);
481
526
  }
482
527
  genInclude(node, names) {
483
528
  return chunk(`import { ${names.join(', ')} } from '${node.path}.js'`, node.loc);
484
529
  }
485
530
  // --- Simple generators ---
486
531
  genBufferedCode(node) {
487
- return chunk(node.expression, node.loc);
532
+ return concat(mark(node.loc), verbatim({ text: node.expression, loc: node.expressionLoc }));
488
533
  }
489
534
  genDoctype(node) {
490
535
  return chunk('doctype()', node.loc);
@@ -545,12 +590,21 @@ function scanUsedHelpers(node) {
545
590
  }
546
591
  break;
547
592
  case 'Iteration':
593
+ helpers.add('each');
548
594
  if (countEffective(n.body) > 1) {
549
595
  helpers.add('h');
550
596
  helpers.add('Fragment');
551
597
  }
552
598
  for (const c of n.body)
553
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
+ }
554
608
  break;
555
609
  case 'Template': {
556
610
  // Template body wraps in a Fragment when it has more than one
@@ -573,6 +627,10 @@ function scanUsedHelpers(node) {
573
627
  walk(c);
574
628
  break;
575
629
  case 'MixinCall':
630
+ if (countEffective(n.block) > 1) {
631
+ helpers.add('h');
632
+ helpers.add('Fragment');
633
+ }
576
634
  for (const c of n.block)
577
635
  walk(c);
578
636
  break;
@@ -594,6 +652,11 @@ function scanUsedHelpers(node) {
594
652
  walk(c);
595
653
  }
596
654
  break;
655
+ case 'Text':
656
+ case 'Pipe':
657
+ if (n.segments.some(s => s.type === 'interpolation'))
658
+ helpers.add('interpolate');
659
+ break;
597
660
  case 'Document':
598
661
  for (const c of n.children)
599
662
  walk(c);
@@ -633,6 +696,10 @@ function collectMixinCalls(doc) {
633
696
  walkAll(n.fallback);
634
697
  break;
635
698
  case 'Iteration':
699
+ walkAll(n.body);
700
+ if (n.fallback)
701
+ walkAll(n.fallback);
702
+ break;
636
703
  case 'Template':
637
704
  case 'MixinDefinition':
638
705
  walkAll(n.body);
@@ -735,7 +802,7 @@ export function generate(doc, options = {}) {
735
802
  const helpers = scanUsedHelpers(doc);
736
803
  // Emit runtime import (canonical order)
737
804
  if (helpers.size > 0) {
738
- const order = ['h', 'doctype', 'Fragment', 'raw', 'mergeClass'];
805
+ const order = ['h', 'doctype', 'Fragment', 'raw', 'mergeClass', 'each', 'interpolate'];
739
806
  const used = order.filter(h => helpers.has(h));
740
807
  parts.push(chunk(`import { ${used.join(', ')} } from "@chilts/weave"`));
741
808
  }
@@ -744,25 +811,10 @@ export function generate(doc, options = {}) {
744
811
  if (hasMixinDefs) {
745
812
  parts.push(chunk('import type { Attrs } from "@chilts/weave"'));
746
813
  }
747
- // Pre-scan: find layout templates that need blocks type augmentation
748
- let blocksTypeName = null;
749
- for (const child of doc.children) {
750
- if (child.type === 'Template' && templateHasBlocks(child)) {
751
- blocksTypeName = capitalize(child.name) + 'Blocks';
752
- }
753
- }
754
- // Emit user imports (augmented with blocks type if needed)
814
+ // Emit user imports
755
815
  for (const child of doc.children) {
756
- if (child.type === 'Import') {
757
- let statement = child.statement;
758
- if (blocksTypeName && statement.includes('import type')) {
759
- const idx = statement.indexOf(' }');
760
- if (idx !== -1) {
761
- statement = statement.slice(0, idx) + `, ${blocksTypeName}` + statement.slice(idx);
762
- }
763
- }
764
- parts.push(chunk(statement, child.loc));
765
- }
816
+ if (child.type === 'Import')
817
+ parts.push(gen.genImport(child));
766
818
  }
767
819
  // Emit include imports: only the mixins this file calls, so an include
768
820
  // that provides nothing used emits nothing.