@chilts/weave 0.6.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
  }
@@ -272,13 +298,16 @@ class CodeGenerator {
272
298
  return `{ ${parts.join(', ')} }`;
273
299
  }
274
300
  mergeClassValue(prefix, value) {
275
- if (value.startsWith('`')) {
276
- 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}"`;
277
306
  }
278
- if (value.startsWith('"')) {
279
- return `"${prefix} ${value.slice(1)}`;
307
+ if (value.startsWith('`') || value.startsWith('"') || value.startsWith("'")) {
308
+ return `${value[0]}${prefix} ${value.slice(1)}`;
280
309
  }
281
- return `\`${prefix} \${${value}}\``;
310
+ return `mergeClass("${prefix}", ${value})`;
282
311
  }
283
312
  attrKey(name) {
284
313
  if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name))
@@ -330,19 +359,19 @@ class CodeGenerator {
330
359
  // A mid-layer template both fills its parent's slots and exposes its
331
360
  // own (nested `block` nodes), so it needs a `blocks` parameter too.
332
361
  const params = templateHasBlocks(node)
333
- ? `${node.params}, blocks: ${capitalize(node.name)}Blocks = {}`
362
+ ? appendArg(node.params, `blocks: ${capitalize(node.name)}Blocks = {}`)
334
363
  : node.params;
335
364
  const code = `export function ${node.name}(${params}) {\n${i1}return ${extendsChunk.code}\n}`;
336
365
  const mappings = [
337
366
  mapping(node.loc),
338
- ...offsetMappings(extendsChunk.mappings, 1),
367
+ ...offsetMappings(extendsChunk.mappings, 1 + lineCount(params)),
339
368
  ];
340
369
  return { code, mappings };
341
370
  }
342
371
  // Check if body tree contains BlockNodes (layout template)
343
372
  const hasBlocks = templateHasBlocks(node);
344
373
  const params = hasBlocks
345
- ? `${node.params}, blocks: ${capitalize(node.name)}Blocks = {}`
374
+ ? appendArg(node.params, `blocks: ${capitalize(node.name)}Blocks = {}`)
346
375
  : node.params;
347
376
  const bodyChunks = node.body
348
377
  .map(child => this.genNode(child, indent + 2))
@@ -371,16 +400,14 @@ class CodeGenerator {
371
400
  const code = `export function ${node.name}(${params}) {\n${commentPrefix}${i1}return ${bodyChunk.code}\n}`;
372
401
  const mappings = [
373
402
  mapping(node.loc),
374
- ...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
375
404
  ];
376
405
  return { code, mappings };
377
406
  }
378
407
  // --- Mixin generation ---
379
408
  genMixinDefinition(node, indent) {
380
409
  const i1 = this.ind(indent + 1);
381
- const params = node.params
382
- ? `${node.params}, attrs: Attrs = {}`
383
- : 'attrs: Attrs = {}';
410
+ const params = appendArg(node.params, 'attrs: Attrs = {}');
384
411
  const bodyChunks = node.body
385
412
  .map(child => this.genNode(child, indent + 1))
386
413
  .filter((e) => e !== null);
@@ -388,19 +415,16 @@ class CodeGenerator {
388
415
  const code = `export function ${node.name}(${params}) {\n${i1}return ${bodyChunk.code}\n}`;
389
416
  const mappings = [
390
417
  mapping(node.loc),
391
- ...offsetMappings(bodyChunk.mappings, 1),
418
+ ...offsetMappings(bodyChunk.mappings, 1 + lineCount(params)),
392
419
  ];
393
420
  return { code, mappings };
394
421
  }
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);
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);
404
428
  }
405
429
  genCallAttrs(attrs) {
406
430
  const parts = [];
@@ -446,17 +470,17 @@ class CodeGenerator {
446
470
  blockChunks.push({ code: blockCode, mappings: blockMappings });
447
471
  }
448
472
  const joinedBlocks = joinChunks(blockChunks, ',\n');
449
- 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}})`;
450
476
  const mappings = [
451
477
  mapping(node.loc),
452
- ...offsetMappings(joinedBlocks.mappings, 1),
478
+ ...offsetMappings(joinedBlocks.mappings, 1 + lineCount(head)),
453
479
  ];
454
480
  return { code, mappings };
455
481
  }
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);
482
+ genInclude(node, names) {
483
+ return chunk(`import { ${names.join(', ')} } from '${node.path}.js'`, node.loc);
460
484
  }
461
485
  // --- Simple generators ---
462
486
  genBufferedCode(node) {
@@ -470,12 +494,21 @@ class CodeGenerator {
470
494
  function countEffective(nodes) {
471
495
  return nodes.filter(n => n.type !== 'Comment' && n.type !== 'PassthroughComment').length;
472
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
+ }
473
504
  function scanUsedHelpers(node) {
474
505
  const helpers = new Set();
475
506
  function walk(n) {
476
507
  switch (n.type) {
477
508
  case 'Element':
478
509
  helpers.add('h');
510
+ if (n.classes.length > 0 && needsRuntimeClassMerge(n))
511
+ helpers.add('mergeClass');
479
512
  if (n.rawBlock)
480
513
  helpers.add('raw');
481
514
  for (const c of n.children)
@@ -570,6 +603,121 @@ function scanUsedHelpers(node) {
570
603
  walk(node);
571
604
  return helpers;
572
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
+ }
573
721
  // --- Public API ---
574
722
  /** Generate a TypeScript expression from a single AST node. */
575
723
  export function generateExpr(node) {
@@ -580,14 +728,14 @@ export function generateExpr(node) {
580
728
  return result.code;
581
729
  }
582
730
  /** Generate TypeScript source from a DocumentNode. */
583
- export function generate(doc) {
731
+ export function generate(doc, options = {}) {
584
732
  const gen = new CodeGenerator(true);
585
733
  const parts = [];
586
734
  // Scan for used runtime helpers
587
735
  const helpers = scanUsedHelpers(doc);
588
736
  // Emit runtime import (canonical order)
589
737
  if (helpers.size > 0) {
590
- const order = ['h', 'doctype', 'Fragment', 'raw'];
738
+ const order = ['h', 'doctype', 'Fragment', 'raw', 'mergeClass'];
591
739
  const used = order.filter(h => helpers.has(h));
592
740
  parts.push(chunk(`import { ${used.join(', ')} } from "@chilts/weave"`));
593
741
  }
@@ -616,13 +764,11 @@ export function generate(doc) {
616
764
  parts.push(chunk(statement, child.loc));
617
765
  }
618
766
  }
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
- }
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));
626
772
  }
627
773
  // Emit extends imports (auto-import layout function)
628
774
  for (const child of doc.children) {
@@ -647,16 +793,16 @@ export function generate(doc) {
647
793
  return { code: final.code + '\n', mappings: final.mappings };
648
794
  }
649
795
  /** Full pipeline: source string → TypeScript source. */
650
- export function compile(source) {
796
+ export function compile(source, options = {}) {
651
797
  const tokens = lex(source);
652
798
  const doc = parse(tokens);
653
- return generate(doc).code;
799
+ return generate(doc, options).code;
654
800
  }
655
801
  /** Full pipeline with source map generation. */
656
802
  export function compileWithSourceMap(source, opts) {
657
803
  const tokens = lex(source);
658
804
  const doc = parse(tokens);
659
- const result = generate(doc);
805
+ const result = generate(doc, opts);
660
806
  const sourceMap = buildSourceMap(result.mappings, opts.file, opts.sourceFile);
661
807
  return { code: result.code, sourceMap };
662
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