@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,303 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@chilts/weave` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ While the version is below `1.0.0`, breaking changes may land in minor releases.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.8.0] - 2026-10-22
12
+
13
+ ### Added
14
+
15
+ - **`unless`.** `unless cond` renders its block when `cond` is falsy,
16
+ compiling to the same ternary as `if !(cond)`. It is a guard only: an
17
+ `else` or `else if` after it is a compile error asking for an `if` with the
18
+ branches swapped. Previously `unless` was a compile error pointing at
19
+ `if !(cond)`.
20
+
21
+ - **Class arrays/objects and style objects.** `class=` accepts an array
22
+ (nested, with `null`/`undefined`/`false`/`""` dropped) or an object whose
23
+ truthy keys are kept, also when merged with shorthand classes:
24
+ `li.item(class=({ active: isActive }))` renders `class="item active"`.
25
+ `style=` accepts an object: `div(style=({ marginTop: 0 }))` renders
26
+ `style="margin-top:0"`. Previously these rendered `class="a,b"` or
27
+ `[object Object]`. The `Attrs` type now types `class` and `style` with the
28
+ new exported `ClassValue` and `StyleValue` types, and `mergeClass()` accepts
29
+ any `ClassValue`.
30
+
31
+ - **htmx example and docs.** A new `examples/htmx/` shows a page and the
32
+ fragment endpoints that reuse its mixin, and the README describes using
33
+ Weave for server-rendered apps.
34
+
35
+ - **`for ... else`.** An `else` after a `for` renders when the collection is
36
+ empty.
37
+
38
+ - **Mixin call-site content.** A bare `block` inside a mixin renders the
39
+ content indented under the call (`+card("Hi")` followed by indented lines),
40
+ with indented lines under `block` as its default. The mixin gains a
41
+ `block?: () => unknown` parameter after `attrs`; a call with content passes
42
+ `{}` for `attrs` when it has none. One line of content can also go on the
43
+ call's own line, as for an element: `+btn("save") Save` or
44
+ `+btn("save")(class="wide")= label`. A call can't have both inline and
45
+ indented content (a `ParserError`). Previously text after a mixin call was
46
+ dropped without a word, so the button rendered empty.
47
+
48
+ - **Type errors mapped back to `.weave`.** `mapDiagnostic()` takes a
49
+ diagnostic reported by `tsc` against a compiled `.ts` file and uses the
50
+ `.ts.map` written beside it to translate it to the `.weave` file, line and
51
+ column; `formatDiagnostic()` prints it like a `LexerError`, with a code
52
+ frame. An error inside a TS expression points at the exact token: in
53
+ `h1= user.nmae` it lands on `nmae`, not `h1`. This covers buffered code,
54
+ `#{}` interpolations, attribute values, `if`/`else if` conditions, `for`
55
+ headers, `template`/`mixin` params, and mixin call and `extends` args,
56
+ including every line of a multi-line param list. An error in code weave
57
+ generated around them points at the start of the node.
58
+ `decodeMappings()`, `originalPosition()` and
59
+ `formatCodeFrame()` are exported too. The API takes plain
60
+ `{ file, line, col, message }` objects, so it does not depend on the
61
+ `typescript` package.
62
+
63
+ - **`weave check`.** `weave check views/ lib/views/` compiles the templates,
64
+ type-checks the whole project against its `tsconfig.json` (or
65
+ `--project <path>`), and prints every error — those in a compiled view at
66
+ their `.weave` file, line and column with a code frame, the rest as-is —
67
+ followed by `Found N errors.` It exits 1 on any compile or type error, so it
68
+ can stand in for `tsc --noEmit` in CI. It loads the project's own
69
+ `typescript`, now an optional peer dependency. `--watch` is not supported
70
+ yet.
71
+
72
+ ### Changed
73
+
74
+ - **Layouts declare their own `<Name>Blocks` type.** The compiler now emits
75
+ an exported `<Name>Blocks` interface (one optional `() => unknown` member
76
+ per `block`) above each template with slots, instead of splicing the name
77
+ into the first `import type` and requiring you to write the interface.
78
+ This works with no type import and with several layouts in one file.
79
+ **Breaking:** delete any hand-written `<Name>Blocks` interface, or `tsc`
80
+ reports a duplicate identifier.
81
+
82
+ - `for` loops now compile to the new `each()` runtime helper instead of
83
+ `.map()`, so compiled templates import `each` from `@chilts/weave`.
84
+
85
+ - Text with `#{}` interpolation now compiles to the new `interpolate` tagged
86
+ template (``h("h1", {}, interpolate`Hello ${name}`)``), so compiled
87
+ templates import `interpolate` from `@chilts/weave`. An interpolated `null`
88
+ or `undefined` now renders nothing, the same as `p= value`, where it used
89
+ to print `null` or `undefined`.
90
+
91
+ ### Fixed
92
+
93
+ - `raw()` and mixin output inside `#{}` were escaped: `p Hi #{raw(html)}`
94
+ rendered `Hi &lt;b&gt;...` and `p By #{userLink(author, {})}` showed the
95
+ link's markup as text. Interpolated values now render exactly as element
96
+ children do, so both come out as HTML.
97
+ - A bare `block` inside a mixin compiled to invalid TypeScript
98
+ (`blocks.?.()`) and content indented under a mixin call was silently
99
+ dropped. A bare `block` outside a mixin, or a named block inside one, is now
100
+ a `CodegenError`.
101
+ - `for` over a non-array iterable (`xs.entries()`, `Set`, `Map`, a generator)
102
+ rendered `[object Iterator Helper]` or failed to type-check; any iterable
103
+ now works.
104
+ - The `Attrs` type now accepts `null`, so `a(href=x)` with `x: string | null`
105
+ type-checks; the runtime already omitted `null` attributes.
106
+ - Templates return `SafeHtml`, not a string, despite 0.4.0's note that they
107
+ return strings directly: only doctype-led templates ever did, by accident,
108
+ and 0.5.0 ended that. `SafeHtml` is now the documented contract for templates
109
+ and mixins alike, so nested output is never escaped twice; call `String()` on
110
+ it for the HTML. The README's Express example passed the result straight to
111
+ `res.send()`, which sends it as JSON; it now calls `String()` first.
112
+ - A mixin that never uses `attrs` compiled to an unused `attrs` parameter,
113
+ failing `tsc` under `noUnusedParameters`; it is now named `_attrs`. The
114
+ parameter stays, so call sites may still pass extra attributes.
115
+ - A template with a single root element indented that element's children one
116
+ level too deep in the compiled `.ts`. Cosmetic only; the output is unchanged.
117
+ - Pug syntax that Weave doesn't support no longer mis-parses silently; each
118
+ form is now a `LexerError` that names the Weave alternative. `p!= x` used to
119
+ drop `x`; block expansion (`li: a Home`) and anything else run onto an
120
+ element (`a(href="/")Home`, `br/`) dropped the rest of the line; `p.` emitted
121
+ `class=""` and turned its text into elements; `- const x = 1` became a `-`
122
+ element; and `case`, `when`, `default`, `each`, `while`, `unless`, `append`
123
+ and `prepend` became elements of those names. Tag interpolation
124
+ (`p Hello #[strong world]`) and unescaped interpolation (`p Hi !{html}`)
125
+ rendered their markup characters as text; `!{html}` now points at
126
+ `#{raw(html)}`. Empty `.` / `#` shorthands and tag names that don't start
127
+ with a letter are rejected too.
128
+ - `block` took the rest of its line as the slot name, so Pug's
129
+ `block append scripts` compiled to invalid TypeScript
130
+ (`blocks.append scripts?.()`). A block name must now be a single identifier,
131
+ or it is a `LexerError`; `block append` / `block prepend` (and bare
132
+ `append` / `prepend`) point at adding a second, empty slot to the layout
133
+ (`block pageScripts` after `block scripts`) and filling that instead.
134
+
135
+ ## [0.7.0] - 2026-10-20
136
+
137
+ ### Added
138
+
139
+ - **Escaped dots in class and id shorthand.** `.gap-1\.5` is the single class
140
+ `gap-1.5`, so Tailwind's fractional spacing classes work as shorthand.
141
+ - **Multi-line parameter lists.** The parentheses after `template`, `mixin`,
142
+ `extends`, `for` and a mixin call may now wrap over several lines, staying
143
+ open until they balance, so a long destructured signature can be written
144
+ the way TypeScript is normally wrapped. Continuation lines must be indented
145
+ under the opening line (a closing line such as `}: Props)` may sit level with
146
+ it). A string inside must close on its own line, and `//` comments are
147
+ dropped from the output. The compiled code keeps the wrapped layout, adding
148
+ `attrs`/`blocks` on a line of their own when the `)` was on its own line.
149
+ - **Emacs mode understands context.** `weave-mode` now follows multi-line
150
+ attribute and parameter lists and raw (`tag!`) blocks when highlighting and
151
+ indenting, highlights attribute names, aligns `else` with its `if`, and
152
+ cycles outwards on repeated TAB. An apostrophe in text no longer opens a
153
+ string, `//` in a URL is no longer a comment, and `.`/`#` are only coloured
154
+ as a class or id where they really are one.
155
+
156
+ ### Fixed
157
+
158
+ - A dotted class such as `.gap-1.5` was silently split into `gap-1` plus a
159
+ stray class `5`. A shorthand class that is a bare number is now a
160
+ `LexerError` that names the split and suggests escaping the dot.
161
+ - `include ./partials` imported a binding named after the file (`partials`)
162
+ instead of the mixins the template calls, so a file defining several
163
+ mixins, or one not named after its file, compiled to code that failed in
164
+ `tsc`. The compiler now reads the included file and imports only the mixins
165
+ called (`import { badge, chip } from './partials.js'`). Calling a mixin that
166
+ no include or local definition provides is a `CodegenError`. For API users,
167
+ `compile()`, `compileWithSourceMap()` and `generate()` take an optional
168
+ `resolveInclude`. Without it, a lone include provides every called mixin
169
+ that isn't defined locally, and several includes are an error.
170
+ - Shorthand classes combined with a non-literal `class=` value leaked the
171
+ value's string form: `div.pt-4(class=undefined)` rendered
172
+ `class="pt-4 undefined"`, as did the usual conditional-class pattern
173
+ `class=(on ? "x" : undefined)` whenever it was off. Non-literal values are
174
+ now merged at render time by a new `mergeClass()` runtime helper, which
175
+ drops `undefined`, `null`, `false` and `""`. An attribute whose value is
176
+ `null` is now omitted instead of rendering as `"null"`.
177
+ - A template literal with a `${...}` interpolation inside a mixin call's
178
+ arguments, or a `template`/`mixin` parameter list, failed with
179
+ "Unterminated parentheses". A string containing a brace inside the
180
+ interpolation is also handled.
181
+ - Error locations and source maps now point at the start of a token rather
182
+ than just past its end — for example, a mixin call is located at its `+`
183
+ instead of its argument list.
184
+ - In watch mode, changing an included file now recompiles the templates that
185
+ include it, so a newly added mixin is picked up without touching each page.
186
+ The start-up reconcile likewise recompiles a template whose include is
187
+ newer than its output.
188
+ - Rebuilding with `--no-sourcemap` now removes a `.ts.map` left over from an
189
+ earlier build instead of leaving a map that no longer matches its `.ts`.
190
+
191
+ ## [0.6.0] - 2026-10-19
192
+
193
+ ### Added
194
+
195
+ - **Multi-line attribute lists.** End a line with the opening `(` to spread an
196
+ element's attributes over several lines — one attribute per line, indented
197
+ under the element, closed by a `)` on a line of its own. Works for mixin call
198
+ attributes too. Anything after the closing `)` is read as trailing content.
199
+ Attribute values follow exactly the same rules as the inline form.
200
+
201
+ ### Changed
202
+
203
+ - **Breaking: every attribute must have a value.** The Pug-style valueless
204
+ boolean shorthand has been removed — `input(disabled)` no longer compiles.
205
+ Boolean attributes are now written with an explicit boolean value:
206
+ `disabled=true`, `disabled=false`, or an expression `disabled=isActive`
207
+ (the runtime renders a bare attribute for `true` and omits it for `false`
208
+ or `undefined`). Do not use a quoted `disabled="false"` — in HTML any
209
+ attribute that is present counts as true.
210
+ - A bare (unquoted) attribute value must be a single token. Multi-token
211
+ expressions such as a ternary or string concatenation must be wrapped in
212
+ parentheses (`class=(wide ? "a" : "b")`) or backticks
213
+ (``class=`${wide ? "a" : "b"}` ``), because a space separates one
214
+ attribute from the next.
215
+
216
+ ### Fixed
217
+
218
+ - The lexer no longer hangs and aborts the process (SIGABRT) on a multi-token
219
+ bare attribute value; it now raises a clear `LexerError` with line and
220
+ column, pointing you to wrap the expression.
221
+ - `main(class=a b)` is rejected with a message offering both readings
222
+ (`class="a b"` as one value, or `class=a b="..."` as two attributes)
223
+ instead of silently parsing as `class="a"` plus a boolean `b`.
224
+ - An attribute list that ran off the end of its line was accepted as if it had
225
+ been closed, so the following lines were lexed as child elements — attribute
226
+ names became tags and their values rendered as visible text, and the stray
227
+ `)` could leak into the generated TypeScript. It is now a `LexerError`
228
+ anchored on the opening `(`.
229
+ - Watch mode reconciles the output tree on start so edits made while the
230
+ watcher was down are not missed.
231
+ - Watch mode on Linux stopped seeing a template after the first time an editor
232
+ saved it atomically (write a temp file, rename it over the original), so
233
+ later edits were never recompiled. Each directory now has its own watcher,
234
+ which survives atomic saves, and subdirectories created while watching are
235
+ picked up — including any templates already written into them.
236
+ - Deleting a `.weave` template now removes its compiled `.ts` from the output
237
+ directory (in one-shot builds and in watch mode). The output directory is
238
+ treated as owned by Weave: any `.ts` with no matching `.weave` is removed.
239
+ - A mixin, or a block's default content, with more than one root element
240
+ referenced `Fragment` without importing it, so the compiled file failed to
241
+ type-check. `Fragment` is now imported whenever it is used.
242
+
243
+ ### Removed
244
+
245
+ - Source maps are no longer included in the published npm bundle.
246
+
247
+ ## [0.5.0] - 2026-06-11
248
+
249
+ ### Fixed
250
+
251
+ - Multi-level `extends` and `doctype` now generate type-safe output.
252
+
253
+ ## [0.4.0] - 2026-04-05
254
+
255
+ ### Changed
256
+
257
+ - Compiled templates return strings directly instead of arrays, simplifying
258
+ the generated output and how templates compose.
259
+
260
+ ## [0.3.0] - 2026-04-05
261
+
262
+ ### Added
263
+
264
+ - Raw block syntax (`tag!`) for passing literal, unescaped content through to
265
+ the output (inline CSS, JavaScript, etc.).
266
+ - Emacs major mode with syntax highlighting, indentation, and comment support.
267
+
268
+ ### Documentation
269
+
270
+ - Added guidance on special characters and the single-pass escaping model to
271
+ the README.
272
+
273
+ ## [0.2.0] - 2026-04-05
274
+
275
+ ### Added
276
+
277
+ - A bare `|` (pipe) on its own line emits a single space, for adding
278
+ whitespace between sibling elements.
279
+
280
+ ## [0.1.0] - 2026-04-05
281
+
282
+ Initial release — the Weave compiler and runtime.
283
+
284
+ ### Added
285
+
286
+ - Template syntax compiling `.weave` files to typed TypeScript functions:
287
+ templates, mixins, `extends`/`block` layouts, conditionals, iteration,
288
+ includes, interpolation, buffered expressions, and silent/pass-through
289
+ comments.
290
+ - Runtime helpers: `h`, `doctype`, `Fragment`, `raw`, and single-pass escaping.
291
+ - CLI `compile` command and a `--watch` mode for automatic recompilation.
292
+ - Error messages with source code frames.
293
+ - Source map generation.
294
+
295
+ [Unreleased]: https://codeberg.org/chilts/weave/compare/v0.8.0...HEAD
296
+ [0.8.0]: https://codeberg.org/chilts/weave/compare/v0.7.0...v0.8.0
297
+ [0.7.0]: https://codeberg.org/chilts/weave/compare/v0.6.0...v0.7.0
298
+ [0.6.0]: https://codeberg.org/chilts/weave/compare/v0.5.0...v0.6.0
299
+ [0.5.0]: https://codeberg.org/chilts/weave/compare/v0.4.0...v0.5.0
300
+ [0.4.0]: https://codeberg.org/chilts/weave/compare/v0.3.0...v0.4.0
301
+ [0.3.0]: https://codeberg.org/chilts/weave/compare/v0.2.0...v0.3.0
302
+ [0.2.0]: https://codeberg.org/chilts/weave/compare/v0.1.0...v0.2.0
303
+ [0.1.0]: https://codeberg.org/chilts/weave/src/tag/v0.1.0
package/ReadMe.md CHANGED
@@ -12,7 +12,7 @@ Pug is great. Weave takes the same indentation-based syntax and makes it TypeScr
12
12
 
13
13
  - **Type-safe templates** — template parameters are real TypeScript types
14
14
  - **Type-safe mixins** — positional args with TS type annotations
15
- - **Tailwind-friendly** — class names with colons (`.md:flex`, `.hover:bg-blue-500`) just work
15
+ - **Tailwind-friendly** — class names with colons (`.md:flex`, `.hover:bg-blue-500`) just work, and dots escape (`.gap-1\.5`)
16
16
  - **Single-pass escaping** — values are escaped once at render time, never pre-escaped
17
17
  - **No runtime template engine** — compiled output is plain TypeScript
18
18
 
@@ -66,9 +66,40 @@ export function simple({ title, content }: SimpleProps) {
66
66
 
67
67
  Now `tsc` catches type errors, your IDE gives you autocomplete, and calling `simple({ title: "Hello", content: "World" })` returns HTML.
68
68
 
69
+ ### Type-checking templates
70
+
71
+ `tsc` reports errors against the compiled `.ts`, which you never edit.
72
+ `weave check` compiles the templates, type-checks your whole project with your
73
+ `tsconfig.json`, and reports each error in a view at its `.weave` line:
74
+
75
+ ```
76
+ $ npx weave check views/ lib/views/
77
+ weave: views/page.weave:3:5: Property 'nmae' does not exist on type '{ name: string; }'. TS2339
78
+ 3 | h1= user.nmae
79
+ | ^
80
+
81
+ Found 1 error.
82
+ ```
83
+
84
+ It exits 1 on any error, so it can replace `tsc --noEmit` in CI, and the
85
+ `file:line:col:` format works with Emacs `compile`. It uses your project's
86
+ `typescript` package (install it as a dev dependency) and the nearest
87
+ `tsconfig.json`, or the one given with `--project <path>`. Errors point at the
88
+ exact token inside an expression, attribute value, condition, parameter or
89
+ argument, and at the start of the node for code Weave generated around them.
90
+
91
+ To map diagnostics yourself (from another tool or build step), use
92
+ `mapDiagnostic({ file, line, col, message })`, which translates a position in a
93
+ compiled `.ts` file to its `.weave` source via the `.ts.map` beside it, and
94
+ `formatDiagnostic()`, which prints the result with a code frame. Neither
95
+ depends on the `typescript` package.
96
+
69
97
  ## Usage
70
98
 
71
- Templates are just functions that return strings, so they work with any framework:
99
+ Templates are just functions that return HTML, so they work with any framework.
100
+ Every template and mixin returns a `SafeHtml`, so its output isn't escaped
101
+ again when nested in another template. Call `String()` on it to get the HTML
102
+ string:
72
103
 
73
104
  ```typescript
74
105
  import express from 'express'
@@ -77,12 +108,56 @@ import { simple } from './lib/views/simple.js'
77
108
  const app = express()
78
109
 
79
110
  app.get('/', (req, res) => {
80
- res.send(simple({ title: "Hello", content: "World" }))
111
+ res.send(String(simple({ title: "Hello", content: "World" })))
81
112
  })
82
113
 
83
114
  app.listen(3000)
84
115
  ```
85
116
 
117
+ ### Server-rendered apps and htmx
118
+
119
+ Weave is a good fit for apps that render HTML on the server and swap pieces of
120
+ it into the page — [htmx](https://htmx.org), Turbo, Unpoly, or plain `fetch`.
121
+
122
+ - **Every template and mixin is an exported function.** Rendering a fragment
123
+ for a swap is just calling the mixin; there is no partial-rendering API.
124
+ - **The page and its fragments share one definition.** The page loops over
125
+ `+todoItem(todo)`, and the endpoint returns `todoItem(todo)`, so the markup
126
+ can't drift apart.
127
+ - **No runtime engine, zero dependencies.** The runtime is a few small helpers
128
+ with no Node-specific imports, so compiled views run on Node, Deno, Bun and
129
+ Cloudflare Workers alike.
130
+
131
+ A mixin for one row:
132
+
133
+ ```
134
+ mixin todoItem(todo: Todo)
135
+ li.todo(id=`todo-${todo.id}` ...attrs)
136
+ input(
137
+ type="checkbox"
138
+ checked=todo.done
139
+ hx-post=`/todos/${todo.id}/toggle`
140
+ hx-target=`#todo-${todo.id}`
141
+ hx-swap="outerHTML"
142
+ )
143
+ span= todo.text
144
+ ```
145
+
146
+ The page uses it in a loop, and the toggle endpoint returns just that row:
147
+
148
+ ```typescript
149
+ import { todoItem } from './lib/views/components/todo.js'
150
+
151
+ // POST /todos/:id/toggle
152
+ todo.done = !todo.done
153
+ return new Response(String(todoItem(todo)), {
154
+ headers: { 'content-type': 'text/html; charset=utf-8' },
155
+ })
156
+ ```
157
+
158
+ See [`examples/htmx/`](examples/htmx/) for the full page, the mixin, and a
159
+ web-standard `fetch` handler serving both.
160
+
86
161
  ## Syntax
87
162
 
88
163
  Weave's syntax will be familiar if you've used Pug.
@@ -97,6 +172,14 @@ p.intro This is a paragraph with a class
97
172
  a(href="/about" class="nav-link") About
98
173
  ```
99
174
 
175
+ A `.` always starts a new class, so a class name that contains a dot — like Tailwind's `gap-1.5` — must escape it with a backslash:
176
+
177
+ ```pug
178
+ .flex.gap-1\.5.px-2\.5
179
+ ```
180
+
181
+ An unescaped `.gap-1.5` would split into `gap-1` and a stray `5`, so a class that is a bare number is rejected with an error pointing at the fix.
182
+
100
183
  ### Attributes
101
184
 
102
185
  Attributes live inside parentheses and are separated by spaces. **Every attribute must have a value** — there is no valueless shorthand:
@@ -115,7 +198,7 @@ button(class=`btn-${variant}`)= label
115
198
 
116
199
  #### Boolean attributes
117
200
 
118
- Write the boolean explicitly. The runtime renders `true` as a bare attribute and omits the attribute entirely when the value is `false` or `undefined`:
201
+ Write the boolean explicitly. The runtime renders `true` as a bare attribute and omits the attribute entirely when the value is `false`, `undefined` or `null`:
119
202
 
120
203
  ```
121
204
  input(type="checkbox" checked=true) → <input type="checkbox" checked>
@@ -125,6 +208,34 @@ input(type="checkbox" checked=isActive) → omitted when isActive is false
125
208
 
126
209
  Avoid a quoted `checked="false"` — in HTML any attribute that is present counts as true, so that renders as checked.
127
210
 
211
+ #### Combining shorthand classes with `class=`
212
+
213
+ Shorthand classes and a `class=` value are merged into one attribute. The shorthand classes are always kept; a value of `undefined`, `null`, `false` or `""` just adds nothing:
214
+
215
+ ```
216
+ div.pt-4(class="mt-2") → <div class="pt-4 mt-2">
217
+ div.pt-4(class=undefined) → <div class="pt-4">
218
+ div.btn(class=(active ? "active" : undefined)) → <div class="btn"> when active is false
219
+ ```
220
+
221
+ #### Class arrays and objects, style objects
222
+
223
+ `class=` also takes an array or an object, which suits conditional Tailwind classes. Arrays may nest and drop `null`, `undefined`, `false` and `""`; an object keeps each key whose value is truthy. Both merge with shorthand classes:
224
+
225
+ ```
226
+ div(class=(["a", "b"])) → <div class="a b">
227
+ li.item(class=({ active: isActive })) → <li class="item active"> when isActive is true
228
+ a(class=(["link", { "md:flex": wide }])) → <a class="link md:flex"> when wide is true
229
+ ```
230
+
231
+ `style=` takes an object of properties. camelCase keys become kebab-case, `--custom` properties are kept as written, and `null`, `undefined`, `false` and `""` values are dropped:
232
+
233
+ ```
234
+ div(style=({ marginTop: 0, "--gap": gap })) → <div style="margin-top:0;--gap:1rem">
235
+ ```
236
+
237
+ An array or object that yields nothing omits the attribute entirely. Numbers are not given units, so write `"4px"` rather than `4` where CSS needs one.
238
+
128
239
  #### Multi-token values must be wrapped
129
240
 
130
241
  Because a space separates one attribute from the next, a bare (unquoted) value has to be a single token. Wrap anything longer in parentheses or backticks:
@@ -188,14 +299,28 @@ else
188
299
  p Welcome, visitor
189
300
  ```
190
301
 
302
+ `unless cond` is a guard: its block renders when `cond` is falsy, the same as
303
+ `if !(cond)`. It takes no `else` or `else if` — `unless … else` is a double
304
+ negative, so write an `if` with the branches swapped instead:
305
+
306
+ ```
307
+ unless user.verified
308
+ p Please confirm your email address.
309
+ ```
310
+
191
311
  ### Iteration
192
312
 
193
313
  ```
194
314
  for ( const post of posts )
195
315
  h2= post.title
196
316
  p= post.content
317
+ else
318
+ p No posts yet.
197
319
  ```
198
320
 
321
+ The optional `else` renders when the collection is empty. Any iterable works —
322
+ arrays, `Set`, `Map`, generators, or `xs.entries()` — and it is consumed once.
323
+
199
324
  ### Mixins
200
325
 
201
326
  Mixins use positional args. An implicit `attrs` parameter captures any extra attributes passed at the call site.
@@ -215,6 +340,42 @@ Usage:
215
340
  +button("Delete", "secondary")(class="ml-4" data-confirm="true")
216
341
  ```
217
342
 
343
+ #### Call-site content
344
+
345
+ A bare `block` inside a mixin renders whatever is indented under the call,
346
+ so one mixin can wrap different content each time. Lines indented under
347
+ `block` are the default when the call has none, and `if block` tests for it:
348
+
349
+ ```
350
+ mixin card(title: string)
351
+ .card(...attrs)
352
+ h2= title
353
+ block
354
+ p.muted Nothing to show.
355
+ ```
356
+
357
+ ```
358
+ +card("Profile")(class="wide")
359
+ p= user.name
360
+ p= user.email
361
+ +card("Recent activity")
362
+ ```
363
+
364
+ The content is passed as a callback after `attrs`, so the call above compiles
365
+ to `card("Profile", { class: "wide" }, () => ...)`. Named blocks (`block
366
+ content`) are layout slots and are not allowed inside a mixin. See
367
+ [`examples/mixin-block/`](examples/mixin-block/).
368
+
369
+ One line of content can go on the call's own line instead, just as an
370
+ element takes inline text or `= expr`:
371
+
372
+ ```
373
+ +btn("save") Save
374
+ +btn("save")(class="wide")= label
375
+ ```
376
+
377
+ A call takes its content inline or indented, not both.
378
+
218
379
  ### Layouts with extends and blocks
219
380
 
220
381
  A layout defines named blocks as content slots:
@@ -232,6 +393,17 @@ template layout({ title }: LayoutProps)
232
393
  p © 2024
233
394
  ```
234
395
 
396
+ The compiled layout takes its slots as a second `blocks` argument, typed by an
397
+ interface the compiler generates from the `block` names — you don't write it:
398
+
399
+ ```ts
400
+ export interface LayoutBlocks {
401
+ content?: () => unknown
402
+ }
403
+
404
+ export function layout({ title }: LayoutProps, blocks: LayoutBlocks = {}) {
405
+ ```
406
+
235
407
  A page extends the layout, filling in the blocks. Data is passed explicitly — nothing is implicitly inherited:
236
408
 
237
409
  ```
@@ -244,6 +416,32 @@ template home({ title, welcomeMessage }: HomeProps)
244
416
 
245
417
  Blocks can have default content in the layout. If a page doesn't override a block, the default renders.
246
418
 
419
+ ### Long parameter lists
420
+
421
+ The parentheses after `template`, `mixin`, `extends`, `for` and a mixin call hold
422
+ TypeScript, which Weave copies through untouched. They can wrap over several lines
423
+ however TypeScript usually wraps: the group simply stays open until its parentheses
424
+ balance.
425
+
426
+ ```
427
+ template dashboard({
428
+ title,
429
+ user,
430
+ }: DashboardProps)
431
+ h1= title
432
+
433
+ +card(
434
+ post.title,
435
+ post.summary,
436
+ )(class="mt-4")
437
+ ```
438
+
439
+ Unlike a long attribute list there is no required shape, but continuation lines must
440
+ be indented under the line that opens the group. The closing line may sit level with
441
+ it if it starts with a closing bracket, as `}: DashboardProps)` does. So a forgotten
442
+ `)` is reported where the indentation drops back. A string must close on the line it
443
+ starts on, and `//` comments are allowed but left out of the compiled output.
444
+
247
445
  ### Includes
248
446
 
249
447
  Include another `.weave` file to use its mixins:
@@ -258,6 +456,12 @@ template actionsPage({ title }: ActionsPageProps)
258
456
  +button("Save", "primary")
259
457
  ```
260
458
 
459
+ An included file can define any number of mixins. Weave reads it and imports
460
+ only the ones the template calls, so `include ./partials` followed by
461
+ `+badge(...)` and `+chip(...)` compiles to
462
+ `import { badge, chip } from './partials.js'`. Calling a mixin that is neither
463
+ defined in the file nor provided by an include is a compile error.
464
+
261
465
  ### Text content
262
466
 
263
467
  Use `|` (pipe) to add literal text as a child of an element:
@@ -280,7 +484,7 @@ This renders as: `Hello <strong>user@example.com</strong>.` — with a space bet
280
484
 
281
485
  ### Inline elements
282
486
 
283
- Weave does not support inline element syntax (e.g. Pug's `#[strong bold]` tag interpolation). To mix elements within text, place each element on its own indented line and use `|` for spacing and text fragments.
487
+ Weave does not support inline element syntax (e.g. Pug's `#[strong bold]` tag interpolation), and `#[` in text is a compile error. To mix elements within text, place each element on its own indented line and use `|` for spacing and text fragments. To show a literal `#[` (or `!{`), use a string expression: `p= "Use #[0] for the first item"`.
284
488
 
285
489
  ### Raw blocks
286
490
 
@@ -319,17 +523,65 @@ Standard TypeScript imports pass through to the compiled output:
319
523
  import type { BlogPost } from '../types/models.js'
320
524
  ```
321
525
 
526
+ ## Coming from Pug
527
+
528
+ Most Pug templates read the same in Weave, but some Pug features are left out
529
+ on purpose. Each one below is a compile error that names the Weave way to
530
+ write it, rather than being silently mis-rendered.
531
+
532
+ | Pug | Weave |
533
+ |---|---|
534
+ | `- const x = 1` (unbuffered code) | Templates hold no statements. Compute values in TypeScript before rendering, or use `tag= expr` |
535
+ | `case` / `when` / `default` | `if` / `else if` / `else` |
536
+ | `each x in xs`, `while` | `for ( const x of xs )`; for keys and values, `for ( const [k, v] of Object.entries(obj) )` |
537
+ | `p!= html` | `p= raw(html)` |
538
+ | `p Hi !{html}` | `p Hi #{raw(html)}` |
539
+ | `a(href!=url)` | Attribute values are always escaped |
540
+ | `p.` (block text) | `p!` for raw text, or `\|` lines for escaped text |
541
+ | `li: a Home` (block expansion) | Put the child on its own indented line |
542
+ | `#[strong bold]` (tag interpolation) | Put each element on its own line; see [Inline elements](#inline-elements) |
543
+ | `foo/` (explicit self-closing) | Void elements like `br` and `img` close themselves |
544
+ | `div&attributes(obj)` | `div(...obj)` |
545
+ | `input(disabled)` | `input(disabled=true)`; see [Boolean attributes](#boolean-attributes) |
546
+ | `extends ./layout` | `extends layout({ data })` calls the layout's template function and passes its data |
547
+
548
+ **Filters** (`:markdown-it`, `include:markdown ./post.md`) are not supported
549
+ and are not planned. Render the content in TypeScript and pass it in as a
550
+ `raw()` value.
551
+
552
+ **`block append` / `block prepend`** are not supported. A page replaces a
553
+ layout's block completely. When pages need to add to what the layout already
554
+ renders, give the layout a second, empty slot:
555
+
556
+ ```
557
+ head
558
+ block scripts
559
+ script(src="/app.js")
560
+ block pageScripts
561
+ ```
562
+
563
+ **Dynamic mixin calls** (`+#{name}()`) are not supported. Mixins are ordinary
564
+ functions, so you can choose one at runtime with a buffered expression and
565
+ pass `{}` for its `attrs`: `div= mixins[kind](item, {})`.
566
+
567
+ **Implicit scope.** In Pug, included and extended templates can see their
568
+ caller's variables. In Weave, data only travels explicitly: through template
569
+ params, `extends layout({ ... })` and mixin args.
570
+
322
571
  ## Development
323
572
 
324
573
  ```bash
325
574
  npm run build # compile TypeScript to dist/
326
- npm test # run tests (vitest)
327
- npm run lint # type-check without emitting (tsc --noEmit)
575
+ npm test # lint, then type-check, then run tests (vitest)
576
+ npm run lint # lint with ESLint
577
+ npm run typecheck # type-check without emitting (tsc --noEmit)
328
578
  ```
329
579
 
330
580
  ## Status
331
581
 
332
- Weave is in early design. The `examples/` directory contains paired `.weave` and `.ts` files that define the target syntax and expected compiler output. The compiler is under active development.
582
+ Weave is usable today: the compiler, the `weave` CLI (with watch mode and source maps) and the runtime helpers are published on npm. It is still pre-1.0, so the syntax may change in a minor release — breaking changes are called out in [CHANGELOG.md](CHANGELOG.md).
583
+
584
+ The `examples/` directory pairs each `.weave` template with its compiled `.ts`. Every example is compiled in the test suite and compared byte-for-byte against that file, so they double as a reference for what the compiler emits.
333
585
 
334
586
  ## Editor Support
335
587
 
@@ -341,6 +593,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full details of each release.
341
593
 
342
594
  | Version | Date | Highlights |
343
595
  |---------|------------|------------|
596
+ | 0.7.0 | 2026-10-20 | Multi-line parameter lists; escaped dots in class shorthand; `include` imports the mixins it provides; context-aware Emacs mode |
344
597
  | 0.6.0 | 2026-10-19 | Multi-line attribute lists; every attribute requires a value (breaking); watch-mode fixes |
345
598
  | 0.5.0 | 2026-06-11 | Type-safe multi-level `extends` and `doctype` |
346
599
  | 0.4.0 | 2026-04-05 | Templates return strings directly |