@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.
@@ -54,7 +54,9 @@ class Lexer {
54
54
  }
55
55
  // Preserve indentation relative to the base
56
56
  const text = rawLine.slice(this.rawBlockBaseIndent);
57
- this.emit('RAW_TEXT', text);
57
+ // The cursor still belongs to the previous line, so anchor
58
+ // explicitly on this line's first non-space character.
59
+ this.emit('RAW_TEXT', text, 0);
58
60
  this.emit('NEWLINE', '');
59
61
  continue;
60
62
  }
@@ -225,70 +227,63 @@ class Lexer {
225
227
  }
226
228
  scanBlock() {
227
229
  this.emit('BLOCK', 'block');
228
- const name = this.currentLine.slice('block '.length).trim();
229
- this.emit('IDENTIFIER', name);
230
+ const nameStart = this.skipSpacesFrom('block '.length);
231
+ const name = this.currentLine.slice(nameStart).trim();
232
+ this.emit('IDENTIFIER', name, nameStart);
230
233
  this.pos = this.currentLine.length;
231
234
  }
232
235
  scanInclude() {
233
236
  this.emit('INCLUDE', 'include');
234
- const path = this.currentLine.slice('include '.length).trim();
235
- this.emit('PATH', path);
237
+ const pathStart = this.skipSpacesFrom('include '.length);
238
+ const path = this.currentLine.slice(pathStart).trim();
239
+ this.emit('PATH', path, pathStart);
236
240
  this.pos = this.currentLine.length;
237
241
  }
238
242
  // --- Declaration scanners ---
239
243
  scanTemplate() {
244
+ this.emit('TEMPLATE', 'template', 0);
240
245
  this.pos = 'template '.length;
241
- this.emit('TEMPLATE', 'template');
246
+ const nameStart = this.pos;
242
247
  const name = this.scanIdentifier();
243
- this.emit('IDENTIFIER', name);
244
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
245
- const params = this.scanBalancedParens();
246
- this.emit('PARAMS', params);
247
- }
248
+ this.emit('IDENTIFIER', name, nameStart);
249
+ this.scanParamsIfPresent();
248
250
  }
249
251
  scanMixin() {
252
+ this.emit('MIXIN', 'mixin', 0);
250
253
  this.pos = 'mixin '.length;
251
- this.emit('MIXIN', 'mixin');
254
+ const nameStart = this.pos;
252
255
  const name = this.scanIdentifier();
253
- this.emit('IDENTIFIER', name);
254
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
255
- const params = this.scanBalancedParens();
256
- this.emit('PARAMS', params);
257
- }
256
+ this.emit('IDENTIFIER', name, nameStart);
257
+ this.scanParamsIfPresent();
258
258
  }
259
259
  scanExtends() {
260
+ this.emit('EXTENDS', 'extends', 0);
260
261
  this.pos = 'extends '.length;
261
- this.emit('EXTENDS', 'extends');
262
+ const nameStart = this.pos;
262
263
  const name = this.scanIdentifier();
263
- this.emit('IDENTIFIER', name);
264
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
265
- const params = this.scanBalancedParens();
266
- this.emit('PARAMS', params);
267
- }
264
+ this.emit('IDENTIFIER', name, nameStart);
265
+ this.scanParamsIfPresent();
268
266
  }
269
267
  // --- Control flow scanners ---
270
268
  scanIf() {
269
+ this.emit('IF', 'if', 0);
271
270
  this.pos = 'if '.length;
272
- this.emit('IF', 'if');
273
271
  const expr = this.currentLine.slice(this.pos);
274
272
  this.emit('BUFFERED_CODE', expr);
275
273
  this.pos = this.currentLine.length;
276
274
  }
277
275
  scanElseIf() {
276
+ this.emit('ELSE_IF', 'else if', 0);
278
277
  this.pos = 'else if '.length;
279
- this.emit('ELSE_IF', 'else if');
280
278
  const expr = this.currentLine.slice(this.pos);
281
279
  this.emit('BUFFERED_CODE', expr);
282
280
  this.pos = this.currentLine.length;
283
281
  }
284
282
  scanFor() {
283
+ this.emit('FOR', 'for', 0);
285
284
  this.pos = 'for '.length;
286
- this.emit('FOR', 'for');
287
285
  this.skipSpaces();
288
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
289
- const params = this.scanBalancedParens();
290
- this.emit('PARAMS', params);
291
- }
286
+ this.scanParamsIfPresent();
292
287
  }
293
288
  // --- Element scanner ---
294
289
  scanElement() {
@@ -301,8 +296,9 @@ class Lexer {
301
296
  this.emit('TAG', 'div');
302
297
  }
303
298
  else {
299
+ const tagStart = this.pos;
304
300
  const tag = this.scanTagName();
305
- this.emit('TAG', tag);
301
+ this.emit('TAG', tag, tagStart);
306
302
  }
307
303
  // Classes and IDs
308
304
  this.scanClassesAndIds();
@@ -311,7 +307,7 @@ class Lexer {
311
307
  this.emit('ATTR_START', '(');
312
308
  this.pos++; // skip '('
313
309
  this.scanAttributes(elementIndent);
314
- this.emit('ATTR_END', ')');
310
+ this.emit('ATTR_END', ')', this.pos - 1);
315
311
  }
316
312
  // Raw block: tag!
317
313
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '!') {
@@ -337,35 +333,58 @@ class Lexer {
337
333
  return this.currentLine.slice(start, this.pos);
338
334
  }
339
335
  scanClassesAndIds() {
336
+ let prevClass = null;
340
337
  while (this.pos < this.currentLine.length) {
341
338
  const ch = this.currentLine[this.pos];
342
339
  if (ch === '.') {
340
+ const dotPos = this.pos;
341
+ const dotCol = this.indentWidth + dotPos + 1;
343
342
  this.pos++; // skip dot
344
- const start = this.pos;
345
- while (this.pos < this.currentLine.length) {
346
- const c = this.currentLine[this.pos];
347
- if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
348
- break;
349
- this.pos++;
343
+ const { name, raw } = this.scanShorthandName();
344
+ // A dot is a class separator, so '.gap-1.5' silently became
345
+ // 'gap-1' plus a stray '5'. A class that is a bare number is
346
+ // almost certainly that mistake, so reject it and name the fix.
347
+ if (/^[0-9]+$/.test(name)) {
348
+ const example = prevClass === null ? `.gap-1\\.${name}` : `.${prevClass}\\.${name}`;
349
+ const split = prevClass === null ? '' : ` ('.${prevClass}.${name}' was split into '${prevClass}' and '${name}')`;
350
+ throw new LexerError(`Class '.${name}' is a bare number; a dot inside a class name is read as a separator${split}. ` +
351
+ `Escape the dot with a backslash, e.g. '${example}'`, this.lineIndex + 1, dotCol);
350
352
  }
351
- this.emit('CLASS', this.currentLine.slice(start, this.pos));
353
+ prevClass = raw;
354
+ this.emit('CLASS', name, dotPos);
352
355
  }
353
356
  else if (ch === '#') {
357
+ const hashPos = this.pos;
354
358
  this.pos++; // skip hash
355
- const start = this.pos;
356
- while (this.pos < this.currentLine.length) {
357
- const c = this.currentLine[this.pos];
358
- if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
359
- break;
360
- this.pos++;
361
- }
362
- this.emit('ID', this.currentLine.slice(start, this.pos));
359
+ prevClass = null;
360
+ this.emit('ID', this.scanShorthandName().name, hashPos);
363
361
  }
364
362
  else {
365
363
  break;
366
364
  }
367
365
  }
368
366
  }
367
+ // Scans a class or id name after its '.' or '#'. A backslash-escaped dot
368
+ // ('\.') is a literal dot rather than the start of the next class, so
369
+ // Tailwind's 'gap-1\.5' is a single class 'gap-1.5'. Returns the unescaped
370
+ // name and the source text it came from.
371
+ scanShorthandName() {
372
+ const start = this.pos;
373
+ let name = '';
374
+ while (this.pos < this.currentLine.length) {
375
+ const c = this.currentLine[this.pos];
376
+ if (c === '\\' && this.currentLine[this.pos + 1] === '.') {
377
+ name += '.';
378
+ this.pos += 2;
379
+ continue;
380
+ }
381
+ if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
382
+ break;
383
+ name += c;
384
+ this.pos++;
385
+ }
386
+ return { name, raw: this.currentLine.slice(start, this.pos) };
387
+ }
369
388
  // Scans an attribute list, with the cursor just past the opening '('.
370
389
  // `ownerIndent` is the indent of the element or mixin call that owns the
371
390
  // list, which bounds the multi-line form.
@@ -461,6 +480,7 @@ class Lexer {
461
480
  // Scans a single `...spread`, `name` or `name=value`, with the cursor on
462
481
  // its first character.
463
482
  scanAttribute() {
483
+ const start = this.pos;
464
484
  // Spread: ...identifier
465
485
  if (this.currentLine[this.pos] === '.' &&
466
486
  this.pos + 2 < this.currentLine.length &&
@@ -468,7 +488,7 @@ class Lexer {
468
488
  this.currentLine[this.pos + 2] === '.') {
469
489
  this.pos += 3; // skip ...
470
490
  const name = this.scanIdentifier();
471
- this.emit('SPREAD', name);
491
+ this.emit('SPREAD', name, start);
472
492
  return;
473
493
  }
474
494
  // Attribute name
@@ -482,12 +502,13 @@ class Lexer {
482
502
  throw new LexerError(`Unexpected '${this.currentLine[this.pos]}' in attribute list; ` +
483
503
  `wrap complex attribute values in (parentheses) or \`backticks\``, this.lineIndex + 1, this.indentWidth + this.pos + 1);
484
504
  }
485
- this.emit('ATTR_NAME', name);
505
+ this.emit('ATTR_NAME', name, start);
486
506
  // Check for = value
487
507
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '=') {
488
508
  this.pos++; // skip =
509
+ const valueStart = this.pos;
489
510
  const value = this.scanAttrValue();
490
- this.emit('ATTR_VALUE', value);
511
+ this.emit('ATTR_VALUE', value, valueStart);
491
512
  }
492
513
  }
493
514
  scanAttrName() {
@@ -571,18 +592,16 @@ class Lexer {
571
592
  const elementIndent = this.indentWidth;
572
593
  this.pos = 1; // skip +
573
594
  const name = this.scanIdentifier();
574
- this.emit('MIXIN_CALL', name);
595
+ // Anchored on the '+', not where the name scan left the cursor.
596
+ this.emit('MIXIN_CALL', name, 0);
575
597
  // Args in first set of parens
576
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
577
- const args = this.scanBalancedParens();
578
- this.emit('PARAMS', args);
579
- }
598
+ this.scanParamsIfPresent();
580
599
  // Extra attrs in second set of parens
581
600
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
582
601
  this.emit('ATTR_START', '(');
583
602
  this.pos++; // skip '('
584
603
  this.scanAttributes(elementIndent);
585
- this.emit('ATTR_END', ')');
604
+ this.emit('ATTR_END', ')', this.pos - 1);
586
605
  }
587
606
  }
588
607
  // --- Pipe scanner ---
@@ -599,17 +618,21 @@ class Lexer {
599
618
  this.pos = this.currentLine.length;
600
619
  }
601
620
  // --- Text with interpolation ---
621
+ // `text` is the rest of the current line from the cursor, so an offset
622
+ // into it plus the cursor is that character's position on the line.
602
623
  scanTextWithInterpolation(text) {
603
624
  let i = 0;
604
625
  let buf = '';
626
+ let bufStart = 0;
605
627
  while (i < text.length) {
606
628
  // Check for interpolation #{...}
607
629
  if (text[i] === '#' && i + 1 < text.length && text[i + 1] === '{') {
608
630
  // Emit accumulated text
609
631
  if (buf.length > 0) {
610
- this.emit('TEXT', buf);
632
+ this.emit('TEXT', buf, this.pos + bufStart);
611
633
  buf = '';
612
634
  }
635
+ const interpStart = i;
613
636
  i += 2; // skip #{
614
637
  // Scan balanced braces to find closing }
615
638
  let depth = 1;
@@ -641,10 +664,11 @@ class Lexer {
641
664
  i++;
642
665
  }
643
666
  if (depth !== 0) {
644
- throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + 1);
667
+ throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + interpStart + 1);
645
668
  }
646
- this.emit('INTERPOLATION', text.slice(start, i));
669
+ this.emit('INTERPOLATION', text.slice(start, i), this.pos + interpStart);
647
670
  i++; // skip closing }
671
+ bufStart = i;
648
672
  }
649
673
  else {
650
674
  buf += text[i];
@@ -653,42 +677,119 @@ class Lexer {
653
677
  }
654
678
  // Emit remaining text
655
679
  if (buf.length > 0) {
656
- this.emit('TEXT', buf);
680
+ this.emit('TEXT', buf, this.pos + bufStart);
657
681
  }
658
682
  }
659
683
  // --- Balanced scanning helpers ---
684
+ // Scans a '(...)' group whose contents are captured verbatim (params, for
685
+ // headers, mixin call args), with the cursor on the '('. Returns the text
686
+ // between the parens.
687
+ //
688
+ // The group may span lines: it simply stays open until its parens balance.
689
+ // Unlike a multi-line attribute list there is no required shape, so the
690
+ // way TypeScript is usually wrapped works as written:
691
+ //
692
+ // template dashboard({
693
+ // title,
694
+ // }: DashboardProps)
695
+ //
696
+ // Continuation lines are consumed here, so they never reach the main loop
697
+ // and produce no INDENT/DEDENT/NEWLINE tokens. They keep their indentation
698
+ // relative to the line that opened the group. `//` comments are dropped:
699
+ // the captured text is re-emitted inside a single expression, where a
700
+ // comment would swallow whatever the compiler appends after it.
660
701
  scanBalancedParens() {
661
702
  if (this.currentLine[this.pos] !== '(') {
662
703
  throw new LexerError('Expected opening parenthesis', this.lineIndex + 1, this.indentWidth + this.pos + 1);
663
704
  }
705
+ const ownerIndent = this.indentWidth;
706
+ const openLine = this.lineIndex + 1;
707
+ const openCol = this.indentWidth + this.pos + 1;
664
708
  this.pos++; // skip opening (
665
709
  let depth = 1;
666
- const start = this.pos;
667
- while (this.pos < this.currentLine.length && depth > 0) {
668
- const ch = this.currentLine[this.pos];
669
- if (ch === '(') {
670
- depth++;
671
- this.pos++;
710
+ const pieces = [];
711
+ let prefix = ''; // this line's indentation relative to the owner
712
+ for (;;) {
713
+ const start = this.pos;
714
+ let end = -1; // where this line's content stops: the closing ')' or a comment
715
+ while (this.pos < this.currentLine.length) {
716
+ const ch = this.currentLine[this.pos];
717
+ if (ch === '(') {
718
+ depth++;
719
+ this.pos++;
720
+ }
721
+ else if (ch === ')') {
722
+ depth--;
723
+ if (depth === 0) {
724
+ end = this.pos;
725
+ break;
726
+ }
727
+ this.pos++;
728
+ }
729
+ else if (ch === '"' || ch === "'" || ch === '`') {
730
+ const quotePos = this.pos;
731
+ if (!this.skipStringInPlace(ch)) {
732
+ // A string has to close on its own line; letting it run
733
+ // on would throw the paren count off for every line after.
734
+ throw new LexerError('Unterminated string', this.lineIndex + 1, this.indentWidth + quotePos + 1);
735
+ }
736
+ }
737
+ else if (ch === '/' && this.currentLine[this.pos + 1] === '/') {
738
+ end = this.pos;
739
+ this.pos = this.currentLine.length;
740
+ }
741
+ else {
742
+ this.pos++;
743
+ }
672
744
  }
673
- else if (ch === ')') {
674
- depth--;
675
- if (depth === 0)
676
- break;
677
- this.pos++;
745
+ const isComment = end !== -1 && depth > 0;
746
+ const text = this.currentLine.slice(start, end === -1 ? this.currentLine.length : end).trimEnd();
747
+ if (depth === 0 && pieces.length === 0) {
748
+ // Closed on the line it opened on: the common, single-line case.
749
+ this.pos++; // skip closing )
750
+ return text.trim();
678
751
  }
679
- else if (ch === '"' || ch === "'" || ch === '`') {
680
- this.skipStringInPlace(ch);
752
+ if (pieces.length === 0) {
753
+ pieces.push(text.trimStart());
681
754
  }
682
- else {
683
- this.pos++;
755
+ else if (text !== '' || !isComment) {
756
+ // Lines holding nothing but a comment are dropped entirely.
757
+ pieces.push(text === '' ? '' : prefix + text);
684
758
  }
759
+ if (depth === 0)
760
+ break;
761
+ this.nextParamsLine(ownerIndent, openLine, openCol);
762
+ prefix = ' '.repeat(this.indentWidth - ownerIndent);
685
763
  }
686
- if (depth !== 0) {
687
- throw new LexerError('Unterminated parentheses', this.lineIndex + 1, this.indentWidth + this.pos + 1);
688
- }
689
- const content = this.currentLine.slice(start, this.pos);
690
764
  this.pos++; // skip closing )
691
- return content.trim();
765
+ const content = pieces.join('\n');
766
+ return content.trim() === '' ? '' : content;
767
+ }
768
+ // Moves onto the next non-blank line of a multi-line paren group. Lines in
769
+ // the group must be indented under its owner; the one exception is the
770
+ // line that closes it (e.g. '}: Props)'), which may sit level with the
771
+ // owner as long as it starts with a closing bracket. Anything else at or
772
+ // left of the owner means the ')' was forgotten, so stop there rather
773
+ // than swallowing the rest of the file.
774
+ nextParamsLine(ownerIndent, openLine, openCol) {
775
+ for (;;) {
776
+ if (this.lineIndex + 1 >= this.lines.length) {
777
+ throw new LexerError('Unterminated parentheses', openLine, openCol);
778
+ }
779
+ this.lineIndex++;
780
+ const rawLine = this.lines[this.lineIndex];
781
+ if (rawLine.trim().length === 0)
782
+ continue;
783
+ this.indentWidth = this.measureIndent(rawLine);
784
+ this.currentLine = rawLine.slice(this.indentWidth);
785
+ this.pos = 0;
786
+ if (this.indentWidth < ownerIndent ||
787
+ (this.indentWidth === ownerIndent && !/^[)}\]]/.test(this.currentLine))) {
788
+ throw new LexerError(`Expected ')' to close the parentheses opened at line ${openLine}; ` +
789
+ 'continuation lines must be indented under the line that opens them', this.lineIndex + 1, this.indentWidth + 1);
790
+ }
791
+ return;
792
+ }
692
793
  }
693
794
  scanString(quote) {
694
795
  const start = this.pos;
@@ -745,7 +846,10 @@ class Lexer {
745
846
  }
746
847
  throw new LexerError('Unterminated template literal', this.lineIndex + 1, this.indentWidth + this.pos + 1);
747
848
  }
748
- /** Skip over a string in-place (used within balanced paren scanning) */
849
+ /**
850
+ * Skip over a string in-place (used within balanced paren scanning).
851
+ * Returns false when the line ends before the string closes.
852
+ */
749
853
  skipStringInPlace(quote) {
750
854
  this.pos++; // skip opening quote
751
855
  while (this.pos < this.currentLine.length) {
@@ -754,7 +858,7 @@ class Lexer {
754
858
  }
755
859
  else if (this.currentLine[this.pos] === quote) {
756
860
  this.pos++;
757
- return;
861
+ return true;
758
862
  }
759
863
  else if (quote === '`' &&
760
864
  this.currentLine[this.pos] === '$' &&
@@ -764,21 +868,27 @@ class Lexer {
764
868
  this.pos += 2;
765
869
  let depth = 1;
766
870
  while (this.pos < this.currentLine.length && depth > 0) {
767
- if (this.currentLine[this.pos] === '{')
871
+ if (this.currentLine[this.pos] === '{') {
768
872
  depth++;
769
- else if (this.currentLine[this.pos] === '}')
873
+ }
874
+ else if (this.currentLine[this.pos] === '}') {
770
875
  depth--;
771
- if (depth > 0)
772
- this.pos++;
876
+ }
877
+ else if (this.currentLine[this.pos] === '"' ||
878
+ this.currentLine[this.pos] === "'" ||
879
+ this.currentLine[this.pos] === '`') {
880
+ this.skipStringInPlace(this.currentLine[this.pos]);
881
+ continue;
882
+ }
883
+ this.pos++;
773
884
  }
774
- if (this.pos < this.currentLine.length)
775
- this.pos++; // skip closing }
776
- return;
885
+ // fall through to keep scanning for the closing backtick
777
886
  }
778
887
  else {
779
888
  this.pos++;
780
889
  }
781
890
  }
891
+ return false;
782
892
  }
783
893
  // --- Utility methods ---
784
894
  scanIdentifier() {
@@ -794,17 +904,36 @@ class Lexer {
794
904
  }
795
905
  return this.currentLine.slice(start, this.pos);
796
906
  }
907
+ // Scans a '(...)' group at the cursor, if there is one, as a PARAMS token
908
+ // anchored on the '('. The group may run onto later lines, so the anchor
909
+ // is taken before scanning moves the cursor.
910
+ scanParamsIfPresent() {
911
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
912
+ const line = this.lineIndex + 1;
913
+ const col = this.indentWidth + this.pos + 1;
914
+ const params = this.scanBalancedParens();
915
+ this.tokens.push({ type: 'PARAMS', value: params, line, col });
916
+ }
917
+ }
918
+ // Returns the first non-space position at or after `pos` on the current line.
919
+ skipSpacesFrom(pos) {
920
+ while (pos < this.currentLine.length && this.currentLine[pos] === ' ')
921
+ pos++;
922
+ return pos;
923
+ }
797
924
  skipSpaces() {
798
925
  while (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
799
926
  this.pos++;
800
927
  }
801
928
  }
802
- emit(type, value) {
929
+ // `start` is the token's position on the current line; it defaults to the
930
+ // cursor, which for scanned tokens is already past the token itself.
931
+ emit(type, value, start = this.pos) {
803
932
  this.tokens.push({
804
933
  type,
805
934
  value,
806
935
  line: this.lineIndex + 1,
807
- col: this.indentWidth + this.pos + 1,
936
+ col: this.indentWidth + start + 1,
808
937
  });
809
938
  }
810
939
  }
@@ -9,6 +9,12 @@ export declare class SafeHtml {
9
9
  }
10
10
  export declare function escape(val: string): string;
11
11
  export declare function raw(html: string): SafeHtml;
12
+ /**
13
+ * Merge shorthand classes (`.btn`) with an explicit `class=` value that is only
14
+ * known at render time. A value that means "absent" — undefined, null, a
15
+ * boolean, or "" — adds nothing, so the shorthand classes always survive.
16
+ */
17
+ export declare function mergeClass(prefix: string, value: string | number | boolean | null | undefined): string;
12
18
  export declare function doctype(): SafeHtml;
13
19
  export declare function h(tag: string | symbol, attrs: Attrs, ...children: unknown[]): SafeHtml;
14
20
  export {};
@@ -28,6 +28,16 @@ export function escape(val) {
28
28
  export function raw(html) {
29
29
  return new SafeHtml(html);
30
30
  }
31
+ /**
32
+ * Merge shorthand classes (`.btn`) with an explicit `class=` value that is only
33
+ * known at render time. A value that means "absent" — undefined, null, a
34
+ * boolean, or "" — adds nothing, so the shorthand classes always survive.
35
+ */
36
+ export function mergeClass(prefix, value) {
37
+ if (value == null || typeof value === 'boolean' || value === '')
38
+ return prefix;
39
+ return `${prefix} ${value}`;
40
+ }
31
41
  export function doctype() {
32
42
  return new SafeHtml('<!DOCTYPE html>');
33
43
  }
@@ -57,7 +67,7 @@ function renderChildren(children) {
57
67
  function renderAttrs(attrs) {
58
68
  let out = '';
59
69
  for (const [key, val] of Object.entries(attrs)) {
60
- if (val === undefined || val === false)
70
+ if (val == null || val === false)
61
71
  continue;
62
72
  if (val === true) {
63
73
  out += ` ${key}`;
package/package.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "name": "@chilts/weave",
3
3
  "title": "Weave",
4
4
  "description": "A Pug-inspired templating engine that compiles to TypeScript.",
5
- "version": "0.6.0",
5
+ "version": "0.7.0",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "bin": {
9
- "weave": "./bin/weave.js"
9
+ "weave": "bin/weave.js"
10
10
  },
11
11
  "types": "./dist/index.d.ts",
12
12
  "exports": {
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "dist/",
20
20
  "bin/",
21
+ "CHANGELOG.md",
21
22
  "!dist/**/*.map"
22
23
  ],
23
24
  "directories": {
@@ -42,7 +43,10 @@
42
43
  "homepage": "https://weave.chilts.org",
43
44
  "repository": {
44
45
  "type": "git",
45
- "url": "ssh://git.chilts.me/srv/git/chilts/weave.git"
46
+ "url": "git+https://codeberg.org/chilts/weave.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://codeberg.org/chilts/weave/issues"
46
50
  },
47
51
  "author": "Andrew Chilton <andychilton@gmail.com>",
48
52
  "keywords": [