@chilts/weave 0.5.0 → 0.7.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.
@@ -14,6 +14,29 @@ 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 === '')
24
+ return extra;
25
+ const body = args.trimEnd();
26
+ const tail = args.slice(body.length);
27
+ if (ownLine && tail.includes('\n')) {
28
+ const lastLine = body.slice(body.lastIndexOf('\n') + 1);
29
+ const indent = /^ */.exec(lastLine)[0];
30
+ return `${body}${body.endsWith(',') ? '' : ','}\n${indent}${extra},${tail}`;
31
+ }
32
+ return `${body}${body.endsWith(',') ? ' ' : ', '}${extra}`;
33
+ }
34
+ // Indents the continuation lines of a multi-line captured expression to sit
35
+ // under the output line it starts on. Captured text never holds a string that
36
+ // spans lines, so every newline is safe to indent after.
37
+ function reindent(text, prefix) {
38
+ return prefix === '' ? text : text.replace(/\n(?!\n|$)/g, `\n${prefix}`);
39
+ }
17
40
  function camelToKebab(name) {
18
41
  return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
19
42
  }
@@ -110,9 +133,10 @@ class CodeGenerator {
110
133
  case 'MixinDefinition':
111
134
  return this.genMixinDefinition(node, indent);
112
135
  case 'MixinCall':
113
- return this.genMixinCall(node);
136
+ return this.genMixinCall(node, indent);
114
137
  case 'Include':
115
- return this.genInclude(node);
138
+ // Hoisted: generate() emits the import once it knows the names.
139
+ return null;
116
140
  case 'Block':
117
141
  return this.genBlock(node, indent);
118
142
  case 'Extends':
@@ -157,20 +181,22 @@ class CodeGenerator {
157
181
  : variable;
158
182
  const bi = this.formatted ? this.ind(indent + 1) : ' ';
159
183
  const pi = this.formatted ? this.ind(indent) : '';
160
- const code = `${iterable}.map(${param} =>\n${bi}${bodyChunk.code}\n${pi})`;
184
+ const head = reindent(`${iterable}.map(${param} =>`, this.formatted ? this.ind(indent) : '');
185
+ const code = `${head}\n${bi}${bodyChunk.code}\n${pi})`;
161
186
  const mappings = [
162
187
  mapping(node.loc),
163
- ...offsetMappings(bodyChunk.mappings, 1),
188
+ ...offsetMappings(bodyChunk.mappings, 1 + lineCount(head)),
164
189
  ];
165
190
  return { code, mappings };
166
191
  }
167
192
  parseForParams(params, loc) {
168
- const ofIndex = params.indexOf(' of ');
169
- if (ofIndex === -1) {
193
+ // A multi-line header may put `of` at the start or end of a line.
194
+ const of = /\s+of\s+/.exec(params);
195
+ if (of === null) {
170
196
  throw new CodegenError(`Invalid for params: only "for...of" is supported, got "${params}"`, loc.line, loc.col);
171
197
  }
172
- const binding = params.slice(0, ofIndex).trim();
173
- const iterable = params.slice(ofIndex + 4).trim();
198
+ const binding = params.slice(0, of.index).trim();
199
+ const iterable = params.slice(of.index + of[0].length).trim();
174
200
  const variable = binding.replace(/^(const|let)\s+/, '');
175
201
  return { variable, iterable };
176
202
  }
@@ -259,11 +285,10 @@ class CodeGenerator {
259
285
  if (attr.spread) {
260
286
  parts.push(`...${attr.name}`);
261
287
  }
262
- else if (attr.value === null) {
263
- const key = this.attrKey(attr.name);
264
- parts.push(`${key}: true`);
265
- }
266
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`.
267
292
  const key = this.attrKey(attr.name);
268
293
  parts.push(`${key}: ${attr.value}`);
269
294
  }
@@ -273,13 +298,16 @@ class CodeGenerator {
273
298
  return `{ ${parts.join(', ')} }`;
274
299
  }
275
300
  mergeClassValue(prefix, value) {
276
- if (value.startsWith('`')) {
277
- return `\`${prefix} ${value.slice(1)}`;
301
+ // String and template literals are always strings, so merge them now.
302
+ // Anything else may be undefined/null/false at render time, which must
303
+ // add nothing rather than stringify into the class list.
304
+ if (value === '""' || value === "''" || value === '``') {
305
+ return `"${prefix}"`;
278
306
  }
279
- if (value.startsWith('"')) {
280
- return `"${prefix} ${value.slice(1)}`;
307
+ if (value.startsWith('`') || value.startsWith('"') || value.startsWith("'")) {
308
+ return `${value[0]}${prefix} ${value.slice(1)}`;
281
309
  }
282
- return `\`${prefix} \${${value}}\``;
310
+ return `mergeClass("${prefix}", ${value})`;
283
311
  }
284
312
  attrKey(name) {
285
313
  if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name))
@@ -331,19 +359,19 @@ class CodeGenerator {
331
359
  // A mid-layer template both fills its parent's slots and exposes its
332
360
  // own (nested `block` nodes), so it needs a `blocks` parameter too.
333
361
  const params = templateHasBlocks(node)
334
- ? `${node.params}, blocks: ${capitalize(node.name)}Blocks = {}`
362
+ ? appendArg(node.params, `blocks: ${capitalize(node.name)}Blocks = {}`)
335
363
  : node.params;
336
364
  const code = `export function ${node.name}(${params}) {\n${i1}return ${extendsChunk.code}\n}`;
337
365
  const mappings = [
338
366
  mapping(node.loc),
339
- ...offsetMappings(extendsChunk.mappings, 1),
367
+ ...offsetMappings(extendsChunk.mappings, 1 + lineCount(params)),
340
368
  ];
341
369
  return { code, mappings };
342
370
  }
343
371
  // Check if body tree contains BlockNodes (layout template)
344
372
  const hasBlocks = templateHasBlocks(node);
345
373
  const params = hasBlocks
346
- ? `${node.params}, blocks: ${capitalize(node.name)}Blocks = {}`
374
+ ? appendArg(node.params, `blocks: ${capitalize(node.name)}Blocks = {}`)
347
375
  : node.params;
348
376
  const bodyChunks = node.body
349
377
  .map(child => this.genNode(child, indent + 2))
@@ -372,16 +400,14 @@ class CodeGenerator {
372
400
  const code = `export function ${node.name}(${params}) {\n${commentPrefix}${i1}return ${bodyChunk.code}\n}`;
373
401
  const mappings = [
374
402
  mapping(node.loc),
375
- ...offsetMappings(bodyChunk.mappings, 1 + commentLines), // body starts after fn sig + comments
403
+ ...offsetMappings(bodyChunk.mappings, 1 + lineCount(params) + commentLines), // body starts after fn sig + comments
376
404
  ];
377
405
  return { code, mappings };
378
406
  }
379
407
  // --- Mixin generation ---
380
408
  genMixinDefinition(node, indent) {
381
409
  const i1 = this.ind(indent + 1);
382
- const params = node.params
383
- ? `${node.params}, attrs: Attrs = {}`
384
- : 'attrs: Attrs = {}';
410
+ const params = appendArg(node.params, 'attrs: Attrs = {}');
385
411
  const bodyChunks = node.body
386
412
  .map(child => this.genNode(child, indent + 1))
387
413
  .filter((e) => e !== null);
@@ -389,19 +415,16 @@ class CodeGenerator {
389
415
  const code = `export function ${node.name}(${params}) {\n${i1}return ${bodyChunk.code}\n}`;
390
416
  const mappings = [
391
417
  mapping(node.loc),
392
- ...offsetMappings(bodyChunk.mappings, 1),
418
+ ...offsetMappings(bodyChunk.mappings, 1 + lineCount(params)),
393
419
  ];
394
420
  return { code, mappings };
395
421
  }
396
- genMixinCall(node) {
397
- const argParts = [];
398
- if (node.args) {
399
- argParts.push(node.args);
400
- }
401
- if (node.attrs.length > 0) {
402
- argParts.push(this.genCallAttrs(node.attrs));
403
- }
404
- return chunk(`${node.name}(${argParts.join(', ')})`, node.loc);
422
+ genMixinCall(node, indent = 0) {
423
+ const args = node.attrs.length > 0
424
+ ? appendArg(node.args, this.genCallAttrs(node.attrs))
425
+ : node.args;
426
+ const prefix = this.formatted ? this.ind(indent) : '';
427
+ return chunk(reindent(`${node.name}(${args})`, prefix), node.loc);
405
428
  }
406
429
  genCallAttrs(attrs) {
407
430
  const parts = [];
@@ -409,11 +432,10 @@ class CodeGenerator {
409
432
  if (attr.spread) {
410
433
  parts.push(`...${attr.name}`);
411
434
  }
412
- else if (attr.value === null) {
413
- const key = this.attrKey(attr.name);
414
- parts.push(`${key}: true`);
415
- }
416
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`.
417
439
  const key = this.attrKey(attr.name);
418
440
  parts.push(`${key}: ${attr.value}`);
419
441
  }
@@ -448,17 +470,17 @@ class CodeGenerator {
448
470
  blockChunks.push({ code: blockCode, mappings: blockMappings });
449
471
  }
450
472
  const joinedBlocks = joinChunks(blockChunks, ',\n');
451
- const code = `${node.target}(${node.args}, {\n${joinedBlocks.code}\n${pi}})`;
473
+ const args = reindent(node.args, pi);
474
+ const head = `${node.target}(${appendArg(args, '{', false)}`;
475
+ const code = `${head}\n${joinedBlocks.code}\n${pi}})`;
452
476
  const mappings = [
453
477
  mapping(node.loc),
454
- ...offsetMappings(joinedBlocks.mappings, 1),
478
+ ...offsetMappings(joinedBlocks.mappings, 1 + lineCount(head)),
455
479
  ];
456
480
  return { code, mappings };
457
481
  }
458
- genInclude(node) {
459
- const segments = node.path.split('/');
460
- const name = segments[segments.length - 1];
461
- return chunk(`import { ${name} } from '${node.path}.js'`, node.loc);
482
+ genInclude(node, names) {
483
+ return chunk(`import { ${names.join(', ')} } from '${node.path}.js'`, node.loc);
462
484
  }
463
485
  // --- Simple generators ---
464
486
  genBufferedCode(node) {
@@ -472,12 +494,21 @@ class CodeGenerator {
472
494
  function countEffective(nodes) {
473
495
  return nodes.filter(n => n.type !== 'Comment' && n.type !== 'PassthroughComment').length;
474
496
  }
497
+ /** True when shorthand classes merge with a `class=` value that is not a string literal. */
498
+ function needsRuntimeClassMerge(node) {
499
+ const explicitClass = node.attrs.find(a => a.name === 'class' && !a.spread);
500
+ if (!explicitClass || explicitClass.value === null)
501
+ return false;
502
+ return !/^["'`]/.test(explicitClass.value);
503
+ }
475
504
  function scanUsedHelpers(node) {
476
505
  const helpers = new Set();
477
506
  function walk(n) {
478
507
  switch (n.type) {
479
508
  case 'Element':
480
509
  helpers.add('h');
510
+ if (n.classes.length > 0 && needsRuntimeClassMerge(n))
511
+ helpers.add('mergeClass');
481
512
  if (n.rawBlock)
482
513
  helpers.add('raw');
483
514
  for (const c of n.children)
@@ -534,6 +565,10 @@ function scanUsedHelpers(node) {
534
565
  break;
535
566
  }
536
567
  case 'MixinDefinition':
568
+ if (countEffective(n.body) > 1) {
569
+ helpers.add('h');
570
+ helpers.add('Fragment');
571
+ }
537
572
  for (const c of n.body)
538
573
  walk(c);
539
574
  break;
@@ -542,6 +577,10 @@ function scanUsedHelpers(node) {
542
577
  walk(c);
543
578
  break;
544
579
  case 'Block':
580
+ if (countEffective(n.children) > 1) {
581
+ helpers.add('h');
582
+ helpers.add('Fragment');
583
+ }
545
584
  for (const c of n.children)
546
585
  walk(c);
547
586
  break;
@@ -564,6 +603,121 @@ function scanUsedHelpers(node) {
564
603
  walk(node);
565
604
  return helpers;
566
605
  }
606
+ // --- Include resolution ---
607
+ /** Names of the mixins a document defines at top level. */
608
+ export function definedMixins(doc) {
609
+ return doc.children.flatMap(c => (c.type === 'MixinDefinition' ? [c.name] : []));
610
+ }
611
+ /** Every mixin call in the document, first call of each name, in source order. */
612
+ function collectMixinCalls(doc) {
613
+ const calls = new Map();
614
+ function walkAll(nodes) {
615
+ for (const n of nodes)
616
+ walk(n);
617
+ }
618
+ function walk(n) {
619
+ switch (n.type) {
620
+ case 'MixinCall':
621
+ if (!calls.has(n.name))
622
+ calls.set(n.name, n);
623
+ walkAll(n.block);
624
+ break;
625
+ case 'Element':
626
+ walkAll(n.children);
627
+ break;
628
+ case 'Conditional':
629
+ walkAll(n.consequent);
630
+ for (const alt of n.alternates)
631
+ walkAll(alt.body);
632
+ if (n.fallback)
633
+ walkAll(n.fallback);
634
+ break;
635
+ case 'Iteration':
636
+ case 'Template':
637
+ case 'MixinDefinition':
638
+ walkAll(n.body);
639
+ break;
640
+ case 'Block':
641
+ walkAll(n.children);
642
+ break;
643
+ case 'Extends':
644
+ for (const block of n.blocks)
645
+ walkAll(block.children);
646
+ break;
647
+ }
648
+ }
649
+ walkAll(doc.children);
650
+ return calls;
651
+ }
652
+ /**
653
+ * Value bindings a pass-through `import` statement introduces. A loose read,
654
+ * not a TS parse: it only needs to spot a mixin imported by hand.
655
+ */
656
+ function importedBindings(statement) {
657
+ if (/^import\s+type\b/.test(statement))
658
+ return [];
659
+ const clause = /^import\s+([\s\S]*?)\s+from\b/.exec(statement)?.[1];
660
+ if (!clause)
661
+ return [];
662
+ const names = [];
663
+ const defaultName = /^([A-Za-z_$][\w$]*)/.exec(clause)?.[1];
664
+ if (defaultName)
665
+ names.push(defaultName);
666
+ const namespace = /\*\s*as\s+([A-Za-z_$][\w$]*)/.exec(clause)?.[1];
667
+ if (namespace)
668
+ names.push(namespace);
669
+ const named = /\{([^}]*)\}/.exec(clause)?.[1];
670
+ if (named) {
671
+ for (const spec of named.split(',')) {
672
+ const s = spec.trim();
673
+ if (s === '' || /^type\s/.test(s))
674
+ continue;
675
+ names.push(s.split(/\s+as\s+/).pop().trim());
676
+ }
677
+ }
678
+ return names;
679
+ }
680
+ /** Which called mixins each include must import, in first-call order. */
681
+ function resolveIncludes(doc, resolveInclude) {
682
+ const includes = doc.children.filter((c) => c.type === 'Include');
683
+ const picks = new Map(includes.map(inc => [inc, []]));
684
+ if (includes.length === 0)
685
+ return picks;
686
+ const provided = new Set(definedMixins(doc));
687
+ for (const child of doc.children) {
688
+ if (child.type === 'Import') {
689
+ for (const name of importedBindings(child.statement))
690
+ provided.add(name);
691
+ }
692
+ }
693
+ const needed = [...collectMixinCalls(doc).values()].filter(call => !provided.has(call.name));
694
+ if (!resolveInclude) {
695
+ if (includes.length > 1 && needed.length > 0) {
696
+ const inc = includes[1];
697
+ throw new CodegenError(`Cannot tell which include defines mixin "${needed[0].name}" without an include resolver`, inc.loc.line, inc.loc.col);
698
+ }
699
+ picks.set(includes[0], needed.map(call => call.name));
700
+ return picks;
701
+ }
702
+ const available = new Map();
703
+ for (const inc of includes) {
704
+ try {
705
+ available.set(inc, new Set(resolveInclude(inc.path)));
706
+ }
707
+ catch (err) {
708
+ const msg = err instanceof Error ? err.message : String(err);
709
+ throw new CodegenError(`Cannot include ${inc.path}: ${msg}`, inc.loc.line, inc.loc.col);
710
+ }
711
+ }
712
+ for (const call of needed) {
713
+ const inc = includes.find(i => available.get(i).has(call.name));
714
+ if (!inc) {
715
+ throw new CodegenError(`Mixin "${call.name}" is not defined in this file or any include`, call.loc.line, call.loc.col);
716
+ }
717
+ picks.get(inc).push(call.name);
718
+ }
719
+ return picks;
720
+ }
567
721
  // --- Public API ---
568
722
  /** Generate a TypeScript expression from a single AST node. */
569
723
  export function generateExpr(node) {
@@ -574,14 +728,14 @@ export function generateExpr(node) {
574
728
  return result.code;
575
729
  }
576
730
  /** Generate TypeScript source from a DocumentNode. */
577
- export function generate(doc) {
731
+ export function generate(doc, options = {}) {
578
732
  const gen = new CodeGenerator(true);
579
733
  const parts = [];
580
734
  // Scan for used runtime helpers
581
735
  const helpers = scanUsedHelpers(doc);
582
736
  // Emit runtime import (canonical order)
583
737
  if (helpers.size > 0) {
584
- const order = ['h', 'doctype', 'Fragment', 'raw'];
738
+ const order = ['h', 'doctype', 'Fragment', 'raw', 'mergeClass'];
585
739
  const used = order.filter(h => helpers.has(h));
586
740
  parts.push(chunk(`import { ${used.join(', ')} } from "@chilts/weave"`));
587
741
  }
@@ -610,13 +764,11 @@ export function generate(doc) {
610
764
  parts.push(chunk(statement, child.loc));
611
765
  }
612
766
  }
613
- // Emit include imports
614
- for (const child of doc.children) {
615
- if (child.type === 'Include') {
616
- const result = gen.genNode(child, 0);
617
- if (result !== null)
618
- parts.push(result);
619
- }
767
+ // Emit include imports: only the mixins this file calls, so an include
768
+ // that provides nothing used emits nothing.
769
+ for (const [include, names] of resolveIncludes(doc, options.resolveInclude)) {
770
+ if (names.length > 0)
771
+ parts.push(gen.genInclude(include, names));
620
772
  }
621
773
  // Emit extends imports (auto-import layout function)
622
774
  for (const child of doc.children) {
@@ -641,16 +793,16 @@ export function generate(doc) {
641
793
  return { code: final.code + '\n', mappings: final.mappings };
642
794
  }
643
795
  /** Full pipeline: source string → TypeScript source. */
644
- export function compile(source) {
796
+ export function compile(source, options = {}) {
645
797
  const tokens = lex(source);
646
798
  const doc = parse(tokens);
647
- return generate(doc).code;
799
+ return generate(doc, options).code;
648
800
  }
649
801
  /** Full pipeline with source map generation. */
650
802
  export function compileWithSourceMap(source, opts) {
651
803
  const tokens = lex(source);
652
804
  const doc = parse(tokens);
653
- const result = generate(doc);
805
+ const result = generate(doc, opts);
654
806
  const sourceMap = buildSourceMap(result.mappings, opts.file, opts.sourceFile);
655
807
  return { code: result.code, sourceMap };
656
808
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export { h, doctype, Fragment, raw, escape, SafeHtml } from './runtime/index.js';
1
+ export { h, doctype, Fragment, raw, mergeClass, escape, SafeHtml } from './runtime/index.js';
2
2
  export type { Attrs } from './runtime/index.js';
3
- export { compile, compileWithSourceMap, generate, generateExpr, CodegenError } from './codegen/index.js';
4
- export type { GenerateResult, CodeChunk, Mapping, SourceMap } from './codegen/index.js';
3
+ export { compile, compileWithSourceMap, generate, generateExpr, definedMixins, CodegenError } from './codegen/index.js';
4
+ export type { GenerateResult, GenerateOptions, IncludeResolver, CodeChunk, Mapping, SourceMap } from './codegen/index.js';
5
5
  export { lex, LexerError } from './lexer/index.js';
6
6
  export type { Token, TokenType } from './lexer/index.js';
7
7
  export { parse, ParserError } from './parser/index.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Runtime
2
- export { h, doctype, Fragment, raw, escape, SafeHtml } from './runtime/index.js';
2
+ export { h, doctype, Fragment, raw, mergeClass, escape, SafeHtml } from './runtime/index.js';
3
3
  // Compiler
4
- export { compile, compileWithSourceMap, generate, generateExpr, CodegenError } from './codegen/index.js';
4
+ export { compile, compileWithSourceMap, generate, generateExpr, definedMixins, CodegenError } from './codegen/index.js';
5
5
  export { lex, LexerError } from './lexer/index.js';
6
6
  export { parse, ParserError } from './parser/index.js';
7
7
  //# sourceMappingURL=index.js.map