@chilts/weave 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { Token } from '../lexer/index.js';
2
- export type { AstNode, SourceLocation, TextSegment, AttributeNode, ConditionalBranch, DocumentNode, TemplateNode, MixinDefinitionNode, MixinCallNode, ElementNode, TextNode, BufferedCodeNode, ConditionalNode, IterationNode, BlockNode, ExtendsNode, IncludeNode, ImportNode, DoctypeNode, CommentNode, PassthroughCommentNode, PipeNode, RawBlockNode, } from './types.js';
2
+ export type { AstNode, SourceLocation, ValueLoc, TextSegment, AttributeNode, ConditionalBranch, DocumentNode, TemplateNode, MixinDefinitionNode, MixinCallNode, ElementNode, TextNode, BufferedCodeNode, ConditionalNode, IterationNode, BlockNode, ExtendsNode, IncludeNode, ImportNode, DoctypeNode, CommentNode, PassthroughCommentNode, PipeNode, RawBlockNode, } from './types.js';
3
3
  import type { DocumentNode } from './types.js';
4
4
  export declare class ParserError extends Error {
5
5
  readonly line: number;
@@ -59,6 +59,11 @@ class Parser {
59
59
  loc(tok) {
60
60
  return { line: tok.line, col: tok.col };
61
61
  }
62
+ // A captured value starts where its token does, unless the lexer recorded
63
+ // its lines (a params group, whose token is anchored on the '(').
64
+ valueLoc(tok) {
65
+ return tok.starts ?? [this.loc(tok)];
66
+ }
62
67
  // --- Core parsing ---
63
68
  parseDocument() {
64
69
  const startTok = this.peek();
@@ -94,6 +99,7 @@ class Parser {
94
99
  case 'INTERPOLATION':
95
100
  return this.parseText();
96
101
  case 'IF':
102
+ case 'UNLESS':
97
103
  return this.parseConditional();
98
104
  case 'FOR':
99
105
  return this.parseIteration();
@@ -107,11 +113,13 @@ class Parser {
107
113
  return this.parseExtends();
108
114
  case 'INCLUDE':
109
115
  return this.parseInclude();
110
- // Standalone else if / else are errors — they must follow an if
116
+ // Standalone else if / else are errors — they must follow an if (or a for)
111
117
  case 'ELSE_IF':
112
118
  case 'ELSE': {
113
119
  const tok = this.peek();
114
- throw new ParserError(`Unexpected ${tok.value || type} without preceding if`, tok.line, tok.col);
120
+ throw new ParserError(type === 'ELSE'
121
+ ? 'Unexpected else without preceding if or for'
122
+ : 'Unexpected else if without preceding if', tok.line, tok.col);
115
123
  }
116
124
  default: {
117
125
  const tok = this.peek();
@@ -158,10 +166,15 @@ class Parser {
158
166
  }
159
167
  // --- Control flow parsers ---
160
168
  parseConditional() {
161
- const startTok = this.expect('IF');
169
+ const startTok = this.advance(); // IF or UNLESS
162
170
  const conditionTok = this.expect('BUFFERED_CODE');
163
171
  this.skip('NEWLINE');
164
172
  const consequent = this.parseBlock();
173
+ // 'unless' is a guard only: an else after it reads as a double negative.
174
+ if (startTok.type === 'UNLESS' && (this.peekType() === 'ELSE' || this.peekType() === 'ELSE_IF')) {
175
+ const tok = this.peek();
176
+ throw new ParserError(`Unexpected ${tok.value} after unless — 'unless' takes no else; write 'if' with the branches swapped`, tok.line, tok.col);
177
+ }
165
178
  const alternates = [];
166
179
  while (this.peekType() === 'ELSE_IF') {
167
180
  this.advance(); // consume ELSE_IF
@@ -169,6 +182,7 @@ class Parser {
169
182
  this.skip('NEWLINE');
170
183
  alternates.push({
171
184
  condition: altCondTok.value,
185
+ conditionLoc: this.valueLoc(altCondTok),
172
186
  body: this.parseBlock(),
173
187
  });
174
188
  }
@@ -181,6 +195,8 @@ class Parser {
181
195
  return {
182
196
  type: 'Conditional',
183
197
  condition: conditionTok.value,
198
+ conditionLoc: this.valueLoc(conditionTok),
199
+ ...(startTok.type === 'UNLESS' && { negated: true }),
184
200
  consequent,
185
201
  alternates,
186
202
  fallback,
@@ -192,10 +208,22 @@ class Parser {
192
208
  const paramsTok = this.expect('PARAMS');
193
209
  this.skip('NEWLINE');
194
210
  const body = this.parseBlock();
211
+ if (this.peekType() === 'ELSE_IF') {
212
+ const tok = this.peek();
213
+ throw new ParserError('Unexpected else if after for — only a plain else (rendered when the collection is empty) may follow a for', tok.line, tok.col);
214
+ }
215
+ let fallback = null;
216
+ if (this.peekType() === 'ELSE') {
217
+ this.advance(); // consume ELSE
218
+ this.skip('NEWLINE');
219
+ fallback = this.parseBlock();
220
+ }
195
221
  return {
196
222
  type: 'Iteration',
197
223
  params: paramsTok.value,
224
+ paramsLoc: this.valueLoc(paramsTok),
198
225
  body,
226
+ fallback,
199
227
  loc: this.loc(startTok),
200
228
  };
201
229
  }
@@ -210,6 +238,7 @@ class Parser {
210
238
  type: 'Template',
211
239
  name: nameTok.value,
212
240
  params: paramsTok.value,
241
+ paramsLoc: this.valueLoc(paramsTok),
213
242
  body,
214
243
  loc: this.loc(startTok),
215
244
  };
@@ -225,6 +254,7 @@ class Parser {
225
254
  type: 'MixinDefinition',
226
255
  name: nameTok.value,
227
256
  params: paramsTok.value,
257
+ paramsLoc: this.valueLoc(paramsTok),
228
258
  body,
229
259
  loc: this.loc(startTok),
230
260
  };
@@ -232,8 +262,11 @@ class Parser {
232
262
  parseMixinCall() {
233
263
  const startTok = this.expect('MIXIN_CALL');
234
264
  let args = '';
265
+ let argsLoc;
235
266
  if (this.peekType() === 'PARAMS') {
236
- args = this.advance().value;
267
+ const argsTok = this.advance();
268
+ args = argsTok.value;
269
+ argsLoc = this.valueLoc(argsTok);
237
270
  }
238
271
  const attrs = [];
239
272
  if (this.peekType() === 'ATTR_START') {
@@ -241,12 +274,38 @@ class Parser {
241
274
  attrs.push(...this.parseAttributes());
242
275
  this.expect('ATTR_END');
243
276
  }
277
+ // One line of call-site content on the call's own line, as for an
278
+ // element: '+btn("x") Save' or '+btn("x")= label'.
279
+ let inline = null;
280
+ if (this.peekType() === 'BUFFERED_CODE') {
281
+ const codeTok = this.advance();
282
+ inline = {
283
+ type: 'BufferedCode',
284
+ expression: codeTok.value,
285
+ expressionLoc: this.valueLoc(codeTok),
286
+ loc: this.loc(codeTok),
287
+ };
288
+ }
289
+ else if (this.peekType() === 'TEXT' || this.peekType() === 'INTERPOLATION') {
290
+ inline = this.parseTextSegments();
291
+ }
244
292
  this.skip('NEWLINE');
245
- const block = this.parseBlock();
293
+ // The content is a single callback, so it comes from one place or the
294
+ // other. An element can mix the two because its children are a list.
295
+ let block = this.parseBlock();
296
+ if (inline !== null) {
297
+ const content = block.find(n => n.type !== 'Comment' && n.type !== 'PassthroughComment');
298
+ if (content) {
299
+ throw new ParserError(`Mixin call '+${startTok.value}' has inline content, so it can't also have indented content; ` +
300
+ 'move the inline content onto its own indented line', content.loc.line, content.loc.col);
301
+ }
302
+ block = [inline, ...block];
303
+ }
246
304
  return {
247
305
  type: 'MixinCall',
248
306
  name: startTok.value,
249
307
  args,
308
+ ...(argsLoc ? { argsLoc } : {}),
250
309
  attrs,
251
310
  block,
252
311
  loc: this.loc(startTok),
@@ -286,6 +345,7 @@ class Parser {
286
345
  type: 'Extends',
287
346
  target: targetTok.value,
288
347
  args: argsTok.value,
348
+ argsLoc: this.valueLoc(argsTok),
289
349
  blocks,
290
350
  loc: this.loc(startTok),
291
351
  };
@@ -324,6 +384,7 @@ class Parser {
324
384
  children.push({
325
385
  type: 'BufferedCode',
326
386
  expression: codeTok.value,
387
+ expressionLoc: this.valueLoc(codeTok),
327
388
  loc: this.loc(codeTok),
328
389
  });
329
390
  }
@@ -392,8 +453,8 @@ class Parser {
392
453
  }
393
454
  throw new ParserError(`Attribute '${name}' has no value.` + hint, nameTok.line, nameTok.col);
394
455
  }
395
- const value = this.advance().value;
396
- attrs.push({ name: nameTok.value, value, spread: false });
456
+ const valueTok = this.advance();
457
+ attrs.push({ name: nameTok.value, value: valueTok.value, valueLoc: this.valueLoc(valueTok), spread: false });
397
458
  }
398
459
  else {
399
460
  const tok = this.peek();
@@ -412,11 +473,15 @@ class Parser {
412
473
  segments.push({ type: 'text', value: tok.value });
413
474
  }
414
475
  else {
415
- segments.push({ type: 'interpolation', expression: tok.value });
476
+ segments.push({ type: 'interpolation', expression: tok.value, loc: this.interpolationLoc(tok) });
416
477
  }
417
478
  }
418
479
  return { type: 'Text', segments, loc: this.loc(startTok) };
419
480
  }
481
+ // An INTERPOLATION token is anchored on its '#{'.
482
+ interpolationLoc(tok) {
483
+ return { line: tok.line, col: tok.col + 2 };
484
+ }
420
485
  parsePipe() {
421
486
  const startTok = this.expect('PIPE');
422
487
  const segments = [];
@@ -426,7 +491,7 @@ class Parser {
426
491
  segments.push({ type: 'text', value: tok.value });
427
492
  }
428
493
  else {
429
- segments.push({ type: 'interpolation', expression: tok.value });
494
+ segments.push({ type: 'interpolation', expression: tok.value, loc: this.interpolationLoc(tok) });
430
495
  }
431
496
  }
432
497
  this.skip('NEWLINE');
@@ -2,20 +2,24 @@ export interface SourceLocation {
2
2
  line: number;
3
3
  col: number;
4
4
  }
5
+ export type ValueLoc = SourceLocation[];
5
6
  export type TextSegment = {
6
7
  type: 'text';
7
8
  value: string;
8
9
  } | {
9
10
  type: 'interpolation';
10
11
  expression: string;
12
+ loc?: SourceLocation;
11
13
  };
12
14
  export interface AttributeNode {
13
15
  name: string;
14
16
  value: string | null;
17
+ valueLoc?: ValueLoc;
15
18
  spread: boolean;
16
19
  }
17
20
  export interface ConditionalBranch {
18
21
  condition: string;
22
+ conditionLoc?: ValueLoc;
19
23
  body: AstNode[];
20
24
  }
21
25
  export interface DocumentNode {
@@ -27,6 +31,7 @@ export interface TemplateNode {
27
31
  type: 'Template';
28
32
  name: string;
29
33
  params: string;
34
+ paramsLoc?: ValueLoc;
30
35
  body: AstNode[];
31
36
  loc: SourceLocation;
32
37
  }
@@ -34,6 +39,7 @@ export interface MixinDefinitionNode {
34
39
  type: 'MixinDefinition';
35
40
  name: string;
36
41
  params: string;
42
+ paramsLoc?: ValueLoc;
37
43
  body: AstNode[];
38
44
  loc: SourceLocation;
39
45
  }
@@ -41,6 +47,7 @@ export interface MixinCallNode {
41
47
  type: 'MixinCall';
42
48
  name: string;
43
49
  args: string;
50
+ argsLoc?: ValueLoc;
44
51
  attrs: AttributeNode[];
45
52
  block: AstNode[];
46
53
  loc: SourceLocation;
@@ -64,11 +71,14 @@ export interface TextNode {
64
71
  export interface BufferedCodeNode {
65
72
  type: 'BufferedCode';
66
73
  expression: string;
74
+ expressionLoc?: ValueLoc;
67
75
  loc: SourceLocation;
68
76
  }
69
77
  export interface ConditionalNode {
70
78
  type: 'Conditional';
71
79
  condition: string;
80
+ conditionLoc?: ValueLoc;
81
+ negated?: boolean;
72
82
  consequent: AstNode[];
73
83
  alternates: ConditionalBranch[];
74
84
  fallback: AstNode[] | null;
@@ -77,7 +87,9 @@ export interface ConditionalNode {
77
87
  export interface IterationNode {
78
88
  type: 'Iteration';
79
89
  params: string;
90
+ paramsLoc?: ValueLoc;
80
91
  body: AstNode[];
92
+ fallback: AstNode[] | null;
81
93
  loc: SourceLocation;
82
94
  }
83
95
  export interface BlockNode {
@@ -90,6 +102,7 @@ export interface ExtendsNode {
90
102
  type: 'Extends';
91
103
  target: string;
92
104
  args: string;
105
+ argsLoc?: ValueLoc;
93
106
  blocks: BlockNode[];
94
107
  loc: SourceLocation;
95
108
  }
@@ -1,4 +1,27 @@
1
- export type Attrs = Record<string, string | number | boolean | undefined>;
1
+ export type AttrValue = string | number | boolean | null | undefined;
2
+ /**
3
+ * A `class=` value: a string, an array of class values (nested, falsy entries
4
+ * dropped), or an object whose keys are kept when their value is truthy —
5
+ * `class=['btn', { active: isActive }]`.
6
+ */
7
+ export type ClassValue = AttrValue | readonly ClassValue[] | {
8
+ readonly [name: string]: unknown;
9
+ };
10
+ /**
11
+ * A `style=` value: a string, or an object of properties whose `null`,
12
+ * `undefined`, `false` and `""` values are dropped. camelCase keys become
13
+ * kebab-case (`backgroundColor` → `background-color`); `--custom` properties
14
+ * are kept as written.
15
+ */
16
+ export type StyleValue = string | null | undefined | false | StyleObject;
17
+ type StyleObject = {
18
+ readonly [property: string]: string | number | null | undefined | false;
19
+ };
20
+ export type Attrs = {
21
+ class?: ClassValue;
22
+ style?: StyleValue;
23
+ [name: string]: AttrValue | ClassValue | StyleValue;
24
+ };
2
25
  export declare const Fragment: unique symbol;
3
26
  declare const RAW: unique symbol;
4
27
  export declare class SafeHtml {
@@ -9,7 +32,28 @@ export declare class SafeHtml {
9
32
  }
10
33
  export declare function escape(val: string): string;
11
34
  export declare function raw(html: string): SafeHtml;
35
+ /**
36
+ * Merge shorthand classes (`.btn`) with an explicit `class=` value that is only
37
+ * known at render time. A value that means "absent" — undefined, null, a
38
+ * boolean, or "" — adds nothing, so the shorthand classes always survive.
39
+ * Arrays and objects are flattened as for `class=` itself.
40
+ */
41
+ export declare function mergeClass(prefix: string, value: ClassValue): string;
12
42
  export declare function doctype(): SafeHtml;
43
+ /**
44
+ * Tag for text with `#{}` interpolations, which compiles to
45
+ * interpolate`Hi ${name}!`. A plain template literal would stringify each
46
+ * value before the runtime saw it, so `raw()` and mixin output were escaped
47
+ * and `undefined` printed as "undefined". Each value renders exactly as an
48
+ * element child does instead, and the literal text is escaped.
49
+ */
50
+ export declare function interpolate(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml;
51
+ /**
52
+ * Render `fn` for each item of any iterable — an array, Set, Map, generator or
53
+ * iterator — or `otherwise()` when it yields nothing. This is what `for` and
54
+ * `for ... else` compile to. The iterable is consumed exactly once.
55
+ */
56
+ export declare function each<T>(items: Iterable<T>, fn: (item: T) => unknown, otherwise?: () => unknown): SafeHtml;
13
57
  export declare function h(tag: string | symbol, attrs: Attrs, ...children: unknown[]): SafeHtml;
14
58
  export {};
15
59
  //# sourceMappingURL=index.d.ts.map
@@ -28,6 +28,41 @@ export function escape(val) {
28
28
  export function raw(html) {
29
29
  return new SafeHtml(html);
30
30
  }
31
+ /**
32
+ * Flatten a class value to a space-separated list. A value that means
33
+ * "absent" — undefined, null, a boolean, or "" — yields "".
34
+ */
35
+ function classList(value) {
36
+ if (value == null || typeof value === 'boolean')
37
+ return '';
38
+ if (typeof value === 'string')
39
+ return value;
40
+ if (typeof value === 'number')
41
+ return String(value);
42
+ if (Array.isArray(value))
43
+ return value.map(classList).filter(c => c !== '').join(' ');
44
+ return Object.keys(value).filter(name => value[name]).join(' ');
45
+ }
46
+ function styleString(value) {
47
+ const decls = [];
48
+ for (const [prop, val] of Object.entries(value)) {
49
+ if (val == null || val === false || val === '')
50
+ continue;
51
+ const name = prop.startsWith('--') ? prop : prop.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
52
+ decls.push(`${name}:${val}`);
53
+ }
54
+ return decls.join(';');
55
+ }
56
+ /**
57
+ * Merge shorthand classes (`.btn`) with an explicit `class=` value that is only
58
+ * known at render time. A value that means "absent" — undefined, null, a
59
+ * boolean, or "" — adds nothing, so the shorthand classes always survive.
60
+ * Arrays and objects are flattened as for `class=` itself.
61
+ */
62
+ export function mergeClass(prefix, value) {
63
+ const classes = classList(value);
64
+ return classes === '' ? prefix : `${prefix} ${classes}`;
65
+ }
31
66
  export function doctype() {
32
67
  return new SafeHtml('<!DOCTYPE html>');
33
68
  }
@@ -54,10 +89,40 @@ function renderChildren(children) {
54
89
  }
55
90
  return out;
56
91
  }
92
+ /**
93
+ * Tag for text with `#{}` interpolations, which compiles to
94
+ * interpolate`Hi ${name}!`. A plain template literal would stringify each
95
+ * value before the runtime saw it, so `raw()` and mixin output were escaped
96
+ * and `undefined` printed as "undefined". Each value renders exactly as an
97
+ * element child does instead, and the literal text is escaped.
98
+ */
99
+ export function interpolate(strings, ...values) {
100
+ let out = escape(strings[0]);
101
+ for (let i = 0; i < values.length; i++) {
102
+ out += renderChildren([values[i]]) + escape(strings[i + 1]);
103
+ }
104
+ return new SafeHtml(out);
105
+ }
106
+ /**
107
+ * Render `fn` for each item of any iterable — an array, Set, Map, generator or
108
+ * iterator — or `otherwise()` when it yields nothing. This is what `for` and
109
+ * `for ... else` compile to. The iterable is consumed exactly once.
110
+ */
111
+ export function each(items, fn, otherwise) {
112
+ const rendered = Array.from(items, item => fn(item));
113
+ if (rendered.length === 0 && otherwise)
114
+ return new SafeHtml(renderChildren([otherwise()]));
115
+ return new SafeHtml(renderChildren(rendered));
116
+ }
57
117
  function renderAttrs(attrs) {
58
118
  let out = '';
59
- for (const [key, val] of Object.entries(attrs)) {
60
- if (val === undefined || val === false)
119
+ for (let [key, val] of Object.entries(attrs)) {
120
+ if (val !== null && typeof val === 'object' && (key === 'class' || key === 'style')) {
121
+ val = key === 'class' ? classList(val) : styleString(val);
122
+ if (val === '')
123
+ continue;
124
+ }
125
+ if (val == null || val === false)
61
126
  continue;
62
127
  if (val === true) {
63
128
  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.8.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": {
@@ -26,23 +27,40 @@
26
27
  },
27
28
  "scripts": {
28
29
  "build": "tsc",
29
- "test": "vitest run",
30
+ "test": "npm run lint && npm run typecheck && vitest run",
30
31
  "test:watch": "vitest",
31
- "lint": "tsc --noEmit",
32
+ "lint": "eslint .",
33
+ "typecheck": "tsc -p tsconfig.test.json",
32
34
  "prepublishOnly": "npm run build"
33
35
  },
34
36
  "engines": {
35
37
  "node": ">=20"
36
38
  },
39
+ "peerDependencies": {
40
+ "typescript": ">=5"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "typescript": {
44
+ "optional": true
45
+ }
46
+ },
37
47
  "devDependencies": {
48
+ "@eslint/js": "^10.0.1",
49
+ "@stylistic/eslint-plugin": "^5.10.0",
38
50
  "@types/node": "^22.0.0",
51
+ "eslint": "^10.13.0",
52
+ "globals": "^17.13.0",
39
53
  "typescript": "^5.9.3",
54
+ "typescript-eslint": "^8.72.0",
40
55
  "vitest": "^4.0.0"
41
56
  },
42
57
  "homepage": "https://weave.chilts.org",
43
58
  "repository": {
44
59
  "type": "git",
45
- "url": "ssh://git.chilts.me/srv/git/chilts/weave.git"
60
+ "url": "git+https://codeberg.org/chilts/weave.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://codeberg.org/chilts/weave/issues"
46
64
  },
47
65
  "author": "Andrew Chilton <andychilton@gmail.com>",
48
66
  "keywords": [