@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.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.
- package/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- package/types/statics.d.ts +25 -0
package/README.md
CHANGED
|
@@ -7,19 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
**[darkmown.com](https://darkmown.com)** · Markdown that runs.
|
|
9
9
|
|
|
10
|
-
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~
|
|
10
|
+
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~6.6 KB gzipped, CI-enforced under 8 KB.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Five complete apps, each a readable `.wd` file — see them at **[darkmown.com/showcase](https://darkmown.com/showcase/)**:
|
|
15
|
-
|
|
16
|
-
- **[Folio](https://darkmown.com/folio/)** — a boutique storefront whose cart persists across pages, reloads, and browser tabs (`:store`), with live search and a checkout form.
|
|
17
|
-
- **[Pulse](https://darkmown.com/pulse/)** — a service dashboard driven by `:fetch`: loading / error / empty states, live refresh (`:every`), and status badges, with no hand-written JavaScript.
|
|
18
|
-
- **[Forge](https://darkmown.com/forge/)** — a plan configurator where `:computed` recomputes the price as you toggle features and seats, with conditional upsell hints.
|
|
19
|
-
- **[Compass](https://darkmown.com/compass/)** — a product-finder quiz: a branching state machine expressed entirely as `:if` steps over scored answers.
|
|
20
|
-
- **[Ledger](https://darkmown.com/ledger/)** — a spreadsheet-grade expense table: clickable-header reactive sort, a live filter, running totals via `sum`/`avg`/`max`, `| money`/`| date` format pipes, and a `:theme` toggle.
|
|
21
|
-
|
|
22
|
-
Plus focused feature demos: a draggable, keyboard-navigable **[Swiper](https://darkmown.com/carousel/)** (the `wd.subscribe` escape hatch) and a zero-JS **[Media](https://darkmown.com/media/)** page (`:video` / `:audio` / `:embed`).
|
|
12
|
+
**Who it is for.** Content sites that need a few interactive islands: documentation, blogs, marketing sites, changelogs, small storefronts and dashboards. You get real Markdown files, real static HTML, and a handful of directives when a page needs to react. It is not a general-purpose application framework, and the [Limits](#limits) section says exactly where the line is before you build against it.
|
|
23
13
|
|
|
24
14
|
## Quick start
|
|
25
15
|
|
|
@@ -47,6 +37,27 @@ Prefer to see it run before you install? The [browser playground](https://darkmo
|
|
|
47
37
|
compiles `.wd`/`.md` live in your browser — the same compiler the CLI ships — with
|
|
48
38
|
no install and no build step.
|
|
49
39
|
|
|
40
|
+
**On this page**
|
|
41
|
+
|
|
42
|
+
- [Quick start](#quick-start) · [Showcase](#showcase) · [Working from this repo](#working-from-this-repo) · [Commands](#commands)
|
|
43
|
+
- **Authoring:** [Authoring model](#authoring-model) · [Directive lines](#directive-lines) · [Interpolation](#interpolation) · [Frontmatter](#frontmatter) · [Static assets](#static-assets) · [SEO & feeds](#seo--feeds-sitemap-rss-robots) · [Loops](#loops) · [Content collections](#content-collections) · [Sections](#sections)
|
|
44
|
+
- **Reactivity:** [Reactive directives](#reactive-directives) · [Fetching data](#fetching-data) · [Global state](#global-state--store) · [URL as state](#url-as-state-from-url) · [Forms and persistence](#forms-and-persistence) · [Backends & deploy](#backends--deploy) · [Interactions](#interactions--slider-sortable-carousel)
|
|
45
|
+
- **Presentation:** [Inline attributes](#inline-attributes) · [Media](#media--video-audio-embed) · [Syntax highlighting](#syntax-highlighting) · [Dark mode](#dark-mode--tokens-dark) · [Scoped styles](#scoped-styles--scoped)
|
|
46
|
+
- **Extending:** [The escape hatch](#the-escape-hatch) · [Programmatic compile](#programmatic-compile--compilefrommemory) · [Compile error codes](#compile-error-codes) · [AI authoring](#ai-authoring) · [Editor support](#editor-support)
|
|
47
|
+
- **Before you commit:** [Accessibility](#accessibility) · [Security](#security) · [**Limits**](#limits) · [Spec status](#spec-status)
|
|
48
|
+
|
|
49
|
+
## Showcase
|
|
50
|
+
|
|
51
|
+
Five complete apps, each a readable `.wd` file — see them at **[darkmown.com/showcase](https://darkmown.com/showcase/)**:
|
|
52
|
+
|
|
53
|
+
- **[Folio](https://darkmown.com/folio/)** — a boutique storefront whose cart persists across pages, reloads, and browser tabs (`:store`), with live search and a checkout form.
|
|
54
|
+
- **[Pulse](https://darkmown.com/pulse/)** — a service dashboard driven by `:fetch`: loading / error / empty states, live refresh (`:every`), and status badges, with no hand-written JavaScript.
|
|
55
|
+
- **[Forge](https://darkmown.com/forge/)** — a plan configurator where `:computed` recomputes the price as you toggle features and seats, with conditional upsell hints.
|
|
56
|
+
- **[Compass](https://darkmown.com/compass/)** — a product-finder quiz: a branching state machine expressed entirely as `:if` steps over scored answers.
|
|
57
|
+
- **[Ledger](https://darkmown.com/ledger/)** — a spreadsheet-grade expense table: clickable-header reactive sort, a live filter, running totals via `sum`/`avg`/`max`, `| money`/`| date` format pipes, and a `:theme` toggle.
|
|
58
|
+
|
|
59
|
+
Plus focused feature demos: a draggable, keyboard-navigable **[Swiper](https://darkmown.com/carousel/)** (the `wd.subscribe` escape hatch) and a zero-JS **[Media](https://darkmown.com/media/)** page (`:video` / `:audio` / `:embed`).
|
|
60
|
+
|
|
50
61
|
## Working from this repo
|
|
51
62
|
|
|
52
63
|
```sh
|
|
@@ -63,7 +74,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
63
74
|
- `darkmown build [--target cloudflare] [--drafts]` writes static output to `dist` (plus `sitemap.xml`/`rss.xml`/`robots.txt`); `--drafts` includes `draft: true` pages for staging.
|
|
64
75
|
- `darkmown deploy <vercel|cloudflare> [--prod]` builds and deploys via the platform CLI.
|
|
65
76
|
- `darkmown serve` previews the built `dist` locally.
|
|
66
|
-
- `darkmown catalog [--llms]` prints the machine-readable directive catalog (JSON),
|
|
77
|
+
- `darkmown catalog [--llms|--llms-full]` prints the machine-readable directive catalog (JSON), a compact cheatsheet with `--llms`, or the complete reference including every error code with `--llms-full`. See [AI authoring](#ai-authoring).
|
|
67
78
|
- `darkmown version` prints the installed package version.
|
|
68
79
|
- `darkmown help` prints CLI usage.
|
|
69
80
|
|
|
@@ -74,9 +85,18 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
74
85
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
75
86
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
76
87
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
77
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~
|
|
88
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js`, a minified build of the readable `src/runtime.js` (currently ~6.6 KB gzipped, CI-enforced under 8 KB). An external sourcemap ships beside it at `/__wd/runtime.js.map` with the original source inlined, so DevTools shows real names and real comments. Development serves the same bytes as production.
|
|
78
89
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
79
90
|
|
|
91
|
+
## Directive lines
|
|
92
|
+
|
|
93
|
+
A directive is a whole line, written flush against the left margin. Four rules cover everything the compiler will and will not accept there.
|
|
94
|
+
|
|
95
|
+
- **One directive per line, unindented.** An indented `:state` (inside a list item, say) is prose, and a directive inside a fenced code block is code.
|
|
96
|
+
- **A bare keyword is an error.** A line that is exactly `:state`, `:button`, `@loop`, or any other directive that needs arguments used to render as literal text with no warning at all. It now reaches that directive's own handler and fails with that directive's coded message and `Use:` hint, so `:state` alone gives `[WD201]` and `:button` alone gives `[WD301]`. Three keywords stay valid bare, because they mean something bare: `:::` opens or closes a container, `:theme` declares the theme store, and `:carousel` opens a carousel with no autoplay.
|
|
97
|
+
- **To show a directive name as text, escape it.** `\:fetch` renders the literal text `:fetch`. That is the ordinary CommonMark backslash escape, so it works anywhere Markdown does, including inside a container.
|
|
98
|
+
- **There is no comment syntax.** A trailing `#` or arrow note on a directive line is parsed as part of the value, not as a comment. `html: true ← required` seeds the *string* `true ← required`, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.
|
|
99
|
+
|
|
80
100
|
## Interpolation
|
|
81
101
|
|
|
82
102
|
One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
@@ -85,7 +105,37 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
|
85
105
|
- Declared `:state` becomes a live binding.
|
|
86
106
|
- The page's own frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
|
|
87
107
|
- Anything else stays literal text — braces in prose never break a page or pull in the runtime.
|
|
88
|
-
-
|
|
108
|
+
- Values resolve **inside a link or image destination** too (`[{ item.label }]({ item.url })`, ``), so an `@loop` can drive an `href`/`src` and the page stays static. Every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)` works. A reactive value there becomes a live binding; see [Destinations that bind](#destinations-that-bind) below.
|
|
109
|
+
- Values resolve in **raw HTML** too (attributes and html blocks, HTML-escaped) on an `html: true` page. Those are painted once at build time, not bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `` `[x](/p/{ p.slug }/)` `` as syntax.
|
|
110
|
+
|
|
111
|
+
### Destinations that bind
|
|
112
|
+
|
|
113
|
+
A destination that reads `:state`, `:store`, `:computed`, a reactive loop row, or a loop meta variable is a **live binding**: the compiler emits the destination as a small template and the runtime rebuilds the value on every render.
|
|
114
|
+
|
|
115
|
+
```wd
|
|
116
|
+
:state region = "eu"
|
|
117
|
+
|
|
118
|
+
:button "EU" -> region = "eu"
|
|
119
|
+
:button "US" -> region = "us"
|
|
120
|
+
|
|
121
|
+
[Open the docs for this region](/docs/{ region }/)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Clicking a button rewrites the `href`. The build-time paint is still the seed (`/docs/eu/`), so the link works before the runtime loads and a crawler sees a real URL. Per-row destinations work the same way inside a reactive `@loop`:
|
|
125
|
+
|
|
126
|
+
```wd
|
|
127
|
+
:state products = [{"slug": "aurora", "name": "Aurora Lamp"}]
|
|
128
|
+
|
|
129
|
+
@loop products into p
|
|
130
|
+
- [{ p.name }](/products/{ p.slug }/)
|
|
131
|
+
@endloop
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Three rules worth knowing:
|
|
135
|
+
|
|
136
|
+
- **Values in URL position are percent-encoded.** A `)`, a space, or an angle bracket coming from your data cannot end the link and hand the remainder back to the Markdown parser.
|
|
137
|
+
- **Dangerous schemes are refused twice.** The compiler vets what you wrote; the runtime re-checks what it assembled (control characters stripped first) and applies an **empty** attribute rather than a half-applied one if the value resolves to `javascript:`, `data:`, or `vbscript:`.
|
|
138
|
+
- **Raw HTML attributes do not bind.** `<a href="{ url }">` is painted once with a build warning that names the position that does bind. Move the value into a Markdown destination, or drive the attribute from a colocated `.js` with `wd.subscribe`.
|
|
89
139
|
|
|
90
140
|
### Format pipes — `{ value | name:arg }`
|
|
91
141
|
|
|
@@ -126,7 +176,7 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
126
176
|
|
|
127
177
|
Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card). `lang:` sets the document language on `<html lang>` (`lang: fr`, `lang: pt-BR`, …) — it defaults to `en`.
|
|
128
178
|
|
|
129
|
-
A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections)
|
|
179
|
+
A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `rss_limit:` on the home page caps how many posts the feed carries; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections): `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
|
|
130
180
|
|
|
131
181
|
`transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
|
|
132
182
|
|
|
@@ -166,6 +216,20 @@ site_url: https://example.com
|
|
|
166
216
|
- **`robots.txt`** is *always* emitted; the `Sitemap:` line is added only when `site_url` is set. See [AI crawlers](#ai-crawlers) below for the explicit per-crawler groups.
|
|
167
217
|
- **`sitemap.xml`** lists every built page (reactive pages included — they're indexable HTML). Each `<lastmod>` is the page's frontmatter `date:` if set, else its git last-commit date, else the file's mtime. No `<priority>`/`<changefreq>`.
|
|
168
218
|
- **`rss.xml`** syndicates your **posts** — any page with a `date:` in its frontmatter. Newest first, capped at the 20 most recent. Each item's `<description>` is the page's `excerpt:`, else its `description:`, else (for a plain `.md` post) its first paragraph. Every page links the feed with `<link rel="alternate" type="application/rss+xml">` so readers can autodiscover it.
|
|
219
|
+
- **`rss_limit:`** on the home page changes that cap: `rss_limit: 50` carries the 50 newest posts. It takes digits only (a positive whole number of items), so `1e3`, `0x10`, and `7.0` are refused with `[WD950]` rather than silently coerced. Leave it out for the default of 20.
|
|
220
|
+
|
|
221
|
+
A post is just a page that carries a `date:`. Add `excerpt:` to control the summary the feed shows for it:
|
|
222
|
+
|
|
223
|
+
```md
|
|
224
|
+
---
|
|
225
|
+
title: Hello, Darkmown
|
|
226
|
+
date: 2026-01-15
|
|
227
|
+
excerpt: Why I rewrote my blog as plain Markdown files.
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
# Hello, Darkmown
|
|
231
|
+
…
|
|
232
|
+
```
|
|
169
233
|
|
|
170
234
|
### Canonical URLs
|
|
171
235
|
|
|
@@ -234,17 +298,6 @@ ai_crawlers: deny
|
|
|
234
298
|
|
|
235
299
|
`allow` is the default (and matches what `User-agent: *` already grants). A value that is neither `allow` nor `deny` **fails the build** rather than defaulting: the intent behind a typo like `ai_crawlers: block` is almost always to opt *out*, and silently allowing would be the one unrecoverable outcome.
|
|
236
300
|
|
|
237
|
-
```md
|
|
238
|
-
---
|
|
239
|
-
title: Hello, Darkmown
|
|
240
|
-
date: 2026-01-15
|
|
241
|
-
excerpt: Why I rewrote my blog as plain Markdown files.
|
|
242
|
-
---
|
|
243
|
-
|
|
244
|
-
# Hello, Darkmown
|
|
245
|
-
…
|
|
246
|
-
```
|
|
247
|
-
|
|
248
301
|
Without `site_url`, `robots.txt` still emits and the build prints a one-line hint telling you which field to set; it never crashes.
|
|
249
302
|
|
|
250
303
|
### Drafts
|
|
@@ -333,6 +386,7 @@ Shape a loop without writing JavaScript. Clauses come in a fixed order after `in
|
|
|
333
386
|
- `offset <N>` / `limit <N>` — `<N>` is a non-negative integer **or** a `:state`/`:store` key, which makes pagination reactive:
|
|
334
387
|
|
|
335
388
|
```wd
|
|
389
|
+
:state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
|
|
336
390
|
:state pageSize = 10
|
|
337
391
|
|
|
338
392
|
@loop products into product limit pageSize
|
|
@@ -353,6 +407,8 @@ Each row exposes five meta variables, relative to the rendered slice:
|
|
|
353
407
|
They work in interpolation and in `:if`:
|
|
354
408
|
|
|
355
409
|
```wd
|
|
410
|
+
:state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
|
|
411
|
+
|
|
356
412
|
@loop products into product
|
|
357
413
|
:if $first
|
|
358
414
|
**Top pick:**
|
|
@@ -366,6 +422,8 @@ They work in interpolation and in `:if`:
|
|
|
366
422
|
Add an `@empty` branch to show a fallback when the loop renders no rows (after `where`, `limit`, and the rest):
|
|
367
423
|
|
|
368
424
|
```wd
|
|
425
|
+
:state todos = []
|
|
426
|
+
|
|
369
427
|
@loop todos into todo
|
|
370
428
|
- { todo.title }
|
|
371
429
|
@empty
|
|
@@ -377,6 +435,25 @@ A **missing** in-scope source is an empty list, not an error: `@loop meta.tags i
|
|
|
377
435
|
|
|
378
436
|
> **Note:** All of these clauses stay build-time when the source and every clause argument are static — a sorted, limited loop over a JSON file ships **zero JavaScript**. The loop becomes reactive only when the source is `:state`/`:store`/`:fetch` data, or a clause reads reactive state (like `limit pageSize`).
|
|
379
437
|
|
|
438
|
+
### Table rows from a loop
|
|
439
|
+
|
|
440
|
+
A **static** loop whose body is bare `| … |` cells fills a Markdown table. Write the header in prose and let the loop supply the rows:
|
|
441
|
+
|
|
442
|
+
```wd
|
|
443
|
+
| Item | Price |
|
|
444
|
+
| --- | --- |
|
|
445
|
+
@loop /products.json into row
|
|
446
|
+
| **{ row.name }** | { row.price } |
|
|
447
|
+
@endloop
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
That renders one `<table>` with one `<tr>` per row, and each cell holds ordinary inline Markdown, links included (`| [buy](/buy/{ row.id }/) |`). A format pipe works too, but its `|` has to be escaped inside a table cell: `{ row.price \| money }`. Put the whole table inside the loop instead and you get a headerless `<table>` of the same shape; give the loop body its own `| --- |` separator and each row is a complete table, collapsed into one when the headers match.
|
|
451
|
+
|
|
452
|
+
Two limits to know before you build on it:
|
|
453
|
+
|
|
454
|
+
- A pipe row written in prose **after** `@endloop` (a totals row) does not join the table. It stays a paragraph.
|
|
455
|
+
- A **reactive** loop over pipe rows is `[WD191]`, not silent breakage. A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so there is no correct HTML to emit. Loop a static source for a Markdown table, or build reactive rows out of containers (`::: trow` / `::: td`) and style them with `display: table-row` / `table-cell`.
|
|
456
|
+
|
|
380
457
|
### Editable lists — per-row actions
|
|
381
458
|
|
|
382
459
|
A `:button` inside a reactive `@loop` can act on its own row. `cart += product` carries the current row into another list; `cart remove line` drops the current row from the looped list:
|
|
@@ -407,7 +484,7 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
|
|
|
407
484
|
|
|
408
485
|
### Nested reactive loops
|
|
409
486
|
|
|
410
|
-
|
|
487
|
+
A reactive `@loop` may contain an inner reactive `@loop` over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (`order.lines`), so each row renders its own list and both stay live as state changes:
|
|
411
488
|
|
|
412
489
|
```wd
|
|
413
490
|
:state orders = [
|
|
@@ -518,11 +595,39 @@ State declared inside a section is scoped to it — two sections can both own a
|
|
|
518
595
|
|
|
519
596
|
A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) instead of a `<div>`, so scaffolded pages keep proper landmarks — a skip link skips a `::: nav`, and `::: main` becomes the page's `<main id="main">`. Any other name stays a `<div>` with that class.
|
|
520
597
|
|
|
598
|
+
### Accessibility attributes
|
|
599
|
+
|
|
600
|
+
Darkmown has no general attribute syntax on purpose: styling is `.class` tokens and behavior is `->` actions, which is what keeps output predictable and static pages script-free. That left one thing genuinely unreachable, the ARIA vocabulary a screen reader needs. Exactly three attribute names now compile, on exactly two directives (`:::` and `:button`), always with a **double-quoted static value**:
|
|
601
|
+
|
|
602
|
+
```wd
|
|
603
|
+
:state open = false
|
|
604
|
+
|
|
605
|
+
::: card .note role="region" aria-label="Release notes" title="What changed"
|
|
606
|
+
Notes go here.
|
|
607
|
+
:::
|
|
608
|
+
|
|
609
|
+
::: nav .menu role="navigation" aria-label="Main"
|
|
610
|
+
[Docs](/docs/)
|
|
611
|
+
:::
|
|
612
|
+
|
|
613
|
+
:button "Menu" aria-expanded="false" aria-controls="m" -> open toggle
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- On a container, attributes interleave freely with `.class` and `#id` tokens and coexist with `.class when <predicate>`.
|
|
617
|
+
- On a `:button`, they sit between the label and the `->`. An arrow inside a quoted value is safe: attributes are peeled before the action arrow is looked for.
|
|
618
|
+
- Values are HTML-escaped on emit, so nothing you write can close the attribute or open another.
|
|
619
|
+
- Anything outside the whitelist is `[WD650]`: `onclick=`, `style=`, `href=`, `class=`, `id=`, `data-*` are all refused, and so is `ARIA-LABEL` (the match is case-sensitive) or a single-quoted value. A name on the whitelist with no double-quoted value is `[WD651]`.
|
|
620
|
+
- **Values are static text.** There is no `{ state }` interpolation inside an aria value in this release: `aria-label="{ who }"` emits the literal braces. For a live accessible name, write the element in raw HTML on an `html: true` page, or set it from a colocated `.js`.
|
|
621
|
+
|
|
622
|
+
This is compile-time only. It costs zero runtime bytes, and a static page carrying attributes stays `runtime: false`.
|
|
623
|
+
|
|
521
624
|
### Reactive classes — `.class when <predicate>`
|
|
522
625
|
|
|
523
626
|
A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
|
|
524
627
|
|
|
525
628
|
```wd
|
|
629
|
+
:state products = [{"id": 1, "name": "Aurora", "price": 49, "featured": true}]
|
|
630
|
+
|
|
526
631
|
@loop products into p
|
|
527
632
|
::: card .product .on-sale when p.price < 50 .featured when p.featured
|
|
528
633
|
**{ p.name }** — ${ p.price }
|
|
@@ -552,7 +657,7 @@ Count is still zero.
|
|
|
552
657
|
|
|
553
658
|
A condition reads the same predicate grammar as `.class when`: a bare path (truthy), or the comparisons `==` `!=` `<` `<=` `>` `>=` `contains`, joined with `and`, `or`, and `not`. (`@loop … where` is the comparison-only subset — operators with `and`/`or`.) Chain with `:else if` (any number; an optional bare `:else` must be the last branch):
|
|
554
659
|
|
|
555
|
-
```wd
|
|
660
|
+
```wd-fragment
|
|
556
661
|
:if plan == "pro" or seats >= 5
|
|
557
662
|
Pro plan
|
|
558
663
|
:else if trialDays > 0 and not expired
|
|
@@ -564,6 +669,8 @@ Free plan
|
|
|
564
669
|
|
|
565
670
|
A whole chain compiles to nested conditional regions, so it stays reactive (or folds at build time when every value is static) exactly like a single `:if`.
|
|
566
671
|
|
|
672
|
+
**Declarations inside a closed branch are live.** A `:state`, `:store`, or `:theme` written inside an `:if` branch that starts closed is hydrated the moment the branch opens, `persist` included. The seed is claimed once per **key**, not per node, so closing and re-opening the branch does not reset the value the reader set, and the claimed seed is that key's `reset` baseline. (A `:computed` inside a closed branch stays dormant until the branch opens, by design.)
|
|
673
|
+
|
|
567
674
|
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
|
|
568
675
|
|
|
569
676
|
### Button actions
|
|
@@ -599,15 +706,17 @@ Values are literals: a `"string"`, number, `true`/`false`/`null`, or inline JSON
|
|
|
599
706
|
|
|
600
707
|
**One button can run several actions** with `;`. They apply in order, then the page renders once:
|
|
601
708
|
|
|
602
|
-
```wd
|
|
709
|
+
```wd-fragment
|
|
603
710
|
:button "Add to cart" -> cart.count++ ; cart.total += 9
|
|
604
711
|
```
|
|
605
712
|
|
|
713
|
+
A `:button` also accepts the three [accessibility attributes](#accessibility-attributes), written between the label and the `->`: `:button "Menu" aria-expanded="false" -> open toggle`.
|
|
714
|
+
|
|
606
715
|
> **Pitfall:** `list toggle v` and `list remove v` match members by value (`===`). That is exact for strings, numbers, and booleans, but not reliable for object members — two equal-looking objects are different values. To remove a row object, loop the list and use the per-row `remove` action below.
|
|
607
716
|
|
|
608
717
|
### Computed values — `:computed`
|
|
609
718
|
|
|
610
|
-
`:computed name = <expression>` derives state from other state with a compile-time-checked expression — names, numbers, arithmetic (`+ - * /`), comparisons, and the five **aggregates** over a list: `sum(list, field)`, `avg(list, field)`, `min(list, field)`, `max(list, field)`, and `count(list)`. It recomputes whenever an input changes and reads like any other binding (so it pairs naturally with [format pipes](#format-pipes
|
|
719
|
+
`:computed name = <expression>` derives state from other state with a compile-time-checked expression — names, numbers, arithmetic (`+ - * /`), comparisons, and the five **aggregates** over a list: `sum(list, field)`, `avg(list, field)`, `min(list, field)`, `max(list, field)`, and `count(list)`. It recomputes whenever an input changes and reads like any other binding (so it pairs naturally with [format pipes](#format-pipes---value--namearg-)):
|
|
611
720
|
|
|
612
721
|
```wd
|
|
613
722
|
:store cart = [{"price": 89}, {"price": 12}]
|
|
@@ -626,14 +735,18 @@ The aggregate's field argument is a bare key on each row (`price`, not `item.pri
|
|
|
626
735
|
|
|
627
736
|
```wd
|
|
628
737
|
:fetch board from "/status.json"
|
|
629
|
-
:every 10s -> board refetch
|
|
738
|
+
:every 10s -> board refetch
|
|
630
739
|
|
|
631
740
|
:state secs = 0
|
|
632
|
-
:every 1s -> secs++
|
|
741
|
+
:every 1s -> secs++
|
|
633
742
|
```
|
|
634
743
|
|
|
744
|
+
The first line live-refreshes a dashboard; the second is a ticking counter. There is no comment syntax on a directive line, so a trailing `#` note would be parsed as part of the action.
|
|
745
|
+
|
|
635
746
|
Intervals **pause while the tab is hidden** (via `visibilitychange`) and resume on return, so a backgrounded dashboard stops firing requests and draining battery.
|
|
636
747
|
|
|
748
|
+
**`:every` is a page-level registration, not a per-row one.** A reactive `@loop` compiles its body once into a template and the runtime clones it per row, so a timer written inside that body would be registered once per row (three rows, three intervals), and a removed row's interval would keep firing. That placement is `[WD315]`. Declare the timer once outside the loop, at page level or inside the `::: section`, and act on the whole list (`:every 5s -> rows refetch`). It stays legal at page level, inside a `:::`, inside a **static** loop (which produces N literal copies that never churn), and in a reactive loop's `@empty` branch. `:button` inside a reactive loop is unaffected.
|
|
749
|
+
|
|
637
750
|
### Effects — `:effect`
|
|
638
751
|
|
|
639
752
|
`:effect <watched> -> <actions>` runs actions whenever a watched state path changes. The actions are the same `:button` vocabulary (`;`-chained) — this is the escape hatch for side effects beyond `:computed` (which derives state) and `:fetch` deps (which auto-refetch):
|
|
@@ -647,6 +760,8 @@ Intervals **pause while the tab is hidden** (via `visibilitychange`) and resume
|
|
|
647
760
|
|
|
648
761
|
Effects run after a render, against settled state, and an effect that mutates state triggers another pass — bounded by a 10-pass settle cap that warns (and stops) if an effect never settles. They do not fire on the initial load, only on a real change.
|
|
649
762
|
|
|
763
|
+
Like `:every`, `:effect` is page-level: written inside a reactive `@loop` body it is `[WD315]`, because an effect watches a top-level state key and its actions target one, so there is no per-row meaning to give it. Declare it once outside the loop.
|
|
764
|
+
|
|
650
765
|
## Fetching data
|
|
651
766
|
|
|
652
767
|
`:fetch name from "url"` declares state and fills it from JSON over the network:
|
|
@@ -662,6 +777,7 @@ Each fetch automatically declares four state keys you can branch on:
|
|
|
662
777
|
| `name` | the data | `null` until the response arrives |
|
|
663
778
|
| `name_loading` | boolean | `true` while the request is in flight |
|
|
664
779
|
| `name_error` | string | the error message, or `null` |
|
|
780
|
+
| `name_error_body` | object | the parsed JSON error body, or `null` |
|
|
665
781
|
| `name_empty` | boolean | `true` when the data is `null`, `[]`, or `{}` |
|
|
666
782
|
|
|
667
783
|
### The four-state pattern
|
|
@@ -686,6 +802,28 @@ No team members yet.
|
|
|
686
802
|
|
|
687
803
|
The lifecycle regions announce themselves: a bare `:if name_loading` compiles with `role="status" aria-live="polite"` and `:if name_error` with `role="alert"`, so assistive tech hears the flips with no extra markup. Author-supplied `role`/`aria-live` inside a region always wins.
|
|
688
804
|
|
|
805
|
+
### What the server said: `name_error` and `name_error_body`
|
|
806
|
+
|
|
807
|
+
A real API explains why it refused, and Darkmown surfaces that instead of a status line. When the failing response has a JSON body:
|
|
808
|
+
|
|
809
|
+
- **`name_error`** is the body's own `error` field, then its `message` field, falling back to `HTTP <status>` when the body carries neither or is not JSON at all.
|
|
810
|
+
- **`name_error_body`** is the whole parsed body (`null` when the response was not JSON), so per-field messages render without a line of your own JavaScript.
|
|
811
|
+
|
|
812
|
+
Given a `422` whose body is `{"error": "Pick a file first.", "fields": {"photo": "No file was attached."}}`:
|
|
813
|
+
|
|
814
|
+
```wd
|
|
815
|
+
:fetch signup from "/api/signup" method=POST
|
|
816
|
+
|
|
817
|
+
:if signup_error
|
|
818
|
+
**{ signup_error }**
|
|
819
|
+
:if signup_error_body
|
|
820
|
+
Photo: { signup_error_body.fields.photo }
|
|
821
|
+
:endif
|
|
822
|
+
:endif
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
The first line renders *Pick a file first.*, not *Error: HTTP 422*. Both keys are declared automatically by `:fetch` and by a round-trip `:form` (one with both `into` and `action=`), and both are cleared at the start of the next request, so a stale message never outlives the failure that produced it.
|
|
826
|
+
|
|
689
827
|
### Options
|
|
690
828
|
|
|
691
829
|
- `method=` — `GET` (default), `POST`, `PUT`, `PATCH`, or `DELETE`.
|
|
@@ -700,17 +838,28 @@ The lifecycle regions announce themselves: a bare `:if name_loading` compiles wi
|
|
|
700
838
|
|
|
701
839
|
### Authenticated requests and token refresh
|
|
702
840
|
|
|
703
|
-
`headers=<key>` sends a state object as request headers
|
|
841
|
+
`headers=<key>` sends a state object as request headers; pair it with `:store` to keep the token across reloads.
|
|
842
|
+
|
|
843
|
+
**Seed the store empty.** A `:state`/`:store` value is a *build-time* seed: it is inlined into the page's public HTML, so a literal token written into a `.wd` file ships to every visitor. The token has to arrive at runtime, from your own `api/` endpoint:
|
|
704
844
|
|
|
705
845
|
```wd
|
|
706
|
-
:store session = {
|
|
846
|
+
:store session = {}
|
|
847
|
+
|
|
848
|
+
:form action="/api/login" into login
|
|
849
|
+
:input email type=email required
|
|
850
|
+
:input password type=password required
|
|
851
|
+
:submit "Sign in"
|
|
852
|
+
:endform
|
|
853
|
+
|
|
854
|
+
:effect login -> session merge login
|
|
855
|
+
|
|
707
856
|
:fetch feed from "/api/feed" headers=session
|
|
708
857
|
```
|
|
709
858
|
|
|
710
859
|
Add `refresh="<url>"` and Darkmown manages the token lifecycle: when a request comes back `401`, it POSTs the current `headers` object to the refresh URL, writes the returned headers (the new token) back into the `session` state — persisting it, since `session` is a `:store` — and retries the original request once. Concurrent `401`s sharing a refresh URL are de-duplicated into a single in-flight refresh.
|
|
711
860
|
|
|
712
861
|
```wd
|
|
713
|
-
:store session = {
|
|
862
|
+
:store session = {}
|
|
714
863
|
|
|
715
864
|
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
|
|
716
865
|
```
|
|
@@ -729,7 +878,7 @@ A URL can interpolate state with `{ }`. The fetch re-runs automatically when tha
|
|
|
729
878
|
|
|
730
879
|
Trigger a reload by hand with the `refetch` action:
|
|
731
880
|
|
|
732
|
-
```wd
|
|
881
|
+
```wd-fragment
|
|
733
882
|
:button "Reload" -> roster refetch
|
|
734
883
|
```
|
|
735
884
|
|
|
@@ -797,10 +946,49 @@ The declared value is a **seed**: it is used only the first time, when the store
|
|
|
797
946
|
|
|
798
947
|
Writing the token that matches the default is redundant but never wrong, so `:store cart = [] persist` compiles and means exactly what it says. If a value genuinely ends in one of these words, quote it: `:state note = "0 persist"` seeds the string.
|
|
799
948
|
|
|
949
|
+
There is a third word, [`from-url`](#url-as-state-from-url), which says the value also lives in the query string. It composes with `persist` and belongs to `:state` only. Writing `persist` and `ephemeral` on one line (or the same word twice) is `[WD261]` rather than a token silently folded into the value.
|
|
950
|
+
|
|
800
951
|
`:computed` takes neither: computed values are derived rather than stored, so persist the state they derive from instead (that mistake is a compile error, `WD211`).
|
|
801
952
|
|
|
802
953
|
> **Pitfall:** A store name must be unique. Declaring the same name as both a `:store` and a `:state` on one page is a compile error.
|
|
803
954
|
|
|
955
|
+
## URL as state: `from-url`
|
|
956
|
+
|
|
957
|
+
A filter nobody can link to is half a feature. Add `from-url` to a `:state` and the value lives in the query string as well as in memory: a reload keeps it, a shared link arrives with it applied, and the back button walks through it.
|
|
958
|
+
|
|
959
|
+
```wd
|
|
960
|
+
:state q = "" from-url
|
|
961
|
+
:state tier = "all" from-url
|
|
962
|
+
|
|
963
|
+
:bind q placeholder="Search products"
|
|
964
|
+
|
|
965
|
+
:radio tier
|
|
966
|
+
- all
|
|
967
|
+
- budget
|
|
968
|
+
- premium
|
|
969
|
+
|
|
970
|
+
:state products = [{"name": "Aurora Lamp", "tier": "budget"}]
|
|
971
|
+
|
|
972
|
+
@loop products into p where p.name contains q
|
|
973
|
+
- **{ p.name }** ({ p.tier })
|
|
974
|
+
@empty
|
|
975
|
+
Nothing matches that search.
|
|
976
|
+
@endloop
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
Type in the box and the address bar becomes `?q=aurora`. Reload, and the search comes back. Copy the URL into a new tab and it opens on the same view.
|
|
980
|
+
|
|
981
|
+
The rules:
|
|
982
|
+
|
|
983
|
+
- **The parameter is named after the state key.** A section-scoped key like `cart:items` becomes the parameter `cart.items`, so the name stays readable and stays unique.
|
|
984
|
+
- **A value equal to its declared seed drops its parameter,** so the default page keeps a clean URL.
|
|
985
|
+
- **Writes go through `history.replaceState`,** so filtering never fills the back button with one entry per keystroke. `popstate` re-reads on back and forward, and a parameter that is gone restores the seed.
|
|
986
|
+
- **It composes with `persist`,** and the boot precedence is URL, then stored value, then seed. A link somebody sent you beats what this browser remembers. When back navigation lands on a clean URL, the stored value follows the seed too.
|
|
987
|
+
- **Strings stay strings.** For any other seed type the parameter is JSON-parsed, falling back to the raw string.
|
|
988
|
+
- **`from-url` is `:state` only.** A `:store` is shared by every page and every tab while a query parameter belongs to one page's address, so `from-url` on a `:store` or a `:theme` is `[WD260]` rather than a guess. On a `:computed` it is `[WD211]`: derive the value from a state key that does come from the URL.
|
|
989
|
+
|
|
990
|
+
Live demo: [darkmown.com/url-state/](https://darkmown.com/url-state/).
|
|
991
|
+
|
|
804
992
|
## Forms and persistence
|
|
805
993
|
|
|
806
994
|
Fetched data and a form live happily on the same page:
|
|
@@ -837,6 +1025,55 @@ Fetched data and a form live happily on the same page:
|
|
|
837
1025
|
- `:computed total = items.length * 4` derives state from state with a compile-time-checked expression — names, numbers, arithmetic, comparisons, and list aggregates (`sum`/`avg`/`min`/`max`/`count`). See [Computed values](#computed-values--computed).
|
|
838
1026
|
- `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
|
|
839
1027
|
|
|
1028
|
+
### Bound controls outside a form
|
|
1029
|
+
|
|
1030
|
+
`:select`, `:radio`, and `:checkbox` mean two different things, and **where they sit decides which**. Inside a `:form` they are form fields, submitted by name (unchanged). Outside one they bind to a declared `:state` or `:store` of that name, exactly like `:bind` and `:slider`: move the control and the state changes, change the state and the control moves.
|
|
1031
|
+
|
|
1032
|
+
```wd
|
|
1033
|
+
:state density = "Comfortable"
|
|
1034
|
+
:state previews = true
|
|
1035
|
+
|
|
1036
|
+
:select density
|
|
1037
|
+
- Compact
|
|
1038
|
+
- Comfortable
|
|
1039
|
+
- Spacious
|
|
1040
|
+
|
|
1041
|
+
:checkbox previews
|
|
1042
|
+
- Show image previews
|
|
1043
|
+
|
|
1044
|
+
:if density == "Compact"
|
|
1045
|
+
Rows sit tight together.
|
|
1046
|
+
:endif
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
- **The state has to exist first.** A bound field naming state that is not declared is `[WD450]`, which spells out both readings: declare the state, or move the field inside a `:form`.
|
|
1050
|
+
- **A bound `:checkbox` is a single boolean,** so it takes exactly one `- Label` line (the label shown beside it). Several options is `[WD451]`; for a set of choices use a `:radio` group. The multi-value checkbox group is the in-form behavior and is unchanged.
|
|
1051
|
+
- **A bound `:radio` group keeps its shared `name`,** which is what makes the browser treat it as mutually exclusive, and carries the chosen option's text.
|
|
1052
|
+
|
|
1053
|
+
### File upload
|
|
1054
|
+
|
|
1055
|
+
A `:form` that contains a file field posts **multipart**, so the file itself travels:
|
|
1056
|
+
|
|
1057
|
+
```wd
|
|
1058
|
+
:form into reply action="/api/upload/"
|
|
1059
|
+
:input photo type=file required
|
|
1060
|
+
:input caption placeholder="A caption (optional)"
|
|
1061
|
+
:submit "Upload"
|
|
1062
|
+
:endform
|
|
1063
|
+
|
|
1064
|
+
:if reply
|
|
1065
|
+
Uploaded **{ reply.name }**, { reply.size } bytes.
|
|
1066
|
+
:endif
|
|
1067
|
+
|
|
1068
|
+
:if reply_error
|
|
1069
|
+
**{ reply_error }**
|
|
1070
|
+
:endif
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
The compiler writes `enctype="multipart/form-data"` for the browser's native submit, and the runtime sends real `FormData` with **no** content type of its own so the browser writes the boundary. A raw `<input type="file">` on an `html: true` page counts as a file field too.
|
|
1074
|
+
|
|
1075
|
+
Bound controls inside the form carry no `name`, so `FormData` would never see them; on the multipart path they are appended by their state key instead of being silently dropped. A file field on a `method="get"` form is `[WD452]`: a GET request has no body, so only the file's name would ever travel, which is the kind of failure that looks like it worked.
|
|
1076
|
+
|
|
840
1077
|
## Backends & deploy
|
|
841
1078
|
|
|
842
1079
|
Darkmown builds **100% static, CDN-cacheable HTML** — never per-request server rendering. Reactive pages hydrate from data-attributes client-side; dynamic data arrives via `:fetch`. When you need a backend, you don't learn a new syntax — you write a plain serverless function, and *it just comes with wherever you deploy*.
|
|
@@ -860,9 +1097,9 @@ export default async function (request, context) {
|
|
|
860
1097
|
|
|
861
1098
|
Templates get you to a running, deployable app fast: `darkmown init shop --template store` ships a cart **and** an `api/checkout.js`; `--template dashboard` ships a `:fetch` view **and** an `api/metrics.js`; `--template blog` ships a typed posts collection (`_schema.wd` + one `@loop` over the folder) — adding a post is adding a `.md` file.
|
|
862
1099
|
|
|
863
|
-
## Interactions — `:slider`,
|
|
1100
|
+
## Interactions — `:slider`, `sortable`, `:carousel`
|
|
864
1101
|
|
|
865
|
-
Rich interactions are **pay-for-what-you-use**:
|
|
1102
|
+
Rich interactions are **pay-for-what-you-use**: `sortable`/`:carousel` compile to a tiny `/__wd/behaviors/<name>.js` module injected **only** on pages that use them, budgeted separately from the ≤8 KB core runtime. `:slider` is compile-time only — zero extra JS.
|
|
866
1103
|
|
|
867
1104
|
```wd
|
|
868
1105
|
:slider volume = 50 min=0 max=100 step=5
|
|
@@ -884,7 +1121,7 @@ Second slide
|
|
|
884
1121
|
```
|
|
885
1122
|
|
|
886
1123
|
- **`:slider name = v min max step`** renders a range input two-way bound through `:bind`; range values coerce to Number so `:computed`/math see a number. Ships no behavior module.
|
|
887
|
-
-
|
|
1124
|
+
- **`sortable`** (a bare `@loop` clause, written without a leading colon) drag-reorders the underlying `:state`/`:store` list via Pointer Events (mouse + touch), with full keyboard support (Arrow Up/Down on a focused row, screen-reader instructions + a live "Moved to position N of M" announcement), rewriting the list through the public `window.wd` API so the keyed loop repaints. Valid only on a plain reactive loop (no `where`/`sort`/`reverse`/`offset`/`limit`).
|
|
888
1125
|
- **`:carousel [autoplay=N]`** treats **each direct child block as one slide** (wrap each in its own block, e.g. `::: slide`, and size it in your skin), using native CSS scroll-snap (touch swipe is free) plus prev/next buttons, dot navigation, and mouse drag. `autoplay` is suppressed under `prefers-reduced-motion`.
|
|
889
1126
|
|
|
890
1127
|
## Inline attributes
|
|
@@ -896,13 +1133,13 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
|
|
|
896
1133
|
{.brand}
|
|
897
1134
|
```
|
|
898
1135
|
|
|
899
|
-
The block must follow the element with no space, and works on links, images, emphasis
|
|
1136
|
+
The block must follow the element with no space, and works on links, images, and emphasis. It does **not** attach to inline code: `` `x` ``{.hl} leaves the braces in the output as literal text. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.` or `#`.
|
|
900
1137
|
|
|
901
1138
|
**Headings get anchors for free.** Every markdown heading carries a stable, GitHub-style slug `id` at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with `-1`/`-2` suffixes across the whole document), so any section of any page is deep-linkable with a plain `#the-slug` fragment — zero JS. The [docs page's](https://darkmown.com/docs/) "On this page" table of contents is just markdown links to those anchors.
|
|
902
1139
|
|
|
903
1140
|
## Media — `:video`, `:audio`, `:embed`
|
|
904
1141
|
|
|
905
|
-
|
|
1142
|
+
Three one-line directives replace hand-written `<video>` / `<iframe>` markup. They are **compile-time only** — they emit no `data-wd-*`, so a media-only page still ships **zero** framework JavaScript.
|
|
906
1143
|
|
|
907
1144
|
```wd
|
|
908
1145
|
:video /clip.mp4 poster=/clip.jpg controls
|
|
@@ -917,7 +1154,7 @@ Darkmown's shipped CSP pre-authorizes exactly the two embed origins (`youtube-no
|
|
|
917
1154
|
|
|
918
1155
|
## Syntax highlighting
|
|
919
1156
|
|
|
920
|
-
|
|
1157
|
+
Fenced code blocks with a language are highlighted at **build time** — HTML and CSS only, no client JavaScript. Tag the fence with a language and it just works:
|
|
921
1158
|
|
|
922
1159
|
````md
|
|
923
1160
|
```js
|
|
@@ -950,7 +1187,7 @@ See it recolor live on the [Syntax highlighting demo](https://darkmown.com/highl
|
|
|
950
1187
|
|
|
951
1188
|
## Dark mode — `tokens dark`
|
|
952
1189
|
|
|
953
|
-
|
|
1190
|
+
A colocated `.skin` file already declares its palette in a `tokens` block (`name value` pairs referenced elsewhere as `$name`). Add a second `tokens dark` block to override any of those tokens under the visitor's OS dark preference — it compiles to a `@media (prefers-color-scheme: dark) :root { … }` rule, so the page follows the system theme with **zero JavaScript**.
|
|
954
1191
|
|
|
955
1192
|
```skin
|
|
956
1193
|
tokens
|
|
@@ -970,7 +1207,7 @@ A single `tokens dark` block powers **both** theming paths: it compiles to `:roo
|
|
|
970
1207
|
|
|
971
1208
|
### Manual toggle — `:theme`
|
|
972
1209
|
|
|
973
|
-
|
|
1210
|
+
For an explicit light/dark switch alongside (or instead of) the OS preference, declare `:theme` once and drive it with ordinary buttons. It registers a durable `theme` store and reflects its value onto `<html data-theme="…">`:
|
|
974
1211
|
|
|
975
1212
|
```wd
|
|
976
1213
|
:theme
|
|
@@ -983,7 +1220,7 @@ Because `tokens dark` already emits the `[data-theme="dark"]` rule, **no extra s
|
|
|
983
1220
|
|
|
984
1221
|
## Scoped styles — `scoped`
|
|
985
1222
|
|
|
986
|
-
|
|
1223
|
+
By default a colocated `.skin` is **global** — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called `.card`, global CSS makes them fight. Opt a skin into **scoping** so its selectors only ever match the component it ships with: make the **first line** of the `.skin` file the word `scoped`.
|
|
987
1224
|
|
|
988
1225
|
```skin
|
|
989
1226
|
scoped
|
|
@@ -1097,7 +1334,7 @@ Every author-facing compile error opens with a stable `WDxxx` code, so it is sea
|
|
|
1097
1334
|
Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
|
|
1098
1335
|
```
|
|
1099
1336
|
|
|
1100
|
-
The full list lives in [`docs/errors.md`](docs/errors.md)
|
|
1337
|
+
The full list lives in [`docs/errors.md`](https://github.com/ZVN-DEV/darkmown/blob/master/docs/errors.md): code, cause, fix, and a compilable example each. (It is generated into the repository, not the published tarball; `darkmown catalog --llms-full` prints the same content offline.) Codes are grouped by subsystem:
|
|
1101
1338
|
|
|
1102
1339
|
| Range | Subsystem |
|
|
1103
1340
|
| --- | --- |
|
|
@@ -1168,7 +1405,7 @@ session.call("validate", {});
|
|
|
1168
1405
|
|
|
1169
1406
|
## Editor support
|
|
1170
1407
|
|
|
1171
|
-
A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
|
|
1408
|
+
A VS Code extension in [`editors/vscode`](https://github.com/ZVN-DEV/darkmown/tree/master/editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
|
|
1172
1409
|
|
|
1173
1410
|
Install from a `.vsix` today:
|
|
1174
1411
|
|
|
@@ -1177,7 +1414,7 @@ npm run pack:extension # builds editors/vscode/darkmown-<version>.vsix
|
|
|
1177
1414
|
code --install-extension editors/vscode/darkmown-*.vsix
|
|
1178
1415
|
```
|
|
1179
1416
|
|
|
1180
|
-
Or inside VS Code: Extensions panel → `…` menu → **Install from VSIX…** and pick the built file. A Visual Studio Marketplace listing is pending (see [`editors/vscode/PUBLISHING.md`](editors/vscode/PUBLISHING.md)); until it lands, the `.vsix` route above is the supported install.
|
|
1417
|
+
Or inside VS Code: Extensions panel → `…` menu → **Install from VSIX…** and pick the built file. A Visual Studio Marketplace listing is pending (see [`editors/vscode/PUBLISHING.md`](https://github.com/ZVN-DEV/darkmown/blob/master/editors/vscode/PUBLISHING.md)); until it lands, the `.vsix` route above is the supported install.
|
|
1181
1418
|
|
|
1182
1419
|
## Accessibility
|
|
1183
1420
|
|
|
@@ -1187,6 +1424,7 @@ Every compiled page ships with landmark-and-announcement basics baked in at buil
|
|
|
1187
1424
|
- **Document language.** `lang:` frontmatter sets `<html lang>` per page (default `en`).
|
|
1188
1425
|
- **Live `:fetch` regions.** A bare `:if name_loading` region over a `:fetch` key compiles to `role="status" aria-live="polite"` and `:if name_error` to `role="alert"`, so screen readers announce loading and error flips for free. Write your own `role`/`aria-live` inside the region and Darkmown adds nothing.
|
|
1189
1426
|
- **Accessible names on form controls.** Generated `:input`/`:bind`/`:textarea`/`:select` controls without an author-supplied `aria-label`/`aria-describedby` get an `aria-label` derived from the placeholder or field name; `:slider` and choice groups do the same.
|
|
1427
|
+
- **`role`, `aria-*`, and `title` on containers and buttons.** `::: card role="region" aria-label="Notes"` and `:button "Menu" aria-expanded="false" -> open toggle` compile without raw HTML. Values are static, double-quoted, and escaped on emit. See [Accessibility attributes](#accessibility-attributes) for the whole rule.
|
|
1190
1428
|
|
|
1191
1429
|
## Security
|
|
1192
1430
|
|
|
@@ -1211,6 +1449,35 @@ Builds emit security response headers so a deployed site gets sane defaults with
|
|
|
1211
1449
|
|
|
1212
1450
|
**If you use `:fetch` or `:form action=` against another host, widen `connect-src`.** The shipped CSP allows same-origin connections; calling a third-party API is otherwise blocked by the policy. Add the host to `connect-src` in your deploy config (it is not auto-derived from your page sources). The CSP is a defense-in-depth layer — it does not replace the trust-boundary rules above; see [SECURITY.md](SECURITY.md) for tightening guidance.
|
|
1213
1451
|
|
|
1452
|
+
## Limits
|
|
1453
|
+
|
|
1454
|
+
Darkmown is small on purpose. Here is what it deliberately refuses and what it genuinely cannot do today, collected in one place so you can decide before you build rather than after.
|
|
1455
|
+
|
|
1456
|
+
**Refusals that will not change:**
|
|
1457
|
+
|
|
1458
|
+
- **`.md` never gets directives.** The extension is the feature gate, and it holds transitively: an `.md` page that `@include`s a `.wd` still ships `runtime: false` and inert text.
|
|
1459
|
+
- **No `eval`, and a closed expression vocabulary.** Predicates, `:computed`, and directive actions are compile-time whitelists interpreted by a closed evaluator. There are no custom format pipes and no user-supplied functions anywhere in the language. (That is also why reactive pages need no `'unsafe-eval'`.)
|
|
1460
|
+
- **No backend DSL.** `api/` is plain Web-standard JavaScript. Darkmown owns no server and never will.
|
|
1461
|
+
- **No built-in sanitizer.** The model is trusted-author; see [Security](#security).
|
|
1462
|
+
|
|
1463
|
+
**Structural limits, true today:**
|
|
1464
|
+
|
|
1465
|
+
- **No per-request rendering.** Everything is build time. A `:state`-seeded `@loop` *does* bake its rows into the initial HTML, so that content is indexable, but anything behind `:fetch` is invisible to crawlers and to your own `sitemap.xml`. No personalization, no draft preview URLs.
|
|
1466
|
+
- **Auth is a public shell with `api/`-gated data.** A `:if session` gate ships *both* branches into the HTML, and a `:state`/`:store` seed is inlined into public HTML, so a token must never be written into a page (see [Authenticated requests](#authenticated-requests-and-token-refresh)). The workable architecture is a public page whose data is gated behind your own endpoint.
|
|
1467
|
+
- **No layout or shell inheritance.** Every page includes its own nav and footer. There is no template a page extends and no slot to fill.
|
|
1468
|
+
- **No arbitrary `<head>` content.** The document head is what frontmatter drives; you cannot inject your own tags.
|
|
1469
|
+
- **No route generation from data.** Collections are folders of files. A JSON array cannot become routes. `paginate N` is the one route multiplier, and only over an existing collection listing.
|
|
1470
|
+
- **Only accessibility attributes on containers and buttons.** `:::` and `:button` accept `role="…"`, `aria-…="…"`, and `title="…"`, and nothing else: `class=`, `id=`, `style=`, `data-*`, and event handlers are compile errors by design. Style with `.class` tokens, act with `->` actions, and reach for raw HTML on an `html: true` page when you genuinely need another attribute.
|
|
1471
|
+
- **Aria values are static text.** There is no `{ state }` interpolation inside `role`/`aria-*`/`title`, so a live accessible name means raw HTML or a colocated `.js`.
|
|
1472
|
+
- **Raw HTML attributes do not bind.** A Markdown link or image destination does ([Destinations that bind](#destinations-that-bind)), but `<a href="{ url }">` on an `html: true` page is painted once at build time and warned about. There is no element for the compiler to mark up.
|
|
1473
|
+
- **Reactive rows cannot fill a Markdown table.** A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so `[WD191]` refuses it. Static loops fill tables fine; for reactive rows, build them from containers (`::: trow` / `::: td`) and style them as a table.
|
|
1474
|
+
- **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
|
|
1475
|
+
- **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
|
|
1476
|
+
- **Includes are macros, not components.** No children, no slots, no default arguments, and a missing argument renders `{ title }` literally rather than erroring.
|
|
1477
|
+
- **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile--compilefrommemory) against your own pages, or a browser test runner.
|
|
1478
|
+
|
|
1479
|
+
**Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. There is no server to port, no proprietary component format, no framework runtime baked through the output, and nothing to un-eject. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working. The lock-in is a directory of Markdown files you already own.
|
|
1480
|
+
|
|
1214
1481
|
## Spec status
|
|
1215
1482
|
|
|
1216
1483
|
See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.
|