@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/site/pages/docs/index.wd
CHANGED
|
@@ -26,6 +26,7 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
|
|
|
26
26
|
- [Install](#install)
|
|
27
27
|
- [The two formats](#the-two-formats)
|
|
28
28
|
- [Routing rules](#routing-rules)
|
|
29
|
+
- [Directive lines](#directive-lines)
|
|
29
30
|
- [Includes](#includes)
|
|
30
31
|
- [Loops](#loops)
|
|
31
32
|
- [Content collections](#content-collections)
|
|
@@ -38,21 +39,29 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
|
|
|
38
39
|
- [Drafts](#drafts)
|
|
39
40
|
- [Instant navigation](#instant-navigation)
|
|
40
41
|
- [Images](#images)
|
|
42
|
+
- [Static assets](#static-assets)
|
|
41
43
|
- [Sections](#sections)
|
|
42
44
|
- [Reactive directives](#reactive-directives)
|
|
43
45
|
- [Fetching data](#fetching-data)
|
|
44
46
|
- [Global state — `:store`](#global-state--store)
|
|
47
|
+
- [URL as state](#url-as-state-from-url)
|
|
45
48
|
- [Forms and persistence](#forms-and-persistence)
|
|
46
49
|
- [Backends & deploy](#backends--deploy)
|
|
47
50
|
- [Interactions](#interactions--slider-sortable-carousel)
|
|
48
51
|
- [Theme toggle — `:theme`](#theme-toggle--theme)
|
|
49
52
|
- [Scoped styles — `scoped`](#scoped-styles--scoped)
|
|
53
|
+
- [Inline attributes](#inline-attributes)
|
|
50
54
|
- [Media directives](#media--video-audio-embed)
|
|
51
55
|
- [Syntax highlighting](#syntax-highlighting)
|
|
52
|
-
- [Trust boundary](#trust-boundary)
|
|
53
56
|
- [Colocation](#colocation)
|
|
57
|
+
- [Programmatic compile](#programmatic-compile-compilefrommemory)
|
|
58
|
+
- [AI authoring](#ai-authoring)
|
|
54
59
|
- [Compile error codes](#compile-error-codes)
|
|
55
60
|
- [CLI reference](#cli-reference)
|
|
61
|
+
- [Editor support](#editor-support)
|
|
62
|
+
- [Accessibility](#accessibility)
|
|
63
|
+
- [Security](#security)
|
|
64
|
+
- [Limits](#limits)
|
|
56
65
|
- [Spec status](#spec-status)
|
|
57
66
|
</nav>
|
|
58
67
|
|
|
@@ -73,7 +82,7 @@ Or add Darkmown to an existing project with `npm install -D @zvndev/darkmown`. T
|
|
|
73
82
|
|
|
74
83
|
Want to try the syntax before installing anything? The [playground](/playground/) compiles `.wd` and `.md` live in your browser — the same compiler the CLI ships — and shows the real `file:line` error when you break something.
|
|
75
84
|
|
|
76
|
-
For editing, a
|
|
85
|
+
For editing, there is a VS Code extension for `.wd` and `.skin` files: see [Editor support](#editor-support).
|
|
77
86
|
|
|
78
87
|
`darkmown dev` rebuilds incrementally: it tracks which routes depend on each file (includes, colocated assets, loop data, collections), so editing one page or include recompiles only the routes it affects — and falls back to a full rebuild whenever a file is added, removed, or renamed. `routes.json`, the sitemap, and the RSS feed are regenerated on every rebuild, so they always describe the whole site.
|
|
79
88
|
|
|
@@ -89,6 +98,15 @@ For editing, a [VS Code extension](https://github.com/ZVN-DEV/darkmown/tree/mast
|
|
|
89
98
|
- `.secret.wd`, `-draft.wd`, and hidden folders do not become pages.
|
|
90
99
|
- `site/_/` is the include shelf, never a route.
|
|
91
100
|
|
|
101
|
+
## Directive lines
|
|
102
|
+
|
|
103
|
+
A directive is a whole line, written flush against the left margin. Four rules cover what the compiler will and will not accept there.
|
|
104
|
+
|
|
105
|
+
- **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.
|
|
106
|
+
- **A bare keyword is an error.** A line that is exactly `:state`, `:button`, `@loop`, or any other directive that takes 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 a lone `:state` gives `[WD201]` and a lone `:button` 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.
|
|
107
|
+
- **To show a directive name as text, escape it.** Writing `\:fetch` renders the literal text `:fetch`. It is the ordinary CommonMark backslash escape, so it works anywhere Markdown does.
|
|
108
|
+
- **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.
|
|
109
|
+
|
|
92
110
|
## Includes
|
|
93
111
|
|
|
94
112
|
- `@include /nav.wd` resolves from `site/_`.
|
|
@@ -207,6 +225,25 @@ A **missing** in-scope source is an empty list, not an error — `@loop meta.tag
|
|
|
207
225
|
|
|
208
226
|
These clauses stay build-time when the source and every argument are static — a sorted, limited loop over a JSON file ships zero JS. The loop turns reactive only when the source or a clause reads `:state`/`:store`/`:fetch` data.
|
|
209
227
|
|
|
228
|
+
### Table rows from a loop
|
|
229
|
+
|
|
230
|
+
A **static** loop whose body is bare `| … |` cells fills a Markdown table. Write the header in prose and let the loop supply the rows:
|
|
231
|
+
|
|
232
|
+
```wd
|
|
233
|
+
| Item | Price |
|
|
234
|
+
| --- | --- |
|
|
235
|
+
@loop /products.json into row
|
|
236
|
+
| **{ row.name }** | { row.price } |
|
|
237
|
+
@endloop
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
That renders one `<table>` with one `<tr>` per row, and each cell holds ordinary inline Markdown, links included. A format pipe works too, but its `|` has to be escaped inside a 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.
|
|
241
|
+
|
|
242
|
+
Two limits to know before building on it:
|
|
243
|
+
|
|
244
|
+
- A pipe row written in prose **after** `@endloop` (a totals row) does not join the table. It stays a paragraph.
|
|
245
|
+
- 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`.
|
|
246
|
+
|
|
210
247
|
## Content collections
|
|
211
248
|
|
|
212
249
|
Any folder under `site/pages/` is a **collection** — loop it by its bare name. No `content/` root, no marker file: a `site/pages/blog/` folder of posts *is* the `blog` collection. The whole [blog on this site](/blog/) is one such loop.
|
|
@@ -262,6 +299,31 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
|
262
299
|
- If the name is declared `:state`, it becomes a live binding.
|
|
263
300
|
- The page's frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
|
|
264
301
|
- Otherwise the text is left exactly as written — braces in prose never break a page or pull in the runtime.
|
|
302
|
+
- Values resolve **inside a link or image destination** too, and every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)` works.
|
|
303
|
+
- Values resolve in **raw HTML** as well (attributes and html blocks, HTML-escaped) on an `html: true` page, but those are painted once at build time rather than bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `` `[x](/p/{ p.slug }/)` `` as syntax.
|
|
304
|
+
|
|
305
|
+
### Destinations that bind
|
|
306
|
+
|
|
307
|
+
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:
|
|
308
|
+
|
|
309
|
+
```wd
|
|
310
|
+
:state region = "eu"
|
|
311
|
+
|
|
312
|
+
:button "EU" -> region = "eu"
|
|
313
|
+
:button "US" -> region = "us"
|
|
314
|
+
|
|
315
|
+
[Open the docs for this region](/docs/{ region }/)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
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`: `- [{ p.name }](/products/{ p.slug }/)`.
|
|
319
|
+
|
|
320
|
+
Three rules worth knowing:
|
|
321
|
+
|
|
322
|
+
- **Values in URL position are percent-encoded**, so a `)`, a space, or an angle bracket coming from your data cannot end the link and hand the remainder back to the Markdown parser.
|
|
323
|
+
- **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 when the value resolves to `javascript:`, `data:`, or `vbscript:`.
|
|
324
|
+
- **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`.
|
|
325
|
+
|
|
326
|
+
See it live on the [reactive links demo](/attr-binding/).
|
|
265
327
|
|
|
266
328
|
### Format pipes — `{ value | name:arg }`
|
|
267
329
|
|
|
@@ -300,6 +362,10 @@ site_url: https://example.com
|
|
|
300
362
|
|
|
301
363
|
A page becomes an **RSS post** by carrying a `date:`; `excerpt:` is its feed summary (else `description:`, else — for a plain `.md` — its first paragraph). The sitemap's `<lastmod>` is the `date:` if set, else the file's git/last-modified date. `robots.txt` is always written; the `Sitemap:` line only appears with a `site_url`. Without `site_url`, robots still emits and the build prints a hint — it never breaks.
|
|
302
364
|
|
|
365
|
+
The feed carries the 20 newest posts by default. Set `rss_limit: 50` on the home page to change that. It takes digits only (a positive whole number of items), so `1e3`, `0x10`, and `7.0` are refused with `[WD950]` rather than silently coerced.
|
|
366
|
+
|
|
367
|
+
A sitemap over 50,000 URLs shards automatically into `sitemap-N.xml` files behind a `<sitemapindex>`, and a `date:` that is not a real `yyyy-mm-dd` is omitted from `<lastmod>` with a build hint instead of writing a broken value.
|
|
368
|
+
|
|
303
369
|
## Canonical URLs
|
|
304
370
|
|
|
305
371
|
With `site_url` set, every page states its own absolute URL twice, as `<link rel="canonical">` and as `og:url`, both built from the **same route string** the sitemap and your internal links use, so nothing can drift out of agreement:
|
|
@@ -363,15 +429,51 @@ Add `transitions: true` to a page's frontmatter for **instant, flash-free naviga
|
|
|
363
429
|
|
|
364
430
|
You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
|
|
365
431
|
|
|
432
|
+
## Static assets
|
|
433
|
+
|
|
434
|
+
Non-page files live on the include shelf at `site/_/` and are copied into the build untouched:
|
|
435
|
+
|
|
436
|
+
- Any non-`.md`/`.wd` shelf file is served at `/__wd/media/<path>`, so `site/_/logo.svg` becomes `/__wd/media/logo.svg`. Reference it with a normal URL: ``.
|
|
437
|
+
- Shelf `.json` files are served at `/__wd/data/<name>`, which is what `:fetch` reads. That is why `:fetch` works on any plain static host, with no server.
|
|
438
|
+
- A file colocated under `site/pages/` copies to `dist/` at its own path instead (see [Colocation](#colocation)), so `site/pages/logo.svg` is served at `/logo.svg`.
|
|
439
|
+
|
|
440
|
+
Use the shelf for assets shared across many pages, and colocate the ones a single page owns. Both ship with the right content-type.
|
|
441
|
+
|
|
366
442
|
## Sections
|
|
367
443
|
|
|
368
444
|
`::: section #id .class` opens a container and `:::` closes it. Sections scope state: a `:state` declared inside a section belongs to that section, so two sections can both declare `count` without colliding. Bindings and buttons resolve to the nearest scope.
|
|
369
445
|
|
|
370
446
|
A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) so pages keep proper landmarks without raw HTML. Any other name stays a `<div>` with that class.
|
|
371
447
|
|
|
448
|
+
### Accessibility attributes
|
|
449
|
+
|
|
450
|
+
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 compile, on exactly two directives (`:::` and `:button`), always with a **double-quoted static value**:
|
|
451
|
+
|
|
452
|
+
```wd
|
|
453
|
+
:state open = false
|
|
454
|
+
|
|
455
|
+
::: card .note role="region" aria-label="Release notes" title="What changed"
|
|
456
|
+
Notes go here.
|
|
457
|
+
:::
|
|
458
|
+
|
|
459
|
+
::: nav .menu role="navigation" aria-label="Main"
|
|
460
|
+
[Docs](/docs/)
|
|
461
|
+
:::
|
|
462
|
+
|
|
463
|
+
:button "Menu" aria-expanded="false" aria-controls="m" -> open toggle
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
- On a container they interleave freely with `.class` and `#id` tokens and coexist with `.class when <predicate>`.
|
|
467
|
+
- 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.
|
|
468
|
+
- Values are HTML-escaped on emit, so nothing you write can close the attribute or open another.
|
|
469
|
+
- Anything outside the whitelist is `[WD650]`: `onclick=`, `style=`, `href=`, `class=`, `id=`, and `data-*` are refused, and so is `ARIA-LABEL` (the match is case-sensitive) or a single-quoted value. A whitelisted name with no double-quoted value is `[WD651]`.
|
|
470
|
+
- **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`.
|
|
471
|
+
|
|
472
|
+
Compile-time only: zero runtime bytes, and a static page carrying attributes stays `runtime: false`.
|
|
473
|
+
|
|
372
474
|
## Reactive directives
|
|
373
475
|
|
|
374
|
-
Reactive pages opt into `/__wd/runtime.js` (~
|
|
476
|
+
Reactive pages opt into `/__wd/runtime.js`, a minified build of the readable `src/runtime.js` (~6.6 KB gzipped, under a CI-enforced 8 KB budget), with an external sourcemap beside it at `/__wd/runtime.js.map` carrying the original source, so DevTools shows real names and comments. Development serves the same bytes as production. Static pages ship none of it.
|
|
375
477
|
|
|
376
478
|
```wd
|
|
377
479
|
:state count = 0
|
|
@@ -423,6 +525,8 @@ The aggregate's field is a bare row key (`price`, not `item.price`).
|
|
|
423
525
|
:every 1s -> secs++
|
|
424
526
|
```
|
|
425
527
|
|
|
528
|
+
`:every` is a **page-level** registration. A reactive `@loop` compiles its body once into a template and the runtime clones it per row, so a timer 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 (N literal copies that never churn), and in a reactive loop's `@empty` branch. A `:button` inside a reactive loop is unaffected.
|
|
529
|
+
|
|
426
530
|
### Multi-branch conditionals
|
|
427
531
|
|
|
428
532
|
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.) Chain with `:else if` (any number; a bare `:else` must come last):
|
|
@@ -439,6 +543,8 @@ Free plan
|
|
|
439
543
|
|
|
440
544
|
A whole chain compiles to nested conditional regions, so it folds at build time when every value is static and stays reactive otherwise — exactly like a single `:if`.
|
|
441
545
|
|
|
546
|
+
**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. See the [closed-branch demo](/branch-state/).
|
|
547
|
+
|
|
442
548
|
### Reactive classes — `.class when …`
|
|
443
549
|
|
|
444
550
|
A container class can react to state or the loop item. Static `.class` tokens stay as-is; add `when <predicate>` for a reactive one:
|
|
@@ -464,7 +570,7 @@ The predicate uses the `:if` whitelist (item fields, `:state`, numbers, strings,
|
|
|
464
570
|
:effect q -> searches++
|
|
465
571
|
```
|
|
466
572
|
|
|
467
|
-
Effects run after a render against settled state, never on the initial load, with a 10-pass settle cap guarding against loops.
|
|
573
|
+
Effects run after a render against settled state, never on the initial load, with a 10-pass settle cap guarding against loops. Like `:every`, an `: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.
|
|
468
574
|
|
|
469
575
|
### Button actions
|
|
470
576
|
|
|
@@ -482,11 +588,13 @@ Values are literals — a `"string"`, number, `true`/`false`/`null`, or inline J
|
|
|
482
588
|
:button "Add to cart" -> cart.count++ ; cart.total += 9
|
|
483
589
|
```
|
|
484
590
|
|
|
591
|
+
A `:button` also accepts the three [accessibility attributes](#accessibility-attributes), written between the label and the `->`: `:button "Menu" aria-expanded="false" -> open toggle`.
|
|
592
|
+
|
|
485
593
|
`list toggle v` and `list remove v` match by value, which is exact for strings and numbers but not for object members — remove row objects with the per-row `remove` action instead.
|
|
486
594
|
|
|
487
595
|
## Fetching data
|
|
488
596
|
|
|
489
|
-
`:fetch name from "url"` declares state and fills it from JSON over the network. Shelf `.json` files are served at `/__wd/data/`. Each fetch auto-declares
|
|
597
|
+
`:fetch name from "url"` declares state and fills it from JSON over the network. Shelf `.json` files are served at `/__wd/data/`. Each fetch auto-declares five states you can branch on: `name`, `name_loading`, `name_error`, `name_error_body`, and `name_empty`.
|
|
490
598
|
|
|
491
599
|
```wd
|
|
492
600
|
:fetch team from "/__wd/data/team.json" timeout=8000 retry=2
|
|
@@ -508,6 +616,28 @@ The lifecycle regions announce themselves to assistive tech: a bare `:if name_lo
|
|
|
508
616
|
|
|
509
617
|
Options after the URL: `method=` (default `GET`), `when=load|visible`, `timeout=<ms>`, `retry=<N>`, `headers=<key>`, and `body=<key>` (the last two name a `:state`/`:store` key). A URL can interpolate state (`"/api/users/{ userId }"`) and re-fetches automatically when that state changes; trigger a reload by hand with `:button "Reload" -> team refetch`. Loop a sub-path of fetched data with a dotted source — `@loop team.members into member`. URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation — other schemes are rejected at compile time.
|
|
510
618
|
|
|
619
|
+
### What the server said
|
|
620
|
+
|
|
621
|
+
A real API explains why it refused, and Darkmown surfaces that instead of a status line. When the failing response has a JSON body:
|
|
622
|
+
|
|
623
|
+
- **`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.
|
|
624
|
+
- **`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.
|
|
625
|
+
|
|
626
|
+
Given a `422` whose body is `{"error": "Pick a file first.", "fields": {"photo": "No file was attached."}}`:
|
|
627
|
+
|
|
628
|
+
```wd
|
|
629
|
+
:fetch signup from "/api/signup" method=POST
|
|
630
|
+
|
|
631
|
+
:if signup_error
|
|
632
|
+
**{ signup_error }**
|
|
633
|
+
:if signup_error_body
|
|
634
|
+
Photo: { signup_error_body.fields.photo }
|
|
635
|
+
:endif
|
|
636
|
+
:endif
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
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. See the [server errors demo](/api-errors/).
|
|
640
|
+
|
|
511
641
|
### Authenticated requests
|
|
512
642
|
|
|
513
643
|
`headers=<key>` sends a state object as request headers; pair it with `:store` to persist a bearer token. Add `refresh="<url>"` and a `401` triggers a token refresh: Darkmown POSTs the current headers to the refresh URL, writes the renewed token back into the `headers` state, and retries the request once (concurrent `401`s share one refresh).
|
|
@@ -536,6 +666,44 @@ You have { cart } items.
|
|
|
536
666
|
- On a multi-line seed, a `persist`/`ephemeral` token goes **after the closing bracket on the last line**, not on the declaration line.
|
|
537
667
|
- `persist` means "survives a reload", `ephemeral` means "does not", and both are accepted on `:state`, `:store`, and `:theme`. The keyword only picks the default. `:store sidebarOpen = false ephemeral` is an in-memory store; `:state cart = [] persist` is a persisted one. Writing the token that matches the default is redundant but never wrong.
|
|
538
668
|
- `:computed` takes neither: computed values are derived rather than stored, so persist the state they derive from instead (`WD211`).
|
|
669
|
+
- 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.
|
|
670
|
+
|
|
671
|
+
## URL as state: `from-url`
|
|
672
|
+
|
|
673
|
+
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.
|
|
674
|
+
|
|
675
|
+
```wd
|
|
676
|
+
:state q = "" from-url
|
|
677
|
+
:state tier = "all" from-url
|
|
678
|
+
|
|
679
|
+
:bind q placeholder="Search products"
|
|
680
|
+
|
|
681
|
+
:radio tier
|
|
682
|
+
- all
|
|
683
|
+
- budget
|
|
684
|
+
- premium
|
|
685
|
+
|
|
686
|
+
:state products = [{"name": "Aurora Lamp", "tier": "budget"}]
|
|
687
|
+
|
|
688
|
+
@loop products into p where p.name contains q
|
|
689
|
+
- **{ p.name }** ({ p.tier })
|
|
690
|
+
@empty
|
|
691
|
+
Nothing matches that search.
|
|
692
|
+
@endloop
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
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.
|
|
696
|
+
|
|
697
|
+
The rules:
|
|
698
|
+
|
|
699
|
+
- **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.
|
|
700
|
+
- **A value equal to its declared seed drops its parameter,** so the default page keeps a clean URL.
|
|
701
|
+
- **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.
|
|
702
|
+
- **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.
|
|
703
|
+
- **Strings stay strings.** For any other seed type the parameter is JSON-parsed, falling back to the raw string.
|
|
704
|
+
- **`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.
|
|
705
|
+
|
|
706
|
+
Live demo: [/url-state/](/url-state/).
|
|
539
707
|
|
|
540
708
|
## Forms and persistence
|
|
541
709
|
|
|
@@ -545,6 +713,57 @@ You have { cart } items.
|
|
|
545
713
|
- `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
|
|
546
714
|
- Reactive pages expose `window.wd` (`get`, `set`, `subscribe`, `state`, `render`) so colocated `.js` can do anything directives can't. Section state is addressed as `sectionId:name`.
|
|
547
715
|
|
|
716
|
+
### Bound controls outside a form
|
|
717
|
+
|
|
718
|
+
`: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.
|
|
719
|
+
|
|
720
|
+
```wd
|
|
721
|
+
:state density = "Comfortable"
|
|
722
|
+
:state previews = true
|
|
723
|
+
|
|
724
|
+
:select density
|
|
725
|
+
- Compact
|
|
726
|
+
- Comfortable
|
|
727
|
+
- Spacious
|
|
728
|
+
|
|
729
|
+
:checkbox previews
|
|
730
|
+
- Show image previews
|
|
731
|
+
|
|
732
|
+
:if density == "Compact"
|
|
733
|
+
Rows sit tight together.
|
|
734
|
+
:endif
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
- **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`.
|
|
738
|
+
- **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.
|
|
739
|
+
- **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.
|
|
740
|
+
|
|
741
|
+
See the [settings demo](/settings/).
|
|
742
|
+
|
|
743
|
+
### File upload
|
|
744
|
+
|
|
745
|
+
A `:form` that contains a file field posts **multipart**, so the file itself travels:
|
|
746
|
+
|
|
747
|
+
```wd
|
|
748
|
+
:form into reply action="/api/upload/"
|
|
749
|
+
:input photo type=file required
|
|
750
|
+
:input caption placeholder="A caption (optional)"
|
|
751
|
+
:submit "Upload"
|
|
752
|
+
:endform
|
|
753
|
+
|
|
754
|
+
:if reply
|
|
755
|
+
Uploaded **{ reply.name }**, { reply.size } bytes.
|
|
756
|
+
:endif
|
|
757
|
+
|
|
758
|
+
:if reply_error
|
|
759
|
+
**{ reply_error }**
|
|
760
|
+
:endif
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
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.
|
|
764
|
+
|
|
765
|
+
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. See the [upload demo](/upload/).
|
|
766
|
+
|
|
548
767
|
See it all live on the [Data & Forms page](/data/).
|
|
549
768
|
|
|
550
769
|
## Backends & deploy
|
|
@@ -559,12 +778,12 @@ Darkmown builds 100% static HTML — never per-request rendering. When you need
|
|
|
559
778
|
|
|
560
779
|
`darkmown init shop --template store` scaffolds a cart **and** its `api/checkout.js`; `--template dashboard` ships a `:fetch` view **and** `api/metrics.js`.
|
|
561
780
|
|
|
562
|
-
## Interactions — `:slider`,
|
|
781
|
+
## Interactions — `:slider`, `sortable`, `:carousel`
|
|
563
782
|
|
|
564
|
-
Pay-for-what-you-use:
|
|
783
|
+
Pay-for-what-you-use: `sortable`/`:carousel` compile to a tiny `/__wd/behaviors/*.js` module loaded only where used, budgeted separately from the ≤8 KB core runtime. `:slider` is compile-time only.
|
|
565
784
|
|
|
566
785
|
- `:slider name = v min=0 max=100 step=5` — a range input bound through `:bind`; range values coerce to Number.
|
|
567
|
-
-
|
|
786
|
+
- `sortable` (a bare `@loop` clause, written without a leading colon) — drag-to-reorder a `:state`/`:store` list via Pointer Events, with keyboard (Arrow Up/Down) + screen-reader announcements; valid on a plain reactive loop only.
|
|
568
787
|
- `:carousel [autoplay=N]` — each direct child block is one slide (wrap each in its own block, e.g. `::: slide`); native scroll-snap plus prev/next, dots, and mouse drag. Autoplay respects `prefers-reduced-motion`.
|
|
569
788
|
|
|
570
789
|
See all three live on the [Interactions page](/interactions/).
|
|
@@ -605,6 +824,16 @@ compiles to `.card[data-wd-scope="wd-7c21"] { … }`, and the component's markup
|
|
|
605
824
|
|
|
606
825
|
Honest limits this release: only **whole-selector** `:global()` (no `.card :global(.x)` yet), and an **unused** scoped selector **warns but is not removed** (a colocated `.js` may add the class). See the side-by-side [Scoped demo](/scoped/).
|
|
607
826
|
|
|
827
|
+
## Inline attributes
|
|
828
|
+
|
|
829
|
+
A trailing `{.class .class #id}` attaches classes or an id to the inline element directly before it, most often to style a link as a button without a wrapper:
|
|
830
|
+
|
|
831
|
+
```wd
|
|
832
|
+
[Get started](/start/){.btn .lg}
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
The block must follow the element with no space. It works on links, images, and emphasis, and it does **not** attach to inline code. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.` or `#`. This page uses it: a link marked `{.no-prefetch}` is excluded from the `transitions: true` prerender hint.
|
|
836
|
+
|
|
608
837
|
## Media — `:video`, `:audio`, `:embed`
|
|
609
838
|
|
|
610
839
|
Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):
|
|
@@ -646,16 +875,6 @@ tokens dark
|
|
|
646
875
|
|
|
647
876
|
The framework ships a small default `$code-*` set, so highlighting looks right out of the box. The stylesheet is **pay-for-what-you-use**: it's emitted and linked only on pages that actually contain a highlighted block — a page with no code ships nothing extra. A fence with an unknown or absent language renders as plain escaped `<code>` (no highlighting, no error); inline `` `code` `` is never highlighted, and there are deliberately no line numbers (they break copy-paste). See the live [Syntax highlighting demo](/highlight/).
|
|
648
877
|
|
|
649
|
-
## Trust boundary
|
|
650
|
-
|
|
651
|
-
Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.
|
|
652
|
-
|
|
653
|
-
- **Compile only content you authored.** Don't run `.md`/`.wd` you didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first.
|
|
654
|
-
- **Raw HTML is escaped by default.** Markdown renders with `html: false`, so a stray `<script>` in content becomes inert text — safe for multi-author collections out of the box. Set `html: true` in a page's frontmatter to pass your own raw HTML through verbatim; there is no built-in sanitizer, so keep it off pages with content you didn't write.
|
|
655
|
-
- **`:fetch` and `:form action=` have no host allowlist.** Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Since 2.1 the runtime **interprets** validated `:computed` / `@loop … where` / `.class when` expressions from a compact AST instead of building a `new Function`, so **reactive pages need no `'unsafe-eval'`** — they run under the same strict, eval-free CSP as static pages.
|
|
656
|
-
|
|
657
|
-
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) in the package for the full security model.
|
|
658
|
-
|
|
659
878
|
## Colocation
|
|
660
879
|
|
|
661
880
|
- A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
|
|
@@ -663,6 +882,21 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) i
|
|
|
663
882
|
- Both work for included fragments too, by basename.
|
|
664
883
|
- Any other file under `site/pages/` (an image, font, PDF — anything that isn't a `.md`/`.wd` route or a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type. So `site/pages/logo.svg` is served at `/logo.svg`. Assets below hidden path segments (`.`, `-`, or `_`) and symlinked page assets are skipped, matching the routing privacy rule. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
|
|
665
884
|
|
|
885
|
+
## Programmatic compile: `compileFromMemory`
|
|
886
|
+
|
|
887
|
+
`darkmown build` reads from disk, but the compiler itself is filesystem-free. `compileFromMemory(files, entryPath, options)` compiles a page from an in-memory map of project-relative path to source, so the whole compile path bundles for the browser or any other non-Node host:
|
|
888
|
+
|
|
889
|
+
```js
|
|
890
|
+
import { compileFromMemory } from "@zvndev/darkmown";
|
|
891
|
+
|
|
892
|
+
const { html, assets } = compileFromMemory(
|
|
893
|
+
{ "site/pages/index.wd": "---\ntitle: Hi\n---\n\n:state n = 0\n" },
|
|
894
|
+
"site/pages/index.wd"
|
|
895
|
+
);
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
Includes, colocated `.skin`/`.js` detection, and `@loop` JSON reads all resolve against the map (anything not in it is simply absent), and it throws the same `file:line` compile errors as the CLI. The [playground](/playground/) is built on exactly this entry point: markdown-it plus the compiler bundled into one asset, recompiling on every keystroke into an iframe. On disk, `compilePage(file, paths)` is the same compile with a filesystem reader injected for you.
|
|
899
|
+
|
|
666
900
|
## AI authoring
|
|
667
901
|
|
|
668
902
|
Darkmown ships a machine-readable description of its own language, so an AI tool can learn `.wd` and be constrained to output that compiles. It is all generated from the compiler's own tables, so it never drifts from the real grammar.
|
|
@@ -707,7 +941,7 @@ Every author-facing compile error opens with a stable `WDxxx` code, so it is sea
|
|
|
707
941
|
|
|
708
942
|
```
|
|
709
943
|
[WD201] Malformed :state in site/pages/index.wd:1: :state x.
|
|
710
|
-
Use: :state name = value [persist] — e.g. :state count = 0
|
|
944
|
+
Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
|
|
711
945
|
```
|
|
712
946
|
|
|
713
947
|
The code is mirrored on the thrown error as `err.wd.code`, alongside `file`, `line`, `hint`, and a compilable `example`. Codes are grouped by subsystem — `WD0xx` source and frontmatter, `WD1xx` loops, `WD2xx` state and expressions, `WD3xx` actions, `WD4xx` forms, `WD5xx` fetching, `WD6xx` includes and structure, `WD7xx` media, `WD8xx` skins, `WD9xx` project and CLI.
|
|
@@ -726,6 +960,73 @@ The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool
|
|
|
726
960
|
- `darkmown deploy <vercel|cloudflare> [--prod]` — build (target-aware) and deploy through the platform CLI, printing your URL; an unauthenticated CLI surfaces the login to run first.
|
|
727
961
|
- `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.
|
|
728
962
|
|
|
963
|
+
## Editor support
|
|
964
|
+
|
|
965
|
+
A [VS Code extension](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 instead of broken Markdown. Build a `.vsix` from the framework repo and install it with `code --install-extension`; a Marketplace listing is pending.
|
|
966
|
+
|
|
967
|
+
## Accessibility
|
|
968
|
+
|
|
969
|
+
Landmark and announcement basics are baked in at build time, so they cost zero JavaScript and a static page stays static:
|
|
970
|
+
|
|
971
|
+
- **Skip link.** The first focusable element on every page is a visually-hidden-until-focused "Skip to content" link. The shell guarantees it has a target: your own `<main>` is reused (an existing `id` wins, an id-less `<main>` gets `id="main"` stamped on), and a page without one is wrapped in `<main id="main">`. Restyle it through `.wd-skip-link` in a skin.
|
|
972
|
+
- **Document language.** `lang:` frontmatter sets `<html lang>` per page, defaulting to `en`.
|
|
973
|
+
- **Live `:fetch` regions.** A bare `:if name_loading` region compiles with `role="status" aria-live="polite"` and `:if name_error` with `role="alert"`, so screen readers announce the flips for free. A `role`/`aria-live` you write inside the region always wins.
|
|
974
|
+
- **Accessible names on controls.** `:input`, `:bind`, `:textarea`, `:select`, `:slider`, and the choice groups derive an `aria-label` from the placeholder, else a humanized field name, unless you supply one.
|
|
975
|
+
- **`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).
|
|
976
|
+
|
|
977
|
+
There is still a ceiling, and it is stated in [Limits](#limits): those three names are the whole attribute vocabulary, and their values are static text, so a live accessible name needs raw HTML or a colocated `.js`.
|
|
978
|
+
|
|
979
|
+
## Security
|
|
980
|
+
|
|
981
|
+
### Trust boundary
|
|
982
|
+
|
|
983
|
+
Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.
|
|
984
|
+
|
|
985
|
+
- **Compile only content you authored.** Don't run `.md`/`.wd` you didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first.
|
|
986
|
+
- **Raw HTML is escaped by default.** Markdown renders with `html: false`, so a stray `<script>` in content becomes inert text — safe for multi-author collections out of the box. Set `html: true` in a page's frontmatter to pass your own raw HTML through verbatim; there is no built-in sanitizer, so keep it off pages with content you didn't write.
|
|
987
|
+
- **`:fetch` and `:form action=` have no host allowlist.** Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Since 2.1 the runtime **interprets** validated `:computed` / `@loop … where` / `.class when` expressions from a compact AST instead of building a `new Function`, so **reactive pages need no `'unsafe-eval'`** — they run under the same strict, eval-free CSP as static pages.
|
|
988
|
+
|
|
989
|
+
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) in the package for the full security model.
|
|
990
|
+
|
|
991
|
+
### Shipped security headers
|
|
992
|
+
|
|
993
|
+
Builds emit security response headers, so a deployed site gets sane defaults without a hand-written config. The build writes `dist/_headers` (Cloudflare Pages format), and the Vercel and local `serve` paths apply the equivalent:
|
|
994
|
+
|
|
995
|
+
- **`Content-Security-Policy`** with no `'unsafe-inline'` and no `'unsafe-eval'` on `script-src`, on every page. The one inline script the framework emits that CSP gates (the `transitions: true` speculationrules block) is authorized by a build-time `'sha256-…'` hash, and the inline state seed is a non-executable JSON data block CSP does not gate. Static and reactive pages get the same strict policy.
|
|
996
|
+
- **`X-Content-Type-Options: nosniff`**, a **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
|
|
997
|
+
- A raw inline `<script>` you write into an `html: true` page is blocked by that policy. Put it in a colocated `.js` file (same-origin, allowed by `'self'`) or widen `script-src` deliberately.
|
|
998
|
+
- Calling another host with `:fetch` or `:form action=`? Widen `connect-src` (and `form-action` for a native form POST) in your deploy config. It is not derived from your page sources.
|
|
999
|
+
- The two embed origins (`youtube-nocookie.com`, `player.vimeo.com`) and `media-src 'self' https:` are pre-authorized, so `:embed` and remote media work out of the box.
|
|
1000
|
+
|
|
1001
|
+
## Limits
|
|
1002
|
+
|
|
1003
|
+
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.
|
|
1004
|
+
|
|
1005
|
+
**Refusals that will not change:**
|
|
1006
|
+
|
|
1007
|
+
- **`.md` never gets directives.** The extension is the feature gate, and it holds transitively: an `.md` page that includes a `.wd` still ships `runtime: false` and inert text.
|
|
1008
|
+
- **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.
|
|
1009
|
+
- **No backend DSL.** `api/` is plain Web-standard JavaScript. Darkmown owns no server and never will.
|
|
1010
|
+
- **No built-in sanitizer.** The model is trusted-author; see [Security](#security).
|
|
1011
|
+
|
|
1012
|
+
**Structural limits, true today:**
|
|
1013
|
+
|
|
1014
|
+
- **No per-request rendering.** Everything is build time. A `:state`-seeded `@loop` *does* bake its rows into the initial HTML, so it is indexable, but anything behind `:fetch` is invisible to crawlers and to the sitemap. No personalization, no draft preview URLs.
|
|
1015
|
+
- **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. The workable architecture is a public page whose data is gated behind your `api/` endpoint.
|
|
1016
|
+
- **No layout or shell inheritance.** Every page includes its own nav and footer. There is no template a page extends.
|
|
1017
|
+
- **No arbitrary `<head>` content.** The document head is what frontmatter drives; you cannot inject your own tags.
|
|
1018
|
+
- **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.
|
|
1019
|
+
- **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.
|
|
1020
|
+
- **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`.
|
|
1021
|
+
- **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.
|
|
1022
|
+
- **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.
|
|
1023
|
+
- **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
|
|
1024
|
+
- **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
|
|
1025
|
+
- **Includes are macros, not components.** No children, no slots, no default arguments, and a missing argument renders `{ title }` literally rather than erroring.
|
|
1026
|
+
- **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile-compilefrommemory) or a browser test runner.
|
|
1027
|
+
|
|
1028
|
+
**Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. No server to port, no proprietary component format, no framework baked into the output. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working.
|
|
1029
|
+
|
|
729
1030
|
## Spec status
|
|
730
1031
|
|
|
731
1032
|
The implementation is faithful to the original core thesis: Markdown-first authoring, no component ceremony, zero runtime on static pages, and tiny direct-DOM reactivity only when declared. `:store`, the full `:fetch` lifecycle (loading/error/empty states, dynamic URLs, `refetch`, dotted loop sources, authenticated `headers=` + `refresh=` token renewal), `:form` (including server round-trips), loop ergonomics (`sort by`/`limit`/`offset`/`reverse`/`@empty` and `$index`/`$number`/`$first`/`$last`/`$count`), `:if … :else if … :else`, reactive `.class when …`, `:effect`, `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/) and the [Reactive page](/reactive/). Still on the roadmap: HTML-fragment `swap` semantics and cart server sync. (The serverless `api/` model already shipped in 1.2.0 — see [Backends & deploy](#backends--deploy) above; a `.wd` backend DSL remains a deliberate non-goal.) See `docs/spec-alignment.md` in the package for the full audit.
|
package/site/pages/index.wd
CHANGED
|
@@ -162,7 +162,7 @@ The echo endpoint is only live on the deployed site: { reply_error }
|
|
|
162
162
|
## Three rules, no escape hatch
|
|
163
163
|
|
|
164
164
|
- **Zero-JS static.** A page with no state ships no framework JavaScript. Open [the docs](/docs/) or [a plain `.md` page](/markdown/) and check the network tab.
|
|
165
|
-
- **One runtime.** Every reactive page shares the same
|
|
165
|
+
- **One runtime.** Every reactive page shares the same 6,584-byte gzipped runtime, CI-enforced under 8 KB. No per-component bundles, no hydration tax that grows with your UI. It runs under a strict CSP with no `unsafe-eval`.
|
|
166
166
|
- **One loop, one binding.** `@loop … into …` is the only loop. `{ name }` is the only interpolation. There is no second way to do either.
|
|
167
167
|
|
|
168
168
|
## A whole framework, in text
|
|
@@ -177,6 +177,8 @@ The echo endpoint is only live on the deployed site: { reply_error }
|
|
|
177
177
|
## See it for real
|
|
178
178
|
|
|
179
179
|
:try "A whole live app in one file: fetch, loops, nested ifs, a form" href="/app/"
|
|
180
|
+
:try "A filter that lives in the URL, so the view is shareable" href="/url-state/"
|
|
181
|
+
:try "A link whose destination follows state as you click" href="/attr-binding/"
|
|
180
182
|
:try "The docs, a .wd page built from includes" href="/docs/"
|
|
181
183
|
:try "A plain .md page, where directives stay inert text" href="/markdown/"
|
|
182
184
|
|