@zvndev/darkmown 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +7 -3
- package/docs/spec-alignment.md +2 -2
- package/package.json +2 -2
- package/site/_/nav.skin +2 -1
- package/site/_/nav.wd +1 -0
- package/site/_/roadmap.json +7 -0
- package/site/pages/app.skin +76 -0
- package/site/pages/app.wd +92 -0
- package/site/pages/docs/index.wd +3 -3
- package/site/pages/index.wd +30 -14
- package/site/pages/reactive.wd +25 -0
- package/src/compiler.js +72 -12
- package/src/runtime.js +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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
|
+
## 0.5.0 — 2026-06-12
|
|
6
|
+
|
|
7
|
+
- **Nested `:if` over loop items.** Conditionals inside a reactive `@loop` now nest — an inner `:if` resolves after the outer branch, per row, both at build time (balanced-region pre-render) and at runtime (recursive fill). Previously the compiler threw "not supported yet"; the loop-template fill no longer relies on a non-greedy regex that broke on nesting.
|
|
8
|
+
- **VS Code extension** (`editors/vscode`): syntax highlighting, snippets, and folding for `.wd` and `.skin` files. Grammars are tested through the real VS Code tokenizer (vscode-textmate) in CI.
|
|
9
|
+
- New `/app/` demo — a whole live app in one `.wd` file: static build-time loop, fetched data looped with nested conditionals, a persistent counter with a computed milestone, and a form that round-trips to a real server. It is the framework's elevator pitch as a single page.
|
|
10
|
+
- Site: landing page repositioned around the core wedge ("Markdown that runs" — full reactive websites in plain Markdown). `description`/Open Graph/Twitter meta tags now emit from frontmatter. Runtime-size claims corrected to the true ~2 KB; mobile nav wraps instead of overflowing.
|
|
11
|
+
|
|
5
12
|
## 0.4.0 — 2026-06-11
|
|
6
13
|
|
|
7
14
|
- **Renamed**: the framework is now **Darkmown** (formerly Markie). Package `@zvndev/darkmown` (npm's typosquat guard reserves bare `darkmown` as too similar to `markdown`), CLI `darkmown`. The `.wd` format and `/__wd/` asset paths are unchanged.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**[darkmown.com](https://darkmown.com)** · markdown, rearranged.
|
|
4
4
|
|
|
5
|
-
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
|
|
5
|
+
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 2 KB gzipped (CI-enforced under 5 KB).
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -45,7 +45,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
45
45
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
46
46
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
47
47
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
48
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~
|
|
48
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~2 KB gzipped, CI-enforced under 5 KB).
|
|
49
49
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
50
50
|
|
|
51
51
|
## Interpolation
|
|
@@ -132,12 +132,16 @@ Loading…
|
|
|
132
132
|
- `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement.
|
|
133
133
|
- `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one. `darkmown dev` ships a `/__wd/echo` endpoint for demos.
|
|
134
134
|
- `:state x = [] persist` keeps that state in localStorage across reloads.
|
|
135
|
-
- `:if item.path` works inside reactive loops for per-row branches.
|
|
135
|
+
- `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
|
|
136
136
|
|
|
137
137
|
## The escape hatch
|
|
138
138
|
|
|
139
139
|
Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
|
|
140
140
|
|
|
141
|
+
## Editor support
|
|
142
|
+
|
|
143
|
+
A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. Build it with `cd editors/vscode && npx @vscode/vsce package`, or install the published extension from the Marketplace (search "Darkmown").
|
|
144
|
+
|
|
141
145
|
## Spec status
|
|
142
146
|
|
|
143
147
|
See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -20,7 +20,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
20
20
|
- **`:fetch name from "url"`:** declarative data loading into reactive state, with `name_error` for failures. Shelf `.json` publishes to `/__wd/data/` so it works on static hosts.
|
|
21
21
|
- **`:form` two ways:** `:form into name` captures submits into client state with zero backend; `:form action="/url"` emits a plain native form and ships no JS — the progressive-enhancement story from the original doc.
|
|
22
22
|
- **`:state x = v persist`:** localStorage-backed state. This is the honest client-side slice of the original `:cart` primitive; server sync remains future work.
|
|
23
|
-
- **`:if item.path` inside reactive loops:** per-row conditional branches, filled at compile time and re-filled by the keyed patcher.
|
|
23
|
+
- **`:if item.path` inside reactive loops:** per-row conditional branches, filled at compile time and re-filled by the keyed patcher. Conditionals **nest** — an inner `:if` is resolved after the outer branch, both at build time (balanced-region pre-render) and at runtime (recursive fill).
|
|
24
24
|
- **`window.wd` escape hatch:** `get`/`set`/`state`/`render` exposed to colocated `.js` — the adapted form of the original `<script scope>` "infinite extension" idea, aligned with Darkmown's colocation model instead of inline scripts.
|
|
25
25
|
|
|
26
26
|
## Deliberate adaptations
|
|
@@ -49,7 +49,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
49
49
|
## Still missing from the full vision
|
|
50
50
|
|
|
51
51
|
- First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
|
|
52
|
-
-
|
|
52
|
+
- `@loop` over fetch sub-paths (looping a path *inside* fetched state rather than top-level state).
|
|
53
53
|
- `@starting-style` emission and browser fallbacks for older engines.
|
|
54
54
|
- Editor tooling, richer diagnostics, and an actual npm publish (needs a license decision).
|
|
55
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "node src/cli.js build",
|
|
15
15
|
"dev": "node src/cli.js dev",
|
|
16
|
-
"test": "node --test"
|
|
16
|
+
"test": "node --test tests/*.test.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"src",
|
package/site/_/nav.skin
CHANGED
package/site/_/nav.wd
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "id": "loops", "title": "Nested conditionals in loops", "shipped": true, "blocked": false },
|
|
3
|
+
{ "id": "editor", "title": "VS Code syntax highlighting", "shipped": true, "blocked": false },
|
|
4
|
+
{ "id": "filter", "title": "State-driven list filtering", "shipped": false, "blocked": false },
|
|
5
|
+
{ "id": "api", "title": "First-party site/api/ runtime", "shipped": false, "blocked": true },
|
|
6
|
+
{ "id": "swap", "title": "HTML-fragment swap responses", "shipped": false, "blocked": false }
|
|
7
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
tokens
|
|
2
|
+
ink #171717
|
|
3
|
+
paper #fbfaf7
|
|
4
|
+
panel #ffffff
|
|
5
|
+
line #e4e1d8
|
|
6
|
+
accent #16645a
|
|
7
|
+
warm #a83f2a
|
|
8
|
+
radius 10px
|
|
9
|
+
|
|
10
|
+
main
|
|
11
|
+
max-width 760px
|
|
12
|
+
margin 0 auto
|
|
13
|
+
padding 2.5rem 1.5rem 4rem
|
|
14
|
+
font ui-sans-serif, system-ui, sans-serif
|
|
15
|
+
color $ink
|
|
16
|
+
|
|
17
|
+
h1
|
|
18
|
+
font-size 2.1rem
|
|
19
|
+
letter-spacing -0.02em
|
|
20
|
+
|
|
21
|
+
.boundary
|
|
22
|
+
margin 1.5rem 0
|
|
23
|
+
padding 1rem 1.2rem
|
|
24
|
+
border-left 3px solid $warm
|
|
25
|
+
background #fcf4ef
|
|
26
|
+
border-radius $radius
|
|
27
|
+
font-size .95rem
|
|
28
|
+
|
|
29
|
+
.roadmap-row
|
|
30
|
+
display flex
|
|
31
|
+
align-items center
|
|
32
|
+
justify-content space-between
|
|
33
|
+
gap 1rem
|
|
34
|
+
padding .75rem 1rem
|
|
35
|
+
margin .5rem 0
|
|
36
|
+
background $panel
|
|
37
|
+
border 1px solid $line
|
|
38
|
+
border-radius $radius
|
|
39
|
+
|
|
40
|
+
.badge
|
|
41
|
+
font-size .8rem
|
|
42
|
+
font-weight 600
|
|
43
|
+
padding .2rem .6rem
|
|
44
|
+
border-radius 999px
|
|
45
|
+
white-space nowrap
|
|
46
|
+
|
|
47
|
+
.badge.shipped
|
|
48
|
+
background #e3f3ec
|
|
49
|
+
color $accent
|
|
50
|
+
|
|
51
|
+
.badge.wip
|
|
52
|
+
background #fef0e6
|
|
53
|
+
color $warm
|
|
54
|
+
|
|
55
|
+
.badge.blocked
|
|
56
|
+
background #f3e3e3
|
|
57
|
+
color #9a2a2a
|
|
58
|
+
|
|
59
|
+
button
|
|
60
|
+
background $accent
|
|
61
|
+
color white
|
|
62
|
+
border 0
|
|
63
|
+
padding .55rem .9rem
|
|
64
|
+
margin .25rem .4rem .25rem 0
|
|
65
|
+
border-radius 8px
|
|
66
|
+
font-size .95rem
|
|
67
|
+
cursor pointer
|
|
68
|
+
|
|
69
|
+
input
|
|
70
|
+
font-size 1rem
|
|
71
|
+
padding .6rem .7rem
|
|
72
|
+
border 1px solid $line
|
|
73
|
+
border-radius 8px
|
|
74
|
+
width 100%
|
|
75
|
+
max-width 22rem
|
|
76
|
+
margin .3rem 0
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: A live app in one Markdown file
|
|
3
|
+
description: One .wd file — static prose, fetched data, looped results with nested conditionals, a persistent counter, and a form that round-trips to a real server. No JavaScript written, no components, no build config.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
@include /nav.wd
|
|
7
|
+
|
|
8
|
+
<main>
|
|
9
|
+
|
|
10
|
+
# A live app, in one Markdown file
|
|
11
|
+
|
|
12
|
+
Everything on this page — the prose, the fetched list, the badges, the form, the counter — is a single `.wd` file. No components. No JSX. No build config. Watch where JavaScript actually enters.
|
|
13
|
+
|
|
14
|
+
## Static until proven reactive
|
|
15
|
+
|
|
16
|
+
This whole section is plain Markdown. The list below is unrolled **at build time** from a JSON file — it renders into HTML with no runtime at all:
|
|
17
|
+
|
|
18
|
+
@loop /features.json into feature
|
|
19
|
+
- **{ feature.name }** — { feature.detail }
|
|
20
|
+
@endloop
|
|
21
|
+
|
|
22
|
+
<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 ~2 KB runtime loads — and only because the document asks for it.</aside>
|
|
23
|
+
|
|
24
|
+
## Fetched data, looped, with nested conditions
|
|
25
|
+
|
|
26
|
+
`:fetch` pulls JSON over the network into reactive state — from a static file here, or any API. Until it lands, the page shows a fallback. Each row then decides its own badge from **nested** conditionals, in plain Markdown:
|
|
27
|
+
|
|
28
|
+
:fetch roadmap from "/__wd/data/roadmap.json"
|
|
29
|
+
|
|
30
|
+
:if roadmap
|
|
31
|
+
@loop roadmap into item
|
|
32
|
+
::: card .roadmap-row
|
|
33
|
+
**{ item.title }**
|
|
34
|
+
:if item.blocked
|
|
35
|
+
<span class="badge blocked">blocked</span>
|
|
36
|
+
:else
|
|
37
|
+
:if item.shipped
|
|
38
|
+
<span class="badge shipped">shipped ✓</span>
|
|
39
|
+
:else
|
|
40
|
+
<span class="badge wip">in progress</span>
|
|
41
|
+
:endif
|
|
42
|
+
:endif
|
|
43
|
+
:::
|
|
44
|
+
@endloop
|
|
45
|
+
:else
|
|
46
|
+
Loading the roadmap…
|
|
47
|
+
:endif
|
|
48
|
+
|
|
49
|
+
No ternaries, no `.map()`, no framework — the outer `:if` checks `blocked`, the inner one checks `shipped`, per row, and both stay reactive.
|
|
50
|
+
|
|
51
|
+
## A counter that survives reload
|
|
52
|
+
|
|
53
|
+
`:state … persist` keeps this in `localStorage`, and `:computed` derives from it. Star a few, reload the page, and it holds:
|
|
54
|
+
|
|
55
|
+
::: section #watch
|
|
56
|
+
:state stars = 0 persist
|
|
57
|
+
:computed milestone = stars >= 3
|
|
58
|
+
|
|
59
|
+
You are watching { stars } item(s).
|
|
60
|
+
|
|
61
|
+
:button "★ Watch one" -> stars++
|
|
62
|
+
:button "Reset" -> stars = 0
|
|
63
|
+
|
|
64
|
+
:if milestone
|
|
65
|
+
Nice — you are tracking the whole near-term roadmap.
|
|
66
|
+
:endif
|
|
67
|
+
:::
|
|
68
|
+
|
|
69
|
+
## A form that talks to a real server
|
|
70
|
+
|
|
71
|
+
This form posts to a live serverless function and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST — Darkmown adapts to your backend instead of owning one:
|
|
72
|
+
|
|
73
|
+
:form action="/__wd/echo" into reply
|
|
74
|
+
:input note placeholder="Tell us what you'd build with this"
|
|
75
|
+
:submit "Send to the server"
|
|
76
|
+
:endform
|
|
77
|
+
|
|
78
|
+
:if reply
|
|
79
|
+
The server got it and answered at **{ reply.at }**.
|
|
80
|
+
:else
|
|
81
|
+
Your reply will appear here.
|
|
82
|
+
:endif
|
|
83
|
+
|
|
84
|
+
:if reply_error
|
|
85
|
+
Something went wrong: { reply_error }
|
|
86
|
+
:endif
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
That was one file. Static prose, build-time loops, fetched data, nested conditionals, persistent state, computed values, and a server round-trip — and the only JavaScript on the page is the shared runtime Darkmown loads for you. [Read the docs](/docs/) or [deploy your own](/#deploy).
|
|
91
|
+
|
|
92
|
+
</main>
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -75,7 +75,7 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
|
75
75
|
|
|
76
76
|
## Reactive directives
|
|
77
77
|
|
|
78
|
-
Reactive pages opt into `/__wd/runtime.js` (under
|
|
78
|
+
Reactive pages opt into `/__wd/runtime.js` (~2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
|
|
79
79
|
|
|
80
80
|
```wd
|
|
81
81
|
:state count = 0
|
|
@@ -107,7 +107,7 @@ Directive actions are intentionally narrow and compile-time checked. Arbitrary J
|
|
|
107
107
|
- `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead.
|
|
108
108
|
- `:input field placeholder="…" required` and `:submit "Label"` build the form body.
|
|
109
109
|
- `:state cart = [] persist` survives reloads via localStorage.
|
|
110
|
-
- `:if item.path` works inside reactive loops for per-row branches.
|
|
110
|
+
- `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
|
|
111
111
|
- Reactive pages expose `window.wd` (`get`, `set`, `state`, `render`) so colocated `.js` can do anything directives can't. Section state is addressed as `sectionId:name`.
|
|
112
112
|
|
|
113
113
|
See it all live on the [Data & Forms page](/data/).
|
|
@@ -120,6 +120,6 @@ See it all live on the [Data & Forms page](/data/).
|
|
|
120
120
|
|
|
121
121
|
## Spec status
|
|
122
122
|
|
|
123
|
-
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. `:fetch`, `:form
|
|
123
|
+
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. `:fetch`, `:form` (including server round-trips), `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/). Still on the roadmap: a first-party server runtime (`site/api/`), HTML-fragment swaps, and nested `:if` over loop items. See `docs/spec-alignment.md` in the package for the full audit.
|
|
124
124
|
|
|
125
125
|
</main>
|
package/site/pages/index.wd
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Darkmown
|
|
2
|
+
title: Darkmown — Markdown that runs
|
|
3
|
+
description: Build full websites in plain Markdown. Routing, state, server calls, includes, and reactive islands — no JavaScript or CSS required. Your docs and your code are all just text.
|
|
3
4
|
---
|
|
4
5
|
|
|
5
6
|
@include /nav.wd
|
|
@@ -7,25 +8,36 @@ title: Darkmown
|
|
|
7
8
|
<main>
|
|
8
9
|
|
|
9
10
|
<section class="hero">
|
|
10
|
-
<p class="eyebrow">Markdown
|
|
11
|
+
<p class="eyebrow">The Markdown framework</p>
|
|
11
12
|
|
|
12
|
-
#
|
|
13
|
+
# Markdown that runs
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
Not just pages — **full websites**. Routing, state, server calls, embeds, and reactive islands, all from plain Markdown files. No JavaScript or CSS required. Your docs and your code are all just text.
|
|
15
16
|
|
|
16
|
-
<a class="btn primary" href="/
|
|
17
|
-
<a class="btn" href="/
|
|
17
|
+
<a class="btn primary" href="/app/">See a whole app in one file</a>
|
|
18
|
+
<a class="btn" href="/docs/">Read the docs</a>
|
|
18
19
|
</section>
|
|
19
20
|
|
|
20
|
-
:note "
|
|
21
|
+
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity, one ~2 KB runtime loads — never before. JS optional, CSS optional, always."
|
|
22
|
+
|
|
23
|
+
## A whole framework, in text
|
|
24
|
+
|
|
25
|
+
Everything a site needs, expressed as Markdown directives. No components, no config, no JSX, no special files:
|
|
26
|
+
|
|
27
|
+
- **Routing** — folders are routes. Drop a file in, it's a page. Prefix with `.` or `-` to hide it.
|
|
28
|
+
- **State & reactivity** — `:state`, `:button`, `:if`, and keyed `@loop`s patch the DOM directly. No virtual DOM.
|
|
29
|
+
- **Server calls** — `:fetch` pulls JSON into state; `:form action=` round-trips to any backend and degrades to a native POST without JS.
|
|
30
|
+
- **Composition** — `@include` embeds one Markdown file into another, passing values down Liquid-style.
|
|
31
|
+
- **Styling, optional** — colocate a `.skin` file for design tokens, or ship none and let the browser default.
|
|
21
32
|
|
|
22
33
|
## What to try
|
|
23
34
|
|
|
24
|
-
:try "
|
|
25
|
-
:try "
|
|
26
|
-
:try "
|
|
27
|
-
:try "
|
|
28
|
-
:try "
|
|
35
|
+
:try "A whole live app in one .wd file: fetch, loops, nested ifs, a form" href="/app/"
|
|
36
|
+
:try "The docs route — a .wd page built from includes" href="/docs/"
|
|
37
|
+
:try "A plain .md page where directives stay inert text" href="/markdown/"
|
|
38
|
+
:try "Reactive page: state, keyed loops, scoped sections" href="/reactive/"
|
|
39
|
+
:try "Data page: fetch, forms, server round-trips, persistence" href="/data/"
|
|
40
|
+
:try "A hidden route, to confirm it 404s" href="/-draft/"
|
|
29
41
|
|
|
30
42
|
## Loops that read like prose
|
|
31
43
|
|
|
@@ -47,15 +59,19 @@ This button is powered by `site/pages/index.js`, discovered by matching the page
|
|
|
47
59
|
|
|
48
60
|
<button class="pulse-button" data-wd-demo>Script is waiting</button>
|
|
49
61
|
|
|
62
|
+
<span id="deploy"></span>
|
|
63
|
+
|
|
50
64
|
## Deploy in one click
|
|
51
65
|
|
|
52
|
-
|
|
66
|
+
`npm run build` emits a plain static `dist/` — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build:
|
|
53
67
|
|
|
54
68
|
<a class="btn primary" href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Vercel</a>
|
|
55
69
|
<a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Cloudflare</a>
|
|
56
70
|
<a class="btn" href="https://github.com/ZVN-DEV/darkmown">Star on GitHub</a>
|
|
57
71
|
|
|
58
|
-
##
|
|
72
|
+
## Built in the open
|
|
73
|
+
|
|
74
|
+
Darkmown itself was built by a small agent team. The board below is a live `@loop` over real data — itself a demo of the framework rendering this page:
|
|
59
75
|
|
|
60
76
|
:sprint min=4 max=6 roles="PM, Development, Research, Testing, Review"
|
|
61
77
|
|
package/site/pages/reactive.wd
CHANGED
|
@@ -39,6 +39,31 @@ The same `@loop` that unrolls static data also drives reactive lists. Items are
|
|
|
39
39
|
|
|
40
40
|
:button "Add todo" -> todos += {"id": 3, "title": "Live compile", "owner": "Testing"}
|
|
41
41
|
|
|
42
|
+
## Conditionals inside a loop, nested
|
|
43
|
+
|
|
44
|
+
`:if` works per row, and conditionals nest — the inner branch is decided after the outer one, for every item, and stays reactive. Add a "done" task and watch which badge each row shows:
|
|
45
|
+
|
|
46
|
+
:state tasks = [{"id": 1, "title": "Draft the spec", "done": true, "blocked": false}, {"id": 2, "title": "Wire the runtime", "done": false, "blocked": true}, {"id": 3, "title": "Write the demo", "done": false, "blocked": false}]
|
|
47
|
+
|
|
48
|
+
@loop tasks into task
|
|
49
|
+
::: card .task-row
|
|
50
|
+
**{ task.title }** —
|
|
51
|
+
:if task.done
|
|
52
|
+
done ✓
|
|
53
|
+
:else
|
|
54
|
+
:if task.blocked
|
|
55
|
+
blocked ✗
|
|
56
|
+
:else
|
|
57
|
+
in progress …
|
|
58
|
+
:endif
|
|
59
|
+
:endif
|
|
60
|
+
:::
|
|
61
|
+
@endloop
|
|
62
|
+
|
|
63
|
+
:button "Add a finished task" -> tasks += {"id": 4, "title": "Ship it", "done": true, "blocked": false}
|
|
64
|
+
|
|
65
|
+
Each row picks one of three states from two nested conditionals, all in plain Markdown — no component, no ternary, no JavaScript.
|
|
66
|
+
|
|
42
67
|
## Section-scoped state
|
|
43
68
|
|
|
44
69
|
Two sections can each own a state value named `tally` without colliding. Buttons resolve to the nearest scope:
|
package/src/compiler.js
CHANGED
|
@@ -10,6 +10,14 @@ const pageIncludeExtensions = [".md", ".wd"];
|
|
|
10
10
|
export function compilePage(file, context) {
|
|
11
11
|
const compiled = compileDocument(file, context);
|
|
12
12
|
const title = compiled.meta.title || "Darkmown";
|
|
13
|
+
const description = compiled.meta.description || "";
|
|
14
|
+
const descriptionTag = description
|
|
15
|
+
? `\n <meta name="description" content="${escapeHtml(description)}">
|
|
16
|
+
<meta property="og:title" content="${escapeHtml(title)}">
|
|
17
|
+
<meta property="og:description" content="${escapeHtml(description)}">
|
|
18
|
+
<meta property="og:type" content="website">
|
|
19
|
+
<meta name="twitter:card" content="summary">`
|
|
20
|
+
: "";
|
|
13
21
|
const favicon = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
|
|
14
22
|
const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
|
|
15
23
|
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
@@ -26,7 +34,7 @@ export function compilePage(file, context) {
|
|
|
26
34
|
<head>
|
|
27
35
|
<meta charset="utf-8">
|
|
28
36
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
-
<title>${escapeHtml(title)}</title
|
|
37
|
+
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
30
38
|
<link rel="icon" href="${favicon}">
|
|
31
39
|
${cssLinks}${transitions}
|
|
32
40
|
</head>
|
|
@@ -530,9 +538,6 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
|
530
538
|
if (ctx.loopItem && head === ctx.loopItem) {
|
|
531
539
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
532
540
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
533
|
-
if (truthy.includes("data-wd-each-if") || falsy.includes("data-wd-each-if")) {
|
|
534
|
-
throw new Error(`Nested :if over loop items is not supported yet (${ctx.file})`);
|
|
535
|
-
}
|
|
536
541
|
const rest = segs.slice(1).join(".");
|
|
537
542
|
const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
|
|
538
543
|
return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
@@ -624,20 +629,75 @@ function withLoopKey(template, itemKey) {
|
|
|
624
629
|
}
|
|
625
630
|
|
|
626
631
|
function fillTemplateString(template, item) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
632
|
+
// Resolve per-item :if regions first (recursively, so nested conditionals are
|
|
633
|
+
// pre-rendered for the initial paint), then fill the plain text bindings.
|
|
634
|
+
return fillEachText(fillEachIfRegions(template, item), item);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
|
|
638
|
+
// chosen branch is recursed so nested conditionals resolve too. The <template>
|
|
639
|
+
// markup is left pristine so the runtime can keep toggling branches.
|
|
640
|
+
function fillEachIfRegions(str, item) {
|
|
641
|
+
const marker = '<span data-wd-each-if ';
|
|
642
|
+
let result = "";
|
|
643
|
+
let i = 0;
|
|
644
|
+
for (;;) {
|
|
645
|
+
const start = str.indexOf(marker, i);
|
|
646
|
+
if (start === -1) return result + str.slice(i);
|
|
647
|
+
result += str.slice(i, start);
|
|
648
|
+
const end = matchElement(str, start, "span");
|
|
649
|
+
result += fillOneEachIf(str.slice(start, end), item);
|
|
650
|
+
i = end;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function fillOneEachIf(region, item) {
|
|
655
|
+
const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || [, ""])[1];
|
|
656
|
+
const trueStart = region.indexOf("<template data-wd-if-true>");
|
|
657
|
+
const trueEnd = matchElement(region, trueStart, "template");
|
|
658
|
+
const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
|
|
659
|
+
const falseEnd = matchElement(region, falseStart, "template");
|
|
660
|
+
const open = "<template data-wd-if-true>".length;
|
|
661
|
+
const close = "</template>".length;
|
|
662
|
+
const truthy = region.slice(trueStart + open, trueEnd - close);
|
|
663
|
+
const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
|
|
664
|
+
const branch = getPath(item, path ? path.split(".") : []) ? truthy : falsy;
|
|
665
|
+
const head = region.slice(0, falseEnd);
|
|
666
|
+
return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function fillEachText(str, item) {
|
|
670
|
+
return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
|
|
635
671
|
const value = p ? getPath(item, p.split(".")) : item;
|
|
636
672
|
const pathAttr = p ? ` data-wd-path="${p}"` : "";
|
|
637
673
|
return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
|
|
638
674
|
});
|
|
639
675
|
}
|
|
640
676
|
|
|
677
|
+
// Return the index just past the balanced close of the element of `tag` that
|
|
678
|
+
// begins at `start`. Counts nested same-tag opens/closes so regions that embed
|
|
679
|
+
// their own spans/templates (nested :if) match correctly.
|
|
680
|
+
function matchElement(str, start, tag) {
|
|
681
|
+
const openPrefix = `<${tag}`;
|
|
682
|
+
const closeTag = `</${tag}>`;
|
|
683
|
+
let depth = 0;
|
|
684
|
+
let i = start;
|
|
685
|
+
while (i < str.length) {
|
|
686
|
+
if (str.startsWith(openPrefix, i) && (str[i + openPrefix.length] === " " || str[i + openPrefix.length] === ">")) {
|
|
687
|
+
depth++;
|
|
688
|
+
const gt = str.indexOf(">", i);
|
|
689
|
+
i = gt === -1 ? str.length : gt + 1;
|
|
690
|
+
} else if (str.startsWith(closeTag, i)) {
|
|
691
|
+
depth--;
|
|
692
|
+
i += closeTag.length;
|
|
693
|
+
if (depth === 0) return i;
|
|
694
|
+
} else {
|
|
695
|
+
i++;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return str.length;
|
|
699
|
+
}
|
|
700
|
+
|
|
641
701
|
export function loopKeyOf(item, counts) {
|
|
642
702
|
const base =
|
|
643
703
|
item && typeof item === "object"
|
package/src/runtime.js
CHANGED
|
@@ -61,11 +61,14 @@ function loopKeyOf(item, counts) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function fillItem(node, item) {
|
|
64
|
+
// querySelectorAll does not descend into <template> content, so this only sees
|
|
65
|
+
// the outermost regions; recursing into each injected branch fills the rest.
|
|
64
66
|
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
65
67
|
const value = getPath(item, region.getAttribute("data-wd-path"));
|
|
66
68
|
const output = region.querySelector("[data-wd-each-if-out]");
|
|
67
69
|
const template = region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]");
|
|
68
70
|
output.innerHTML = template?.innerHTML || "";
|
|
71
|
+
fillItem(output, item);
|
|
69
72
|
}
|
|
70
73
|
const targets = node.matches("[data-wd-each]")
|
|
71
74
|
? [node, ...node.querySelectorAll("[data-wd-each]")]
|