@zvndev/darkmown 0.8.2 → 0.9.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 +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +4 -2
- package/docs/spec-alignment.md +1 -1
- package/package.json +17 -3
- package/site/pages/docs/index.wd +1 -1
- package/site/pages/index.skin +158 -0
- package/site/pages/index.wd +95 -6
- package/src/builder.js +60 -1
- package/src/cli.js +14 -1
- package/src/compiler.js +503 -14
- package/src/config.js +19 -0
- package/src/dev.js +9 -0
- package/src/router.js +26 -0
- package/src/runtime.js +102 -26
- package/src/scaffold.js +12 -0
- package/src/skin.js +31 -3
- package/src/statics.js +20 -0
- package/types/builder.d.ts +39 -0
- package/types/compiler.d.ts +184 -0
- package/types/config.d.ts +45 -0
- package/types/dev.d.ts +13 -0
- package/types/index.d.ts +33 -0
- package/types/router.d.ts +39 -0
- package/types/scaffold.d.ts +8 -0
- package/types/skin.d.ts +21 -0
- package/types/statics.d.ts +23 -0
- package/docs/okf-integration.md +0 -120
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 ~3.
|
|
10
|
+
- Static pages ship **zero** JavaScript; a page loads the ~3.2 KB runtime only if it declares reactive behavior.
|
|
11
11
|
|
|
12
12
|
## Project layout & routing
|
|
13
13
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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.9.0 — 2026-06-15
|
|
6
|
+
|
|
7
|
+
The "proof + full test pyramid" release: prove the moat on the homepage, test everything from unit to browser e2e, and pass a gold-standard open-source audit.
|
|
8
|
+
|
|
9
|
+
- **Landing: the vs-Markdoc proof.** The homepage is now a side-by-side comparison — the real `.wd` source for a live search-filter + add-to-cart store rendered working on the page, next to the equivalent Markdoc (which has no native loop and needs a React component + hydration for interactivity). The homepage is now itself a reactive `.wd` app; `/markdown/` and `/docs/` stay static (zero JS) to prove the static path.
|
|
10
|
+
- **Full test pyramid.** Unit + integration backfill (grammar whitelists, skin compiler, interpolation, router, every compile-error path), a zero-dependency CLI/pipeline e2e (`darkmown init` → build → serve → fetch → assert), real-browser e2e via Playwright (`npm run test:e2e`), and a seeded property/fuzz suite (5,000 cases/run) for the parser and expression validators. The zero-dep `npm test` suite grew from 98 to ~200 checks; coverage is ~96% lines, gated at 80% in CI.
|
|
11
|
+
- **The fuzzer earned its keep:** it found two real bugs, now fixed — two `:button` action errors that omitted the file path, and bare object interpolation (`{ meta }`) emitting `[object Object]` instead of an actionable error. Arrays now join with `, ` consistently.
|
|
12
|
+
- **Type safety.** Source stays plain JavaScript but is fully type-checked via JSDoc + `checkJs` (`npm run typecheck`, gated in CI), and ships `.d.ts` declarations. `typescript` is a dev-only dependency; the runtime stays zero-dependency.
|
|
13
|
+
- **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still downloads ~3.2 KB gzipped even though the source carries full type annotations. The size budget now measures the shipped artifact.
|
|
14
|
+
- **Gold-standard hardening (46.5/50 audit).** Release pack-smoke test before publish, `prepublishOnly` guard, `dependabot` (npm + GitHub Actions), CI `concurrency` cancellation, `.github/` PR + issue templates, `CODEOWNERS`, a short `CODE_OF_CONDUCT.md`, an exposed/documented `window.wd.debug`, and repo-root tidy-up.
|
|
15
|
+
|
|
5
16
|
## 0.8.2 — 2026-06-14
|
|
6
17
|
|
|
7
18
|
Honesty, hardening, and polish pass — every claim in the docs is now backed by shipped code.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**[darkmown.com](https://darkmown.com)** · Markdown that runs.
|
|
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 around ~3.
|
|
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 ~3.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 ~3.
|
|
48
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~3.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
|
|
@@ -209,6 +209,8 @@ Loading…
|
|
|
209
209
|
|
|
210
210
|
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`.
|
|
211
211
|
|
|
212
|
+
Set `window.wd.debug = true` (it defaults to `false`) to log any `:computed` or `@loop … where` expression that fails to evaluate to the console — useful while authoring reactive pages.
|
|
213
|
+
|
|
212
214
|
## Editor support
|
|
213
215
|
|
|
214
216
|
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. Install it from source by building a `.vsix`: `cd editors/vscode && npx @vscode/vsce package`, then `code --install-extension darkmown-*.vsix`. A Marketplace listing is coming soon.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -6,7 +6,7 @@ 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` (~3.
|
|
9
|
+
- **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~3.2 KB gzipped, CI-enforced below the 5 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -10,15 +10,23 @@
|
|
|
10
10
|
"bin": {
|
|
11
11
|
"darkmown": "./src/cli.js"
|
|
12
12
|
},
|
|
13
|
+
"types": "./types/index.d.ts",
|
|
13
14
|
"scripts": {
|
|
14
15
|
"build": "node src/cli.js build",
|
|
15
16
|
"dev": "node src/cli.js dev",
|
|
16
|
-
"test": "node --test tests/*.test.js"
|
|
17
|
+
"test": "node --test tests/*.test.js",
|
|
18
|
+
"test:cov": "node --test --experimental-test-coverage tests/*.test.js",
|
|
19
|
+
"test:e2e": "playwright test",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"types": "tsc -p tsconfig.types.json",
|
|
22
|
+
"prepublishOnly": "npm test"
|
|
17
23
|
},
|
|
18
24
|
"files": [
|
|
19
25
|
"src",
|
|
26
|
+
"types",
|
|
20
27
|
"site",
|
|
21
|
-
"docs",
|
|
28
|
+
"docs/cli.md",
|
|
29
|
+
"docs/spec-alignment.md",
|
|
22
30
|
"AGENTS.md",
|
|
23
31
|
"README.md",
|
|
24
32
|
"LICENSE",
|
|
@@ -30,6 +38,12 @@
|
|
|
30
38
|
"dependencies": {
|
|
31
39
|
"markdown-it": "^14.2.0"
|
|
32
40
|
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@playwright/test": "^1.49.0",
|
|
43
|
+
"@types/markdown-it": "^14.1.2",
|
|
44
|
+
"@types/node": "^22.19.21",
|
|
45
|
+
"typescript": "^5.9.3"
|
|
46
|
+
},
|
|
33
47
|
"repository": {
|
|
34
48
|
"type": "git",
|
|
35
49
|
"url": "git+https://github.com/ZVN-DEV/darkmown.git"
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -131,7 +131,7 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
131
131
|
|
|
132
132
|
## Reactive directives
|
|
133
133
|
|
|
134
|
-
Reactive pages opt into `/__wd/runtime.js` (~3.
|
|
134
|
+
Reactive pages opt into `/__wd/runtime.js` (~3.2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
|
|
135
135
|
|
|
136
136
|
```wd
|
|
137
137
|
:state count = 0
|
package/site/pages/index.skin
CHANGED
|
@@ -133,3 +133,161 @@ a
|
|
|
133
133
|
display block
|
|
134
134
|
color $accent
|
|
135
135
|
font-size .9rem
|
|
136
|
+
|
|
137
|
+
/* ── vs-Markdoc comparison ───────────────────────────────────────── */
|
|
138
|
+
|
|
139
|
+
.versus
|
|
140
|
+
display grid
|
|
141
|
+
grid-template-columns 1fr 1fr
|
|
142
|
+
gap 1.25rem
|
|
143
|
+
align-items start
|
|
144
|
+
margin 2.5rem 0 1rem
|
|
145
|
+
|
|
146
|
+
.vs-card
|
|
147
|
+
border 1px solid $line
|
|
148
|
+
radius $radius
|
|
149
|
+
bg $panel
|
|
150
|
+
padding 1.25rem 1.25rem 1.1rem
|
|
151
|
+
display flex
|
|
152
|
+
flex-direction column
|
|
153
|
+
min-width 0
|
|
154
|
+
|
|
155
|
+
.vs-card.vs-dark
|
|
156
|
+
bg #fbf7ef
|
|
157
|
+
border 1px solid #e3d8c4
|
|
158
|
+
box-shadow 0 1px 0 #efe7d6
|
|
159
|
+
|
|
160
|
+
.vs-head
|
|
161
|
+
display flex
|
|
162
|
+
align-items baseline
|
|
163
|
+
justify-content space-between
|
|
164
|
+
gap .75rem
|
|
165
|
+
margin-bottom .85rem
|
|
166
|
+
|
|
167
|
+
.vs-tag
|
|
168
|
+
font-family ui-monospace, SFMono-Regular, Menlo, monospace
|
|
169
|
+
font-size .72rem
|
|
170
|
+
font-weight 700
|
|
171
|
+
text-transform uppercase
|
|
172
|
+
letter-spacing .08em
|
|
173
|
+
padding .25rem .55rem
|
|
174
|
+
radius 999px
|
|
175
|
+
|
|
176
|
+
.vs-tag-wd
|
|
177
|
+
color white
|
|
178
|
+
bg $accent
|
|
179
|
+
|
|
180
|
+
.vs-tag-md
|
|
181
|
+
color $muted
|
|
182
|
+
bg #efece3
|
|
183
|
+
border 1px solid $line
|
|
184
|
+
|
|
185
|
+
.vs-sub
|
|
186
|
+
font-family ui-monospace, SFMono-Regular, Menlo, monospace
|
|
187
|
+
font-size .82rem
|
|
188
|
+
color $muted
|
|
189
|
+
|
|
190
|
+
.vs-card pre
|
|
191
|
+
margin .25rem 0 0
|
|
192
|
+
font-size .82rem
|
|
193
|
+
line-height 1.55
|
|
194
|
+
|
|
195
|
+
.vs-live-label
|
|
196
|
+
margin 1rem 0 .35rem
|
|
197
|
+
font-size .88rem
|
|
198
|
+
color $muted
|
|
199
|
+
font-style italic
|
|
200
|
+
|
|
201
|
+
.vs-live
|
|
202
|
+
border 1px dashed #cdbfa6
|
|
203
|
+
radius $radius
|
|
204
|
+
bg #fffdf8
|
|
205
|
+
padding 1rem 1.1rem .85rem
|
|
206
|
+
margin .15rem 0 0
|
|
207
|
+
|
|
208
|
+
.vs-live input[data-wd-bind-input]
|
|
209
|
+
width 100%
|
|
210
|
+
box-sizing border-box
|
|
211
|
+
font inherit
|
|
212
|
+
font-size .95rem
|
|
213
|
+
padding .55rem .7rem
|
|
214
|
+
border 1px solid $line
|
|
215
|
+
radius 6px
|
|
216
|
+
bg white
|
|
217
|
+
margin 0 0 .75rem
|
|
218
|
+
|
|
219
|
+
.vs-live .product
|
|
220
|
+
display flex
|
|
221
|
+
align-items center
|
|
222
|
+
justify-content space-between
|
|
223
|
+
gap .75rem
|
|
224
|
+
padding .5rem .7rem
|
|
225
|
+
margin .35rem 0
|
|
226
|
+
bg $panel
|
|
227
|
+
border 1px solid $line
|
|
228
|
+
radius 6px
|
|
229
|
+
font-size .95rem
|
|
230
|
+
|
|
231
|
+
.vs-live .product p
|
|
232
|
+
margin 0
|
|
233
|
+
|
|
234
|
+
.vs-live .product button
|
|
235
|
+
border 0
|
|
236
|
+
bg $accent
|
|
237
|
+
color white
|
|
238
|
+
font inherit
|
|
239
|
+
font-size .82rem
|
|
240
|
+
padding .35rem .7rem
|
|
241
|
+
radius 6px
|
|
242
|
+
cursor pointer
|
|
243
|
+
flex-shrink 0
|
|
244
|
+
|
|
245
|
+
.vs-live .product button:hover
|
|
246
|
+
bg #11514a
|
|
247
|
+
|
|
248
|
+
.vs-live > p:last-of-type
|
|
249
|
+
margin .85rem 0 0
|
|
250
|
+
font-size .95rem
|
|
251
|
+
|
|
252
|
+
.vs-needs
|
|
253
|
+
margin 1rem 0 0
|
|
254
|
+
padding 1rem 1.1rem
|
|
255
|
+
border-left 3px solid $warm
|
|
256
|
+
bg #fcf4ef
|
|
257
|
+
radius 6px
|
|
258
|
+
|
|
259
|
+
.vs-needs strong
|
|
260
|
+
color $warm
|
|
261
|
+
font-size .92rem
|
|
262
|
+
|
|
263
|
+
.vs-needs pre
|
|
264
|
+
margin .7rem 0 .6rem
|
|
265
|
+
font-size .78rem
|
|
266
|
+
|
|
267
|
+
.vs-needs-foot
|
|
268
|
+
display block
|
|
269
|
+
font-size .85rem
|
|
270
|
+
color $muted
|
|
271
|
+
|
|
272
|
+
.vs-caption
|
|
273
|
+
margin auto 0 0
|
|
274
|
+
padding-top 1rem
|
|
275
|
+
font-style italic
|
|
276
|
+
color $ink
|
|
277
|
+
font-size .95rem
|
|
278
|
+
|
|
279
|
+
.vs-card.vs-dark .vs-caption
|
|
280
|
+
color $accent
|
|
281
|
+
|
|
282
|
+
/* Secondary value-prop row, demoted below the comparison */
|
|
283
|
+
|
|
284
|
+
.props
|
|
285
|
+
margin 1.5rem 0 0
|
|
286
|
+
|
|
287
|
+
.props h2
|
|
288
|
+
margin-top 1.5rem
|
|
289
|
+
|
|
290
|
+
@media (max-width: 720px)
|
|
291
|
+
.versus
|
|
292
|
+
grid-template-columns 1fr
|
|
293
|
+
gap 1rem
|
package/site/pages/index.wd
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Darkmown — Markdown that runs
|
|
3
|
-
description:
|
|
2
|
+
title: Darkmown — Markdown that runs, not Markdown that needs React
|
|
3
|
+
description: A live store widget — search filter, add-to-cart, running total — written as one Markdown file that runs. The same outcome in Markdoc needs a React component, hydration, and a build step. Compare them side by side.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
@include /nav.wd
|
|
@@ -8,17 +8,105 @@ description: Build full websites in plain Markdown. Routing, state, server calls
|
|
|
8
8
|
<main>
|
|
9
9
|
|
|
10
10
|
<section class="hero">
|
|
11
|
-
<p class="eyebrow">
|
|
11
|
+
<p class="eyebrow">Darkmown vs. Markdoc</p>
|
|
12
12
|
|
|
13
|
-
# Markdown that runs.
|
|
13
|
+
# Markdown that runs. Not Markdown that needs React.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The left file is Markdown. It is also a running app — a live search filter, add-to-cart, and a running total. The right file is the closest Markdoc can get on its own: a static list. To make it do what the left one already does, you add a React component, hydration, and a build step.
|
|
16
16
|
|
|
17
17
|
<a class="btn primary" href="/app/">See a whole app in one file</a>
|
|
18
18
|
<a class="btn" href="/docs/">Read the docs</a>
|
|
19
19
|
</section>
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
<section class="versus">
|
|
22
|
+
|
|
23
|
+
::: div .vs-card .vs-dark
|
|
24
|
+
<header class="vs-head"><span class="vs-tag vs-tag-wd">Darkmown</span><span class="vs-sub">store.wd</span></header>
|
|
25
|
+
|
|
26
|
+
```wd
|
|
27
|
+
:state products = [{"id":1,"name":"Aurora Lamp","price":49},{"id":2,"name":"Briza Fan","price":39},{"id":3,"name":"Cove Speaker","price":89},{"id":4,"name":"Dune Mug","price":12}]
|
|
28
|
+
:state cart = []
|
|
29
|
+
:state q = ""
|
|
30
|
+
:computed count = cart.length
|
|
31
|
+
|
|
32
|
+
:bind q placeholder="Search products…"
|
|
33
|
+
|
|
34
|
+
@loop products into p where p.name contains q
|
|
35
|
+
::: card .product
|
|
36
|
+
**{ p.name }** — ${ p.price }
|
|
37
|
+
:button "Add" -> cart += p
|
|
38
|
+
:::
|
|
39
|
+
@endloop
|
|
40
|
+
|
|
41
|
+
Cart: { count } item(s)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
<p class="vs-live-label">↓ and here is that exact file, running</p>
|
|
45
|
+
|
|
46
|
+
::: div .vs-live
|
|
47
|
+
:state products = [{"id":1,"name":"Aurora Lamp","price":49},{"id":2,"name":"Briza Fan","price":39},{"id":3,"name":"Cove Speaker","price":89},{"id":4,"name":"Dune Mug","price":12}]
|
|
48
|
+
:state cart = []
|
|
49
|
+
:state q = ""
|
|
50
|
+
:computed count = cart.length
|
|
51
|
+
|
|
52
|
+
:bind q placeholder="Search products…"
|
|
53
|
+
|
|
54
|
+
@loop products into p where p.name contains q
|
|
55
|
+
::: card .product
|
|
56
|
+
**{ p.name }** — ${ p.price }
|
|
57
|
+
:button "Add" -> cart += p
|
|
58
|
+
:::
|
|
59
|
+
@endloop
|
|
60
|
+
|
|
61
|
+
Cart: **{ count }** item(s)
|
|
62
|
+
:::
|
|
63
|
+
|
|
64
|
+
<p class="vs-caption">This is the whole file. It runs.</p>
|
|
65
|
+
:::
|
|
66
|
+
|
|
67
|
+
::: div .vs-card
|
|
68
|
+
<header class="vs-head"><span class="vs-tag vs-tag-md">Markdoc</span><span class="vs-sub">store.md</span></header>
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
{% if $products %}
|
|
72
|
+
- **Aurora Lamp** — $49
|
|
73
|
+
- **Briza Fan** — $39
|
|
74
|
+
- **Cove Speaker** — $89
|
|
75
|
+
- **Dune Mug** — $12
|
|
76
|
+
{% /if %}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
<p class="vs-live-label">Markdoc has <code>{% if %}</code>, <code>{% partial %}</code>, and <code>$variables</code> — but no loop tag, so each row is written by hand and the list is static. To filter, add to a cart, or total it, you reach for code:</p>
|
|
80
|
+
|
|
81
|
+
::: div .vs-needs
|
|
82
|
+
<strong>+ store-widget.jsx</strong> — a React component, registered as a custom tag:
|
|
83
|
+
|
|
84
|
+
```jsx
|
|
85
|
+
const tags = { store: { render: 'Store' } };
|
|
86
|
+
Markdoc.renderers.react(content, React, {
|
|
87
|
+
components: { Store } // your filter + cart + total live here
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
<span class="vs-needs-foot">…plus a React + hydration runtime shipped to the browser, and the build step that wires the tag to the component.</span>
|
|
92
|
+
:::
|
|
93
|
+
|
|
94
|
+
<p class="vs-caption">Same outcome needs a React component.</p>
|
|
95
|
+
:::
|
|
96
|
+
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~3.2 KB runtime loads, never before. JS optional, CSS optional, always."
|
|
100
|
+
|
|
101
|
+
<section class="props">
|
|
102
|
+
|
|
103
|
+
## Three rules, no escape hatch
|
|
104
|
+
|
|
105
|
+
- **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
|
|
106
|
+
- **One runtime.** Every reactive page shares the same ~3.2 KB runtime. No per-component bundles, no hydration tax that scales with your UI.
|
|
107
|
+
- **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
|
|
108
|
+
|
|
109
|
+
</section>
|
|
22
110
|
|
|
23
111
|
## A whole framework, in text
|
|
24
112
|
|
|
@@ -78,3 +166,4 @@ Darkmown itself was built by a small agent team. The board below is a live `@loo
|
|
|
78
166
|
@include /sprint-loop.wd
|
|
79
167
|
|
|
80
168
|
</main>
|
|
169
|
+
</content>
|
package/src/builder.js
CHANGED
|
@@ -6,16 +6,36 @@ import { createPaths } from "./config.js";
|
|
|
6
6
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
7
7
|
import { compileSkin } from "./skin.js";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import("./config.js").Paths} Paths
|
|
11
|
+
* @typedef {import("./compiler.js").Assets} Assets
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A built route entry written to `dist/routes.json`.
|
|
16
|
+
* @typedef {object} RouteManifestEntry
|
|
17
|
+
* @property {string} route Public route path.
|
|
18
|
+
* @property {string} file Source file path, relative to cwd, POSIX-separated.
|
|
19
|
+
* @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
|
|
20
|
+
*/
|
|
21
|
+
|
|
9
22
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Compile the whole site under `cwd` into `dist`, returning the route manifest.
|
|
26
|
+
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
27
|
+
* @returns {{ routes: RouteManifestEntry[], distRoot: string }}
|
|
28
|
+
*/
|
|
11
29
|
export function buildSite(cwd = process.cwd()) {
|
|
12
30
|
const paths = createPaths(cwd);
|
|
13
31
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
14
32
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
15
33
|
emitShelfAssets(paths);
|
|
16
34
|
const routes = discoverRoutes(paths.routesRoot);
|
|
35
|
+
/** @type {RouteManifestEntry[]} */
|
|
17
36
|
const manifest = [];
|
|
18
37
|
|
|
38
|
+
/** @type {Set<string>} */
|
|
19
39
|
const warned = new Set();
|
|
20
40
|
|
|
21
41
|
for (const route of routes) {
|
|
@@ -45,12 +65,41 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
45
65
|
return { routes: manifest, distRoot: paths.distRoot };
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Strip JSDoc `/** ... *\/` blocks from the runtime before it ships.
|
|
70
|
+
* The source keeps full type annotations (for `checkJs` + `.d.ts`), but the
|
|
71
|
+
* browser never needs to download them — this keeps the shipped runtime lean
|
|
72
|
+
* and the gzip budget honest (measured against what users actually receive).
|
|
73
|
+
* Only `/** ... *\/` blocks are removed; code and string literals are untouched.
|
|
74
|
+
* @param {string} source
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function stripRuntimeComments(source) {
|
|
78
|
+
return source
|
|
79
|
+
// Remove the JSDoc block text only — never the surrounding newlines, so an
|
|
80
|
+
// inline `/** @type {x} */ code` keeps its line and never glues onto a
|
|
81
|
+
// preceding `//` comment.
|
|
82
|
+
.replace(/\/\*\*[\s\S]*?\*\//g, "")
|
|
83
|
+
// Drop the now blank/whitespace-only lines the removed blocks leave behind.
|
|
84
|
+
.replace(/^[ \t]*\n/gm, "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {Paths} paths
|
|
89
|
+
* @returns {void}
|
|
90
|
+
*/
|
|
48
91
|
function emitRuntime(paths) {
|
|
49
92
|
const out = path.join(paths.distRoot, "__wd/runtime.js");
|
|
50
93
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
51
|
-
fs.
|
|
94
|
+
const source = fs.readFileSync(path.join(moduleDir, "runtime.js"), "utf8");
|
|
95
|
+
fs.writeFileSync(out, stripRuntimeComments(source));
|
|
52
96
|
}
|
|
53
97
|
|
|
98
|
+
/**
|
|
99
|
+
* @param {Assets} assets
|
|
100
|
+
* @param {Paths} paths
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
54
103
|
function emitAssets(assets, paths) {
|
|
55
104
|
for (const [source, href] of assets.files) {
|
|
56
105
|
const out = path.join(paths.distRoot, href);
|
|
@@ -63,6 +112,11 @@ function emitAssets(assets, paths) {
|
|
|
63
112
|
}
|
|
64
113
|
}
|
|
65
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
|
|
117
|
+
* @param {Paths} paths
|
|
118
|
+
* @returns {void}
|
|
119
|
+
*/
|
|
66
120
|
function emitShelfAssets(paths) {
|
|
67
121
|
if (!fs.existsSync(paths.shelfRoot)) return;
|
|
68
122
|
for (const file of walk(paths.shelfRoot)) {
|
|
@@ -76,7 +130,12 @@ function emitShelfAssets(paths) {
|
|
|
76
130
|
}
|
|
77
131
|
}
|
|
78
132
|
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} dir
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
79
137
|
function walk(dir) {
|
|
138
|
+
/** @type {string[]} */
|
|
80
139
|
const files = [];
|
|
81
140
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
82
141
|
const abs = path.join(dir, entry.name);
|
package/src/cli.js
CHANGED
|
@@ -29,7 +29,9 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
29
29
|
const port = Number(process.env.PORT || 5173);
|
|
30
30
|
const distRoot = path.join(process.cwd(), "dist");
|
|
31
31
|
buildSite();
|
|
32
|
+
/** @type {Set<import("node:http").ServerResponse>} */
|
|
32
33
|
const clients = new Set();
|
|
34
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
33
35
|
let timer;
|
|
34
36
|
|
|
35
37
|
// Rebuild in a child process so changes to Darkmown's own src/ always load
|
|
@@ -94,7 +96,7 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
94
96
|
serveDev(distRoot, url, res);
|
|
95
97
|
} catch (error) {
|
|
96
98
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
97
|
-
res.end(error.stack || String(error));
|
|
99
|
+
res.end((error instanceof Error && error.stack) || String(error));
|
|
98
100
|
}
|
|
99
101
|
});
|
|
100
102
|
server.listen(port, () => {
|
|
@@ -117,6 +119,12 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
117
119
|
process.exit(1);
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} distRoot
|
|
124
|
+
* @param {string} url
|
|
125
|
+
* @param {import("node:http").ServerResponse} res
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
120
128
|
function serveDev(distRoot, url, res) {
|
|
121
129
|
const file = resolvePublicFile(distRoot, url);
|
|
122
130
|
if (!file || !fs.existsSync(file)) {
|
|
@@ -132,6 +140,11 @@ function serveDev(distRoot, url, res) {
|
|
|
132
140
|
fs.createReadStream(file).pipe(res);
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/**
|
|
144
|
+
* @param {Set<import("node:http").ServerResponse>} clients
|
|
145
|
+
* @param {string} message
|
|
146
|
+
* @returns {void}
|
|
147
|
+
*/
|
|
135
148
|
function broadcast(clients, message) {
|
|
136
149
|
for (const client of clients) client.write(message);
|
|
137
150
|
}
|