@zvndev/darkmown 2.0.0 → 2.0.1
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 +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/docs/spec-alignment.md +2 -1
- package/package.json +3 -2
- package/site/_/footer.wd +1 -1
- package/site/pages/app.wd +1 -1
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/docs/index.wd +15 -1
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +5 -3
- package/site/pages/showcase/index.wd +1 -1
- package/src/compiler/context.js +11 -2
- package/src/compiler/includes.js +9 -3
- package/src/compiler/loops.js +34 -4
- package/src/compiler/page.js +29 -2
- package/src/compiler/structure.js +26 -4
- package/src/templates/blog/site/_/nav.wd +6 -9
- package/src/templates/blog/site/pages/index.skin +7 -4
- package/src/templates/blog/site/pages/index.wd +2 -3
- package/src/templates/dashboard/site/_/nav.wd +6 -9
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -3
- package/src/templates/landing/site/_/nav.wd +6 -9
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -9
- package/src/templates/starter/site/_/nav.wd +7 -10
- package/src/templates/starter/site/pages/about.md +7 -18
- package/src/templates/starter/site/pages/about.skin +0 -25
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -3
- package/src/templates/store/site/_/nav.wd +6 -9
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -3
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
|
|
|
7
7
|
- `.md` files are plain CommonMark. Directives stay inert text.
|
|
8
8
|
- `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
|
|
9
9
|
- To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
|
|
10
|
-
- Static pages ship **zero** JavaScript; a page loads the ~7.
|
|
10
|
+
- Static pages ship **zero** JavaScript; a page loads the ~7.5 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
|
|
11
11
|
|
|
12
12
|
### ⚠️ Raw HTML is escaped by default (since 2.0.0)
|
|
13
13
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 2.0.1 — 2026-07-11
|
|
6
|
+
|
|
7
|
+
A tightening pass from a post-2.0 product review: a silent reactive-loop failure becomes a loud compile error, scaffolds stop shipping the raw-HTML footgun, and every scaffolded page gets real landmarks back. All compile-time — the runtime is byte-identical (7518 B gzipped).
|
|
8
|
+
|
|
9
|
+
- **A third reactive `@loop` level is now a compile error.** The runtime supports one level of reactive nesting; a third reactive level previously compiled clean and painted the inner region empty at runtime. It now fails the build with `file:line`, the offending opener quoted, and a corrective suggestion — including when the third level arrives through an `@include` (nesting depth threads across the include boundary). Static (build-unrolled) nesting is unaffected at any depth.
|
|
10
|
+
- **`::: nav` and `::: main` containers emit real landmark elements.** A container named `nav` or `main` now compiles to `<nav class="nav">` / `<main class="main">` instead of a `<div>` (the name stays as a class, so existing `.nav`/`.main` skin selectors match unchanged; every other name still emits a `<div>`). With that, the shell reuses the container's `<main>` for its `id="main"` skip-link target instead of wrapping the whole body — nav chrome sits outside `<main>` and "Skip to content" actually skips it.
|
|
11
|
+
- **Scaffold templates no longer set `html: true`.** 2.0.0 made escaped markdown the default and called raw-HTML mode the #1 footgun — while every template scaffolded with the flag on. All five templates now express their nav/layout/CTAs with container directives and `{.class}` inline attributes instead of raw HTML (zero `html: true` anywhere in a fresh scaffold), and with the landmark containers above they keep proper `<nav>`/`<main>` structure. The starter's `about.md` becomes pure CommonMark with a "Back home" link — a `.md` page can't include the shared nav, and the page now says so instead of duplicating raw markup.
|
|
12
|
+
- **`file:line` on the remaining compile errors.** Malformed `@loop` headers and include-resolution failures (unresolvable path, sandbox escape) reported only the file; they now report `file:line` like every other directive error, and the unresolvable-include message gained a hint ("Looked in site/pages and site/_ — check the path and leading slash").
|
|
13
|
+
- **Size prose is drift-guarded.** Docs and site copy quoting the runtime size (now "~7.5 KB" everywhere current-tense) are asserted against `.size-snapshot.json` by a new test, so the number in the prose can never silently diverge from the shipped bytes again.
|
|
14
|
+
- **darkmown.com hero: install one-liner with a copy button.** The landing page now puts `npx @zvndev/darkmown init my-site` (click-to-copy) directly under the CTAs, and the site docs gained a CLI reference section covering all six commands.
|
|
15
|
+
- **Internal: a build-throughput benchmark.** `npm run bench:build` generates a synthetic site (collections, includes, static loops, highlighted code) and reports pages/sec; it refuses to touch a `site/` directory it didn't generate itself.
|
|
16
|
+
|
|
5
17
|
## 2.0.0 — 2026-07-11
|
|
6
18
|
|
|
7
19
|
The raw-HTML markdown default flips to escaped (`html: false` — the breaking change that makes this a major), alongside security hardening and a DX pass from a product review: incremental dev rebuilds, true error line numbers, a docs table of contents, and styled templates. Nearly all build-time and dev-server; the one runtime change — deterministic UTC date pipes — nudges the runtime to 7518 B gzipped, still well under the 8 KB budget.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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 ~7.
|
|
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 ~7.5 KB gzipped, CI-enforced under 8 KB.
|
|
11
11
|
|
|
12
12
|
## Showcase
|
|
13
13
|
|
|
@@ -65,7 +65,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
65
65
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
66
66
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
67
67
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
68
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.
|
|
68
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.5 KB gzipped, CI-enforced under 8 KB).
|
|
69
69
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
70
70
|
|
|
71
71
|
## Interpolation
|
|
@@ -353,7 +353,7 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
|
|
|
353
353
|
- Interpolation inside the inner body resolves the inner item first, then the outer item — `{ line.qty }` and `{ order.ref }` are both in scope.
|
|
354
354
|
- Build-time loops over JSON/frontmatter already nest freely (the "loops nest" behavior above); what's new is **reactive-inside-reactive** over a `:state`/`:store`/`:fetch` row field.
|
|
355
355
|
|
|
356
|
-
> **Honest caveat:** nesting is **one level only** — an inner reactive loop
|
|
356
|
+
> **Honest caveat:** nesting is **one level only** — an inner reactive loop that itself contains a third reactive loop is a **compile error** with a corrective message, not a silent runtime failure. Build-time loop nesting is unaffected by this limit.
|
|
357
357
|
>
|
|
358
358
|
> **Per-row actions inside an inner loop:** `cart += member` (append-row) works from an inner loop — it carries the inner item into a top-level `:state`/`:store` list. But a per-row **`remove`** needs a top-level list as its source, and an inner loop's source is a path off the outer row (`team.members`), so deleting an inner row in place is **not** supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
|
|
359
359
|
|
|
@@ -440,6 +440,8 @@ Cart has { count } items.
|
|
|
440
440
|
|
|
441
441
|
State declared inside a section is scoped to it — two sections can both own a `count`. Bindings and actions resolve to the nearest scope.
|
|
442
442
|
|
|
443
|
+
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.
|
|
444
|
+
|
|
443
445
|
### Reactive classes — `.class when <predicate>`
|
|
444
446
|
|
|
445
447
|
A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
|
package/docs/spec-alignment.md
CHANGED
|
@@ -6,10 +6,11 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
6
6
|
|
|
7
7
|
- **Markdown-first authoring:** `.md` is strict CommonMark rendered by a real parser (markdown-it); `.wd` is the same Markdown plus first-party directives. Renaming the file is the upgrade path — extensions genuinely gate functionality.
|
|
8
8
|
- **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
|
|
9
|
-
- **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.
|
|
9
|
+
- **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.5 KB gzipped, CI-enforced below the 8 KB target).
|
|
10
10
|
- **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
|
|
11
11
|
- **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
|
|
12
12
|
- **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
|
|
13
|
+
- **Semantic landmark containers:** `::: nav` / `::: main` emit the real `<nav>`/`<main>` element (keeping the name as a class for styling); other container names stay `<div>`s. Keeps skip links and landmarks intact without raw HTML.
|
|
13
14
|
- **`.skin` language:** indentation-based token styling compiles to CSS.
|
|
14
15
|
- **Folder router:** `site/pages` maps to routes, and `.`, `-`, `_` prefixes hide pages.
|
|
15
16
|
- **Colocation:** matching `.skin` and `.js` files attach to the page by basename, for includes too.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"prepare": "husky || true",
|
|
36
36
|
"prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
|
|
37
37
|
"prepublishOnly": "npm run prepublish:check",
|
|
38
|
-
"og": "node scripts/og-image.mjs"
|
|
38
|
+
"og": "node scripts/og-image.mjs",
|
|
39
|
+
"bench:build": "node scripts/bench-build.mjs"
|
|
39
40
|
},
|
|
40
41
|
"lint-staged": {
|
|
41
42
|
"*.{js,json}": "biome check --no-errors-on-unmatched --files-ignore-unknown=true"
|
package/site/_/footer.wd
CHANGED
|
@@ -6,7 +6,7 @@ html: true
|
|
|
6
6
|
<div class="foot-inner">
|
|
7
7
|
<div>
|
|
8
8
|
<strong>Darkmown</strong> — Markdown that runs.
|
|
9
|
-
<div class="small">Zero JS on static pages · one ~7.
|
|
9
|
+
<div class="small">Zero JS on static pages · one ~7.5 KB runtime when reactive.</div>
|
|
10
10
|
</div>
|
|
11
11
|
<nav class="foot-links">
|
|
12
12
|
<a href="/docs/">Docs</a>
|
package/site/pages/app.wd
CHANGED
|
@@ -20,7 +20,7 @@ This whole section is plain Markdown. The list below is unrolled **at build time
|
|
|
20
20
|
- **{ feature.title }** — { feature.body }
|
|
21
21
|
@endloop
|
|
22
22
|
|
|
23
|
-
<aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~7.
|
|
23
|
+
<aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~7.5 KB gzipped runtime loads (CI-enforced under 8 KB) — and only because the document asks for it.</aside>
|
|
24
24
|
|
|
25
25
|
## Fetched data, looped, with nested conditions
|
|
26
26
|
|
|
@@ -15,7 +15,7 @@ none of them pay for it.
|
|
|
15
15
|
|
|
16
16
|
That is the whole bargain. A `.md` file is plain CommonMark. Rename it to `.wd`
|
|
17
17
|
and you unlock directives — state, loops, conditionals — but you only ship the
|
|
18
|
-
~7.
|
|
18
|
+
~7.5 KB runtime on the pages that actually use them. A listing built from a
|
|
19
19
|
collection loop resolves entirely at build time, so it stays static HTML.
|
|
20
20
|
|
|
21
21
|
## The extension is the feature gate
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -47,6 +47,7 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
|
|
|
47
47
|
- [Syntax highlighting](#syntax-highlighting)
|
|
48
48
|
- [Trust boundary](#trust-boundary)
|
|
49
49
|
- [Colocation](#colocation)
|
|
50
|
+
- [CLI reference](#cli-reference)
|
|
50
51
|
- [Spec status](#spec-status)
|
|
51
52
|
</nav>
|
|
52
53
|
|
|
@@ -308,9 +309,11 @@ You don't size images by hand. Every `<img>` the compiler emits gets its intrins
|
|
|
308
309
|
|
|
309
310
|
`::: 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.
|
|
310
311
|
|
|
312
|
+
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.
|
|
313
|
+
|
|
311
314
|
## Reactive directives
|
|
312
315
|
|
|
313
|
-
Reactive pages opt into `/__wd/runtime.js` (~7.
|
|
316
|
+
Reactive pages opt into `/__wd/runtime.js` (~7.5 KB gzipped, under a CI-enforced 8 KB budget). Static pages do not.
|
|
314
317
|
|
|
315
318
|
```wd
|
|
316
319
|
:state count = 0
|
|
@@ -599,6 +602,17 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) i
|
|
|
599
602
|
- Both work for included fragments too, by basename.
|
|
600
603
|
- 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.
|
|
601
604
|
|
|
605
|
+
## CLI reference
|
|
606
|
+
|
|
607
|
+
The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool — every command is one word:
|
|
608
|
+
|
|
609
|
+
- `darkmown init [dir] [--template <name>]` — scaffold a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`). Existing files are never overwritten.
|
|
610
|
+
- `darkmown dev` — the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner for `api/*` functions.
|
|
611
|
+
- `darkmown build [--target cloudflare] [--drafts]` — write 100% static output to `dist/` (plus `sitemap.xml` / `rss.xml` / `robots.txt`); `--drafts` includes `draft: true` pages for a staging deploy.
|
|
612
|
+
- `darkmown serve` — preview the built `dist/` locally (static only; use `dev` to exercise `api/*`).
|
|
613
|
+
- `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.
|
|
614
|
+
- `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.
|
|
615
|
+
|
|
602
616
|
## Spec status
|
|
603
617
|
|
|
604
618
|
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.js
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
const copyBtn = document.querySelector("[data-copy-cmd]");
|
|
2
|
+
|
|
3
|
+
if (copyBtn) {
|
|
4
|
+
copyBtn.addEventListener("click", async () => {
|
|
5
|
+
const cmd = copyBtn.previousElementSibling?.textContent ?? "";
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(cmd);
|
|
8
|
+
copyBtn.textContent = "Copied";
|
|
9
|
+
copyBtn.classList.add("is-copied");
|
|
10
|
+
setTimeout(() => {
|
|
11
|
+
copyBtn.textContent = "Copy";
|
|
12
|
+
copyBtn.classList.remove("is-copied");
|
|
13
|
+
}, 1600);
|
|
14
|
+
} catch {
|
|
15
|
+
copyBtn.textContent = "Select + ⌘C";
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
const button = document.querySelector("[data-wd-demo]");
|
|
2
21
|
|
|
3
22
|
if (button) {
|
package/site/pages/index.skin
CHANGED
|
@@ -29,7 +29,52 @@
|
|
|
29
29
|
.hero-cta
|
|
30
30
|
display flex
|
|
31
31
|
flex-wrap wrap
|
|
32
|
-
margin-bottom
|
|
32
|
+
margin-bottom 1.5rem
|
|
33
|
+
|
|
34
|
+
.hero-install
|
|
35
|
+
display inline-flex
|
|
36
|
+
align-items center
|
|
37
|
+
gap .55rem
|
|
38
|
+
max-width 100%
|
|
39
|
+
margin 0 0 2rem
|
|
40
|
+
padding .7rem 1rem
|
|
41
|
+
border 1px solid $line
|
|
42
|
+
radius 10px
|
|
43
|
+
bg $sunk
|
|
44
|
+
font-family ui-monospace, SFMono-Regular, Menlo, monospace
|
|
45
|
+
overflow-x auto
|
|
46
|
+
|
|
47
|
+
.hero-install::before
|
|
48
|
+
content "$"
|
|
49
|
+
color $accent
|
|
50
|
+
font-weight 700
|
|
51
|
+
flex-shrink 0
|
|
52
|
+
|
|
53
|
+
.hero-install code
|
|
54
|
+
font-family inherit
|
|
55
|
+
font-size .95rem
|
|
56
|
+
color $ink
|
|
57
|
+
white-space nowrap
|
|
58
|
+
|
|
59
|
+
.hero-install .copy-cmd
|
|
60
|
+
flex-shrink 0
|
|
61
|
+
margin-left .35rem
|
|
62
|
+
padding .25rem .65rem
|
|
63
|
+
border 1px solid $line
|
|
64
|
+
radius 7px
|
|
65
|
+
bg transparent
|
|
66
|
+
color $ink-soft
|
|
67
|
+
font-family inherit
|
|
68
|
+
font-size .8rem
|
|
69
|
+
cursor pointer
|
|
70
|
+
|
|
71
|
+
.hero-install .copy-cmd:hover
|
|
72
|
+
color $ink
|
|
73
|
+
border-color $accent
|
|
74
|
+
|
|
75
|
+
.hero-install .copy-cmd.is-copied
|
|
76
|
+
color $accent
|
|
77
|
+
border-color $accent
|
|
33
78
|
|
|
34
79
|
.hero-stats
|
|
35
80
|
display grid
|
package/site/pages/index.wd
CHANGED
|
@@ -24,9 +24,11 @@ The left file is Markdown. It is also a running app — a live search filter, ad
|
|
|
24
24
|
<a class="btn" href="/docs/">Read the docs</a>
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
|
+
<div class="hero-install"><code>npx @zvndev/darkmown init my-site</code><button type="button" class="copy-cmd" data-copy-cmd aria-label="Copy install command">Copy</button></div>
|
|
28
|
+
|
|
27
29
|
<ul class="hero-stats">
|
|
28
30
|
<li><strong>0 KB</strong><span>framework JS on static pages</span></li>
|
|
29
|
-
<li><strong>~7.
|
|
31
|
+
<li><strong>~7.5 KB</strong><span>one runtime, CI-enforced < 8 KB</span></li>
|
|
30
32
|
<li><strong>1</strong><span>loop and one binding to learn</span></li>
|
|
31
33
|
<li><strong>MIT</strong><span>zero runtime dependencies</span></li>
|
|
32
34
|
</ul>
|
|
@@ -110,14 +112,14 @@ Markdoc.renderers.react(content, React, {
|
|
|
110
112
|
|
|
111
113
|
</section>
|
|
112
114
|
|
|
113
|
-
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~7.
|
|
115
|
+
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~7.5 KB gzipped runtime loads (CI-enforced under 8 KB), never before. JS optional, CSS optional, always."
|
|
114
116
|
|
|
115
117
|
<section class="props">
|
|
116
118
|
|
|
117
119
|
## Three rules, no escape hatch
|
|
118
120
|
|
|
119
121
|
- **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
|
|
120
|
-
- **One runtime.** Every reactive page shares the same ~7.
|
|
122
|
+
- **One runtime.** Every reactive page shares the same ~7.5 KB gzipped runtime, CI-enforced under 8 KB. No per-component bundles, no hydration tax that scales with your UI.
|
|
121
123
|
- **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
|
|
122
124
|
|
|
123
125
|
</section>
|
|
@@ -96,7 +96,7 @@ Darkmown's whole pitch is weight. A static page ships **zero** framework JavaScr
|
|
|
96
96
|
|
|
97
97
|
::: card .sc-scale
|
|
98
98
|
- **Static Darkmown page** — `0 KB` framework JS. Forever.
|
|
99
|
-
- **Reactive Darkmown page** — one shared `~7.
|
|
99
|
+
- **Reactive Darkmown page** — one shared `~7.5 KB` gzipped runtime, no per-component bundles.
|
|
100
100
|
- **A typical SPA** — ships a framework runtime in the **~15–60 KB** gzipped range *before* your app code, on every page (Alpine ~15 KB, Vue ~18 KB runtime, React 19 ~59 KB).
|
|
101
101
|
:::
|
|
102
102
|
|
package/src/compiler/context.js
CHANGED
|
@@ -102,15 +102,24 @@
|
|
|
102
102
|
* @property {string[]} sections Active section-id scope chain.
|
|
103
103
|
* @property {string | null} loopItem Name of the current reactive loop item, if any.
|
|
104
104
|
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
105
|
+
* @property {number} [reactiveDepth] Number of enclosing REACTIVE `@loop` levels
|
|
106
|
+
* (0 at top level). The runtime reconciles at most two nested `data-wd-loop`
|
|
107
|
+
* levels — a third paints empty — so a third reactive level is rejected at
|
|
108
|
+
* compile time. Static (build-unrolled) loops flatten to markup and don't add
|
|
109
|
+
* a level, so they carry the count through without incrementing it.
|
|
110
|
+
* @property {{ at: string, line: string }} [loopOpener] The current `@loop`
|
|
111
|
+
* opener's `file:line` + raw text, set when descending into a loop body so the
|
|
112
|
+
* reactive-nesting-depth error can point at the offending opener.
|
|
105
113
|
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
106
114
|
* @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
|
|
107
115
|
* @property {(lines: string[], ctx: Ctx) => string} compileBody The body
|
|
108
116
|
* dispatcher, threaded through the context so block handlers (`:if`, `:::`,
|
|
109
117
|
* `:form`, `@loop`, …) recurse into nested bodies without importing the
|
|
110
118
|
* dispatcher back — the module graph stays an import-cycle-free DAG.
|
|
111
|
-
* @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null) => { meta: Meta, html: string }} compileFile
|
|
119
|
+
* @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null, reactiveDepth?: number, loopOpener?: { at: string, line: string } | null) => { meta: Meta, html: string }} compileFile
|
|
112
120
|
* The per-file compile, threaded for the same reason so `@include` can
|
|
113
|
-
* compile its target file.
|
|
121
|
+
* compile its target file. `reactiveDepth`/`loopOpener` carry the enclosing
|
|
122
|
+
* reactive-loop nesting across the include boundary so the depth guard holds.
|
|
114
123
|
*/
|
|
115
124
|
|
|
116
125
|
/**
|
package/src/compiler/includes.js
CHANGED
|
@@ -158,9 +158,13 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
158
158
|
* @param {string} fromFile File requesting the include.
|
|
159
159
|
* @param {Paths} context
|
|
160
160
|
* @param {boolean} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
|
|
161
|
+
* @param {string} [loc] Source location (`file:line`) for the error messages, so
|
|
162
|
+
* an unresolved/out-of-sandbox include reports the directive's line. Callers
|
|
163
|
+
* with a line index pass `at(ctx, index)`; it defaults to `fromFile` (the bare
|
|
164
|
+
* path) for any caller without one, preserving the file-only message.
|
|
161
165
|
* @returns {string}
|
|
162
166
|
*/
|
|
163
|
-
export function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
167
|
+
export function resolveInclude(spec, fromFile, context, allowAny = false, loc = fromFile) {
|
|
164
168
|
const clean = stripQuotes(spec);
|
|
165
169
|
const candidates = [];
|
|
166
170
|
if (clean.startsWith("/")) {
|
|
@@ -172,13 +176,15 @@ export function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
|
172
176
|
for (const candidate of candidates) {
|
|
173
177
|
const resolved = path.resolve(candidate);
|
|
174
178
|
if (!isAllowedInclude(resolved, context)) {
|
|
175
|
-
throw new Error(`Include "${spec}" from ${
|
|
179
|
+
throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
|
|
176
180
|
}
|
|
177
181
|
if (!fs.existsSync(resolved)) continue;
|
|
178
182
|
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
179
183
|
return resolved;
|
|
180
184
|
}
|
|
181
|
-
throw new Error(
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Could not resolve include "${spec}" from ${loc}. Looked in site/pages and site/_ — check the path and leading slash.`
|
|
187
|
+
);
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
/**
|
package/src/compiler/loops.js
CHANGED
|
@@ -219,7 +219,7 @@ function numArgAttr(arg) {
|
|
|
219
219
|
*/
|
|
220
220
|
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
221
221
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
222
|
-
if (!match) throw new Error(`Malformed @loop in ${ctx
|
|
222
|
+
if (!match) throw new Error(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`);
|
|
223
223
|
const source = stripQuotes(match[1].trim());
|
|
224
224
|
const itemName = match[2];
|
|
225
225
|
const clauses = match[3]
|
|
@@ -273,8 +273,13 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
273
273
|
paginate: clauses.paginate
|
|
274
274
|
};
|
|
275
275
|
// The loop body starts on the line after the opener; nested errors in it (and
|
|
276
|
-
// in the empty branch, via `opts.emptyStart`) report the true file line.
|
|
277
|
-
|
|
276
|
+
// in the empty branch, via `opts.emptyStart`) report the true file line. The
|
|
277
|
+
// opener's location rides on the body ctx so a reactive loop nested inside this
|
|
278
|
+
// one can point the depth error back at its own opener line.
|
|
279
|
+
const bodyCtx = {
|
|
280
|
+
...nestedCtx(ctx, index + 1),
|
|
281
|
+
loopOpener: { at: at(ctx, index), line: line.trim() }
|
|
282
|
+
};
|
|
278
283
|
|
|
279
284
|
if (
|
|
280
285
|
source.startsWith("/") ||
|
|
@@ -283,7 +288,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
283
288
|
source.endsWith(".json")
|
|
284
289
|
) {
|
|
285
290
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
286
|
-
const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
|
|
291
|
+
const dataFile = resolveInclude(source, ctx.file, ctx.context, true, at(ctx, index));
|
|
287
292
|
ctx.comp.deps.add(dataFile);
|
|
288
293
|
const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
|
289
294
|
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
@@ -372,6 +377,27 @@ function paginateOnlyCollections(ctx) {
|
|
|
372
377
|
return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
|
|
373
378
|
}
|
|
374
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Reject a reactive `@loop` that would open a THIRD reactive nesting level. The
|
|
382
|
+
* runtime reconciles at most two nested `data-wd-loop` levels (an outer loop and
|
|
383
|
+
* one inner loop); a third level's `data-wd-loop-out` paints empty. This guards
|
|
384
|
+
* every reactive-region entry point ({@link reactiveLoop}, {@link itemRelativeLoop}),
|
|
385
|
+
* so a state-key, item-relative, or state-filtered source is all covered. Static
|
|
386
|
+
* (build-unrolled) loops carry the depth through without incrementing it, so an
|
|
387
|
+
* interleaved static level neither triggers nor masks the limit.
|
|
388
|
+
* `ctx.reactiveDepth` is the number of reactive loops already enclosing this one.
|
|
389
|
+
* @param {Ctx} ctx
|
|
390
|
+
* @returns {void}
|
|
391
|
+
*/
|
|
392
|
+
function assertReactiveDepth(ctx) {
|
|
393
|
+
if ((ctx.reactiveDepth ?? 0) < 2) return;
|
|
394
|
+
const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Reactive @loop nesting is limited to one inner level in ${opener.at}: "${opener.line}". ` +
|
|
397
|
+
`Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
375
401
|
/**
|
|
376
402
|
* Loop over a build-time collection's entry rows. With no `paginate`, this is a
|
|
377
403
|
* plain static unroll (filter/sort/reverse/offset/limit at build time, zero JS).
|
|
@@ -540,6 +566,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart =
|
|
|
540
566
|
* @returns {string}
|
|
541
567
|
*/
|
|
542
568
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
569
|
+
assertReactiveDepth(ctx);
|
|
543
570
|
ctx.comp.assets.runtime = true;
|
|
544
571
|
/** @type {Ctx} */
|
|
545
572
|
const templateCtx = {
|
|
@@ -547,6 +574,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
547
574
|
loopItem: itemName,
|
|
548
575
|
loopKey: key ?? undefined,
|
|
549
576
|
loopMeta: true,
|
|
577
|
+
reactiveDepth: (ctx.reactiveDepth ?? 0) + 1,
|
|
550
578
|
scope: createScope(ctx.scope)
|
|
551
579
|
};
|
|
552
580
|
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
|
@@ -689,6 +717,7 @@ function loopClauseAttrs(c) {
|
|
|
689
717
|
* @returns {string}
|
|
690
718
|
*/
|
|
691
719
|
function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
720
|
+
assertReactiveDepth(ctx);
|
|
692
721
|
/** @type {Ctx} */
|
|
693
722
|
// loopKey: undefined — an item-relative loop's source is a path off the outer
|
|
694
723
|
// row, not a top-level state key, so a per-row `remove` inside it has no valid
|
|
@@ -698,6 +727,7 @@ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
|
698
727
|
loopItem: itemName,
|
|
699
728
|
loopKey: undefined,
|
|
700
729
|
loopMeta: true,
|
|
730
|
+
reactiveDepth: (ctx.reactiveDepth ?? 0) + 1,
|
|
701
731
|
scope: createScope(ctx.scope)
|
|
702
732
|
};
|
|
703
733
|
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
package/src/compiler/page.js
CHANGED
|
@@ -293,7 +293,17 @@ function measureImage(src, paths) {
|
|
|
293
293
|
export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
|
|
294
294
|
const comp = createCompilation();
|
|
295
295
|
comp.collections = collections;
|
|
296
|
-
const result = compileFile(
|
|
296
|
+
const result = compileFile(
|
|
297
|
+
file,
|
|
298
|
+
context,
|
|
299
|
+
stack,
|
|
300
|
+
createScope(null, vars),
|
|
301
|
+
comp,
|
|
302
|
+
[],
|
|
303
|
+
null,
|
|
304
|
+
0,
|
|
305
|
+
null
|
|
306
|
+
);
|
|
297
307
|
return {
|
|
298
308
|
meta: result.meta,
|
|
299
309
|
html: result.html,
|
|
@@ -314,9 +324,24 @@ export function compileDocument(file, context, stack = [], vars = {}, collection
|
|
|
314
324
|
* @param {Compilation} comp
|
|
315
325
|
* @param {string[]} sections
|
|
316
326
|
* @param {string | null} loopItem
|
|
327
|
+
* @param {number} [reactiveDepth] Enclosing REACTIVE `@loop` levels, threaded in
|
|
328
|
+
* from the including page so an include's body can't bypass the two-level
|
|
329
|
+
* reactive-nesting limit at the `@include` boundary.
|
|
330
|
+
* @param {{ at: string, line: string } | null} [loopOpener] The enclosing `@loop`
|
|
331
|
+
* opener, threaded in so a depth error inside the include names the right opener.
|
|
317
332
|
* @returns {{ meta: Meta, html: string }}
|
|
318
333
|
*/
|
|
319
|
-
export function compileFile(
|
|
334
|
+
export function compileFile(
|
|
335
|
+
file,
|
|
336
|
+
context,
|
|
337
|
+
stack,
|
|
338
|
+
scope,
|
|
339
|
+
comp,
|
|
340
|
+
sections,
|
|
341
|
+
loopItem,
|
|
342
|
+
reactiveDepth = 0,
|
|
343
|
+
loopOpener = null
|
|
344
|
+
) {
|
|
320
345
|
const real = fs.realpathSync(file);
|
|
321
346
|
if (stack.includes(real)) {
|
|
322
347
|
throw new Error(
|
|
@@ -351,6 +376,8 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
351
376
|
comp,
|
|
352
377
|
sections,
|
|
353
378
|
loopItem,
|
|
379
|
+
reactiveDepth,
|
|
380
|
+
...(loopOpener ? { loopOpener } : {}),
|
|
354
381
|
md: selectMd(meta),
|
|
355
382
|
compileBody,
|
|
356
383
|
compileFile
|
|
@@ -27,6 +27,14 @@ import { compileWhen, evalPredicate } from "./predicates.js";
|
|
|
27
27
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
// Container names that emit their own semantic landmark element instead of a
|
|
31
|
+
// `<div>`, so scaffolds get real `<nav>`/`<main>` landmarks (accessibility: the
|
|
32
|
+
// skip link targets a genuine main-content region and navigation chrome sits in
|
|
33
|
+
// its own landmark). The name is still added as a class, so existing `.nav`/
|
|
34
|
+
// `.main` skin selectors keep working. Fixed compiler-owned constants — never
|
|
35
|
+
// author-supplied — so no tag-injection surface; classes/ids still escape.
|
|
36
|
+
const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
|
|
37
|
+
|
|
30
38
|
/**
|
|
31
39
|
* @param {string} line
|
|
32
40
|
* @param {Ctx} ctx
|
|
@@ -36,9 +44,13 @@ import { compileWhen, evalPredicate } from "./predicates.js";
|
|
|
36
44
|
export function handleInclude(line, ctx, index) {
|
|
37
45
|
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
38
46
|
if (!match) throw new Error(`Malformed @include in ${at(ctx, index)}: ${line}`);
|
|
39
|
-
const target = resolveInclude(match[1], ctx.file, ctx.context);
|
|
47
|
+
const target = resolveInclude(match[1], ctx.file, ctx.context, false, at(ctx, index));
|
|
40
48
|
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
41
49
|
const childScope = createScope(ctx.scope, args);
|
|
50
|
+
// Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
|
|
51
|
+
// error names the right opener) into the include, exactly as `loopItem` is —
|
|
52
|
+
// otherwise a reactive @loop inside the include would start counting from zero
|
|
53
|
+
// and could open a THIRD nested `data-wd-loop` level that silently paints empty.
|
|
42
54
|
const child = ctx.compileFile(
|
|
43
55
|
target,
|
|
44
56
|
ctx.context,
|
|
@@ -46,7 +58,9 @@ export function handleInclude(line, ctx, index) {
|
|
|
46
58
|
childScope,
|
|
47
59
|
ctx.comp,
|
|
48
60
|
ctx.sections,
|
|
49
|
-
ctx.loopItem
|
|
61
|
+
ctx.loopItem,
|
|
62
|
+
ctx.reactiveDepth ?? 0,
|
|
63
|
+
ctx.loopOpener ?? null
|
|
50
64
|
);
|
|
51
65
|
// An include with its OWN scoped colocated skin stamps just its returned
|
|
52
66
|
// subtree — so a scoped `.card` in the include can't collide with a `.card`
|
|
@@ -102,14 +116,22 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
102
116
|
const stateClasses = [];
|
|
103
117
|
let id = "";
|
|
104
118
|
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
105
|
-
// the <section> tag;
|
|
119
|
+
// the <section> tag; a whitelisted semantic landmark (`nav`/`main`) emits that
|
|
120
|
+
// real element AND keeps the name as a class hook (so `.nav`/`.main` skins
|
|
121
|
+
// still cascade); any other name becomes a <div> and also a class. The tag is
|
|
122
|
+
// always one of these fixed constants — never author text — so it can't inject.
|
|
106
123
|
const lead = rest.match(/^([^\s.#]\S*)/);
|
|
107
124
|
let nameToken = "section";
|
|
108
125
|
if (lead) {
|
|
109
126
|
nameToken = lead[1];
|
|
110
127
|
rest = rest.slice(lead[0].length).trim();
|
|
111
128
|
}
|
|
112
|
-
if (nameToken
|
|
129
|
+
if (nameToken === "section") {
|
|
130
|
+
// default tag, no class
|
|
131
|
+
} else if (SEMANTIC_CONTAINER_TAGS.has(nameToken)) {
|
|
132
|
+
tag = nameToken;
|
|
133
|
+
extraClass.push(nameToken);
|
|
134
|
+
} else {
|
|
113
135
|
tag = "div";
|
|
114
136
|
extraClass.push(nameToken);
|
|
115
137
|
}
|
|
@@ -27,12 +27,12 @@ page
|
|
|
27
27
|
line-height 1.6
|
|
28
28
|
-webkit-font-smoothing antialiased
|
|
29
29
|
|
|
30
|
-
main
|
|
30
|
+
.main
|
|
31
31
|
max-width 720px
|
|
32
32
|
margin 0 auto
|
|
33
33
|
padding 2.5rem 1.5rem
|
|
34
34
|
|
|
35
|
-
nav
|
|
35
|
+
.nav
|
|
36
36
|
display flex
|
|
37
37
|
align-items center
|
|
38
38
|
justify-content space-between
|
|
@@ -40,10 +40,13 @@ nav
|
|
|
40
40
|
margin 0 auto
|
|
41
41
|
padding 1.25rem 1.5rem
|
|
42
42
|
|
|
43
|
-
nav
|
|
43
|
+
.nav p
|
|
44
|
+
margin 0
|
|
45
|
+
|
|
46
|
+
.nav strong
|
|
44
47
|
font-size 1.1rem
|
|
45
48
|
|
|
46
|
-
nav a
|
|
49
|
+
.nav a
|
|
47
50
|
color $accent
|
|
48
51
|
text-decoration none
|
|
49
52
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
html: true
|
|
3
2
|
title: My Blog
|
|
4
3
|
description: A markdown-native blog built with Darkmown.
|
|
5
4
|
site_url: https://example.com
|
|
@@ -8,7 +7,7 @@ transitions: true
|
|
|
8
7
|
|
|
9
8
|
@include /nav.wd
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
::: main
|
|
12
11
|
|
|
13
12
|
# My Blog
|
|
14
13
|
|
|
@@ -28,4 +27,4 @@ config, zero JavaScript; each post's frontmatter is the data.
|
|
|
28
27
|
No posts yet — add a `.md` file to `site/pages/posts/`.
|
|
29
28
|
@endloop
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
:::
|
|
@@ -18,12 +18,12 @@ page
|
|
|
18
18
|
line-height 1.55
|
|
19
19
|
-webkit-font-smoothing antialiased
|
|
20
20
|
|
|
21
|
-
main
|
|
21
|
+
.main
|
|
22
22
|
max-width 880px
|
|
23
23
|
margin 0 auto
|
|
24
24
|
padding 2rem 1.5rem
|
|
25
25
|
|
|
26
|
-
nav
|
|
26
|
+
.nav
|
|
27
27
|
display flex
|
|
28
28
|
align-items center
|
|
29
29
|
justify-content space-between
|
|
@@ -31,10 +31,13 @@ nav
|
|
|
31
31
|
margin 0 auto
|
|
32
32
|
padding 1.25rem 1.5rem
|
|
33
33
|
|
|
34
|
-
nav
|
|
34
|
+
.nav p
|
|
35
|
+
margin 0
|
|
36
|
+
|
|
37
|
+
.nav strong
|
|
35
38
|
font-size 1.1rem
|
|
36
39
|
|
|
37
|
-
nav a
|
|
40
|
+
.nav a
|
|
38
41
|
color $accent
|
|
39
42
|
text-decoration none
|
|
40
43
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
html: true
|
|
3
2
|
title: Dashboard — built with Darkmown
|
|
4
3
|
description: A live dashboard — stat cards fetched from a serverless function, on an otherwise static page.
|
|
5
4
|
transitions: true
|
|
@@ -7,7 +6,7 @@ transitions: true
|
|
|
7
6
|
|
|
8
7
|
@include /nav.wd
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
::: main
|
|
11
10
|
|
|
12
11
|
::: section #dash
|
|
13
12
|
|
|
@@ -73,4 +72,4 @@ The numbers come from `api/metrics.js` — a serverless function that runs in
|
|
|
73
72
|
|
|
74
73
|
:::
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
:::
|
|
@@ -27,12 +27,12 @@ page
|
|
|
27
27
|
line-height 1.6
|
|
28
28
|
-webkit-font-smoothing antialiased
|
|
29
29
|
|
|
30
|
-
main
|
|
30
|
+
.main
|
|
31
31
|
max-width 900px
|
|
32
32
|
margin 0 auto
|
|
33
33
|
padding 1rem 1.5rem 4rem
|
|
34
34
|
|
|
35
|
-
nav
|
|
35
|
+
.nav
|
|
36
36
|
display flex
|
|
37
37
|
align-items center
|
|
38
38
|
justify-content space-between
|
|
@@ -40,10 +40,13 @@ nav
|
|
|
40
40
|
margin 0 auto
|
|
41
41
|
padding 1.25rem 1.5rem
|
|
42
42
|
|
|
43
|
-
nav
|
|
43
|
+
.nav p
|
|
44
|
+
margin 0
|
|
45
|
+
|
|
46
|
+
.nav strong
|
|
44
47
|
font-size 1.15rem
|
|
45
48
|
|
|
46
|
-
nav a
|
|
49
|
+
.nav a
|
|
47
50
|
color $accent
|
|
48
51
|
text-decoration none
|
|
49
52
|
|
|
@@ -60,6 +63,9 @@ nav a
|
|
|
60
63
|
color $muted
|
|
61
64
|
margin 0 0 1.5rem
|
|
62
65
|
|
|
66
|
+
.lede p
|
|
67
|
+
margin 0
|
|
68
|
+
|
|
63
69
|
.cta
|
|
64
70
|
display inline-flex
|
|
65
71
|
align-items center
|
|
@@ -186,6 +192,9 @@ input:focus-visible, textarea:focus-visible, select:focus-visible
|
|
|
186
192
|
color $muted
|
|
187
193
|
font-size .95rem
|
|
188
194
|
|
|
195
|
+
.quote .who p
|
|
196
|
+
margin 0
|
|
197
|
+
|
|
189
198
|
.wd-carousel-prev
|
|
190
199
|
position absolute
|
|
191
200
|
left -.5rem
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
html: true
|
|
3
2
|
title: Acme — ship faster
|
|
4
3
|
description: A marketing one-pager built with Darkmown. Zero framework JS on the static parts, with a testimonials carousel.
|
|
5
4
|
transitions: true
|
|
@@ -7,12 +6,16 @@ transitions: true
|
|
|
7
6
|
|
|
8
7
|
@include /nav.wd
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
::: main
|
|
11
10
|
|
|
12
11
|
::: hero
|
|
13
12
|
# Ship faster with *Acme*
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
|
|
14
|
+
::: lede
|
|
15
|
+
The all-in-one toolkit your team actually enjoys using.
|
|
16
|
+
:::
|
|
17
|
+
|
|
18
|
+
[Get started](#start){.cta}
|
|
16
19
|
:::
|
|
17
20
|
|
|
18
21
|
::: features
|
|
@@ -35,21 +38,31 @@ One command to Vercel or Cloudflare — your API comes along for free.
|
|
|
35
38
|
:carousel
|
|
36
39
|
::: quote
|
|
37
40
|
> "Acme cut our launch time in half."
|
|
38
|
-
|
|
41
|
+
|
|
42
|
+
::: who
|
|
43
|
+
— Riley, Head of Product
|
|
44
|
+
:::
|
|
39
45
|
:::
|
|
40
46
|
::: quote
|
|
41
47
|
> "The simplest stack we've ever shipped on."
|
|
42
|
-
|
|
48
|
+
|
|
49
|
+
::: who
|
|
50
|
+
— Sam, CTO
|
|
51
|
+
:::
|
|
43
52
|
:::
|
|
44
53
|
::: quote
|
|
45
54
|
> "Our marketing pages load instantly now."
|
|
46
|
-
|
|
55
|
+
|
|
56
|
+
::: who
|
|
57
|
+
— Jordan, Growth
|
|
58
|
+
:::
|
|
47
59
|
:::
|
|
48
60
|
:endcarousel
|
|
49
61
|
|
|
50
62
|
::: cta-band #start
|
|
51
63
|
## Ready to build?
|
|
52
|
-
|
|
64
|
+
|
|
65
|
+
[Start now](/){.cta}
|
|
53
66
|
:::
|
|
54
67
|
|
|
55
|
-
|
|
68
|
+
:::
|
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: About
|
|
3
|
-
html: true
|
|
4
3
|
---
|
|
5
4
|
|
|
6
|
-
<nav>
|
|
7
|
-
<strong>My site</strong>
|
|
8
|
-
<span class="links">
|
|
9
|
-
<a href="/">Home</a>
|
|
10
|
-
<a href="/about/">About</a>
|
|
11
|
-
</span>
|
|
12
|
-
</nav>
|
|
13
|
-
|
|
14
|
-
<main>
|
|
15
|
-
|
|
16
5
|
# About
|
|
17
6
|
|
|
18
|
-
This page is plain Markdown (`.md`) — strict CommonMark, zero framework
|
|
19
|
-
|
|
20
|
-
|
|
7
|
+
This page is plain Markdown (`.md`) — strict CommonMark, zero framework
|
|
8
|
+
JavaScript, and no raw HTML (Darkmown escapes it by default, so untrusted
|
|
9
|
+
content can't inject markup). That's why there's no shared nav bar here: a
|
|
10
|
+
`.md` page can't `@include` or use directives.
|
|
21
11
|
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
Rename it to `.wd` when you want that — state, loops, forms, and an
|
|
13
|
+
`@include /nav.wd` for the shared nav. The colocated `about.skin` styles this
|
|
14
|
+
page either way; it attaches automatically by basename, `.md` or `.wd` alike.
|
|
24
15
|
|
|
25
16
|
[Back home](/)
|
|
26
|
-
|
|
27
|
-
</main>
|
|
@@ -32,31 +32,6 @@ main
|
|
|
32
32
|
margin 0 auto
|
|
33
33
|
padding 3rem 1.5rem
|
|
34
34
|
|
|
35
|
-
nav
|
|
36
|
-
display flex
|
|
37
|
-
align-items center
|
|
38
|
-
justify-content space-between
|
|
39
|
-
gap 1rem
|
|
40
|
-
max-width 760px
|
|
41
|
-
margin 0 auto
|
|
42
|
-
padding 1.25rem 1.5rem
|
|
43
|
-
|
|
44
|
-
nav strong
|
|
45
|
-
font-size 1.1rem
|
|
46
|
-
|
|
47
|
-
nav .links
|
|
48
|
-
display flex
|
|
49
|
-
gap 1.25rem
|
|
50
|
-
|
|
51
|
-
nav a
|
|
52
|
-
color $ink
|
|
53
|
-
text-decoration none
|
|
54
|
-
opacity .8
|
|
55
|
-
|
|
56
|
-
nav a:hover
|
|
57
|
-
opacity 1
|
|
58
|
-
color $accent
|
|
59
|
-
|
|
60
35
|
a
|
|
61
36
|
color $accent
|
|
62
37
|
|
|
@@ -27,12 +27,12 @@ page
|
|
|
27
27
|
line-height 1.6
|
|
28
28
|
-webkit-font-smoothing antialiased
|
|
29
29
|
|
|
30
|
-
main
|
|
30
|
+
.main
|
|
31
31
|
max-width 760px
|
|
32
32
|
margin 0 auto
|
|
33
33
|
padding 3rem 1.5rem
|
|
34
34
|
|
|
35
|
-
nav
|
|
35
|
+
.nav
|
|
36
36
|
display flex
|
|
37
37
|
align-items center
|
|
38
38
|
justify-content space-between
|
|
@@ -41,19 +41,23 @@ nav
|
|
|
41
41
|
margin 0 auto
|
|
42
42
|
padding 1.25rem 1.5rem
|
|
43
43
|
|
|
44
|
-
nav
|
|
44
|
+
.nav p
|
|
45
|
+
margin 0
|
|
46
|
+
|
|
47
|
+
.nav strong
|
|
45
48
|
font-size 1.1rem
|
|
46
49
|
|
|
47
|
-
nav .links
|
|
50
|
+
.nav .links p
|
|
48
51
|
display flex
|
|
49
52
|
gap 1.25rem
|
|
53
|
+
margin 0
|
|
50
54
|
|
|
51
|
-
nav a
|
|
55
|
+
.nav a
|
|
52
56
|
color $ink
|
|
53
57
|
text-decoration none
|
|
54
58
|
opacity .8
|
|
55
59
|
|
|
56
|
-
nav a:hover
|
|
60
|
+
.nav a:hover
|
|
57
61
|
opacity 1
|
|
58
62
|
color $accent
|
|
59
63
|
|
|
@@ -27,12 +27,12 @@ page
|
|
|
27
27
|
line-height 1.55
|
|
28
28
|
-webkit-font-smoothing antialiased
|
|
29
29
|
|
|
30
|
-
main
|
|
30
|
+
.main
|
|
31
31
|
max-width 860px
|
|
32
32
|
margin 0 auto
|
|
33
33
|
padding 2rem 1.5rem
|
|
34
34
|
|
|
35
|
-
nav
|
|
35
|
+
.nav
|
|
36
36
|
display flex
|
|
37
37
|
align-items center
|
|
38
38
|
justify-content space-between
|
|
@@ -40,10 +40,13 @@ nav
|
|
|
40
40
|
margin 0 auto
|
|
41
41
|
padding 1.25rem 1.5rem
|
|
42
42
|
|
|
43
|
-
nav
|
|
43
|
+
.nav p
|
|
44
|
+
margin 0
|
|
45
|
+
|
|
46
|
+
.nav strong
|
|
44
47
|
font-size 1.1rem
|
|
45
48
|
|
|
46
|
-
nav a
|
|
49
|
+
.nav a
|
|
47
50
|
color $accent
|
|
48
51
|
text-decoration none
|
|
49
52
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
html: true
|
|
3
2
|
title: Shop — built with Darkmown
|
|
4
3
|
description: A storefront in Markdown — live search, a shared cart, and a checkout that posts to a serverless function.
|
|
5
4
|
transitions: true
|
|
@@ -7,7 +6,7 @@ transitions: true
|
|
|
7
6
|
|
|
8
7
|
@include /nav.wd
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
::: main
|
|
11
10
|
|
|
12
11
|
::: section #shop
|
|
13
12
|
|
|
@@ -81,4 +80,4 @@ to { receipt.received.email }. Order { receipt.orderId }.
|
|
|
81
80
|
|
|
82
81
|
:::
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
:::
|