@zvndev/darkmown 0.11.1 → 0.12.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 +23 -2
- package/README.md +9 -3
- package/docs/cli.md +6 -0
- package/docs/spec-alignment.md +1 -1
- package/package.json +6 -4
- package/site/_/team.json +5 -5
- package/site/pages/404.wd +21 -0
- package/site/pages/app.wd +3 -3
- package/site/pages/data.wd +1 -1
- package/site/pages/docs/index.wd +12 -1
- package/site/pages/index.wd +4 -12
- package/src/builder.js +33 -0
- package/src/cli.js +25 -5
- package/src/compiler.js +74 -6
- package/src/statics.js +41 -7
- package/site/_/sprint-loop.wd +0 -11
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 ~
|
|
10
|
+
- Static pages ship **zero** JavaScript; a page loads the ~4.7 KB gzipped runtime (CI-enforced under 5 KB) only if it declares reactive behavior.
|
|
11
11
|
|
|
12
12
|
## Project layout & routing
|
|
13
13
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
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.12.0 — 2026-06-18
|
|
6
|
+
|
|
7
|
+
Security and deploy-hardening sprint.
|
|
8
|
+
|
|
9
|
+
- **Critical fix — `:computed` expression injection closed.** String literals inside `:computed` expressions are now escaped with `JSON.stringify` (the same path `@loop where` already used), so a crafted literal can no longer break out of the validated whitelist and execute arbitrary JavaScript at build time or in the browser. Added a regression suite that decodes and evaluates the emitted expression, plus a fuzzer invariant that runs compiled expressions inside a trapped sandbox to catch the whole class.
|
|
10
|
+
- **Honest coverage gate.** Coverage now measures `src/**` only — test files no longer inflate the denominator — and the gate explicitly discloses that the browser runtime is covered by the Playwright e2e job rather than the line gate.
|
|
11
|
+
- **Accessible form inputs by default.** `:input` and `:bind` auto-emit an `aria-label` (from the placeholder, else a humanized field name) when no `aria-label`/`aria-describedby` is supplied. The label is non-visual and never overrides an author-supplied attribute.
|
|
12
|
+
- **Static-host deploys hardened.** Builds emit `dist/404.html`; the preview server serves it on a miss and returns correct content-types for images, SVG, and fonts (unknown types default to `application/octet-stream`). `vercel.json`/`wrangler.toml` document clean-URL and 404 behavior.
|
|
13
|
+
- **Dev server survives a broken build.** A compile error during `darkmown dev` startup no longer crashes the process — the server stays up and the error appears in the overlay, clearing on the next good build.
|
|
14
|
+
- **Trust boundary documented.** README, SECURITY.md, and the docs site now state the trust model plainly: compile only authored content — `html: true` passes raw HTML through, `:fetch` has no URL allowlist, and reactive pages require `script-src 'unsafe-eval'`.
|
|
15
|
+
|
|
16
|
+
## 0.11.2 — 2026-06-17
|
|
17
|
+
|
|
18
|
+
Product trust and release-hardening sprint.
|
|
19
|
+
|
|
20
|
+
- **Public demos fixed.** `/app/` now renders feature bullets from the current data shape, and deploy/form copy clearly distinguishes static hosting from server-backed demo endpoints.
|
|
21
|
+
- **Preview servers safer by default.** `darkmown dev` and `darkmown serve` bind to `127.0.0.1` unless `HOST` is set, and malformed preview URLs no longer crash the server.
|
|
22
|
+
- **Directive hardening.** Demo `:try` links reject dangerous URL schemes; `:input` and `:bind` now carry escaped accessibility attributes.
|
|
23
|
+
- **Release gates tightened.** Tag releases verify Node 20/22/24 before publishing, keep the runtime-size budget enforced, and prepublish checks cover typecheck, coverage, build, size, audit, and consumer smoke.
|
|
24
|
+
- **Runtime guidance synced.** Packaged guidance and public copy now describe the measured ~4.7 KB gzipped runtime with the under-5 KB CI budget.
|
|
25
|
+
|
|
5
26
|
## 0.11.1 — 2026-06-17
|
|
6
27
|
|
|
7
28
|
Maintenance release. No functional changes since 0.11.0.
|
|
@@ -36,7 +57,7 @@ The "proof + full test pyramid" release: prove the moat on the homepage, test ev
|
|
|
36
57
|
- **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.
|
|
37
58
|
- **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.
|
|
38
59
|
- **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.
|
|
39
|
-
- **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still
|
|
60
|
+
- **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still stays under the shared runtime budget; the current runtime is ~4.7 KB gzipped even as the source carries full type annotations. The size budget now measures the shipped artifact.
|
|
40
61
|
- **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.
|
|
41
62
|
|
|
42
63
|
## 0.8.2 — 2026-06-14
|
|
@@ -47,7 +68,7 @@ Honesty, hardening, and polish pass — every claim in the docs is now backed by
|
|
|
47
68
|
- **Runtime:** state changes now batch into a single render per tick (`requestAnimationFrame`, falling back to a microtask), so rapid `:bind` input no longer re-runs the reconciler on every keystroke; the final state always renders and the public `wd.render()` stays synchronous. Opt-in `window.wd.debug` surfaces warnings for failed `:computed` / `where` expressions that were previously silent.
|
|
48
69
|
- **Tests:** first runtime/DOM test suite — keyed loop reconcile (node reuse on add/remove/reorder), `getPath` prototype-pollution rejection, loop-key collisions, and render batching — loading the real runtime via `node:vm`, no new dependencies. Suite grew 83 → 98.
|
|
49
70
|
- **Compiler:** unterminated frontmatter now throws an actionable error with the file path instead of silently swallowing the page; empty `---`/`---` blocks parse cleanly.
|
|
50
|
-
- **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size corrected
|
|
71
|
+
- **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size copy corrected across all surfaces; the current runtime is ~4.7 KB gzipped and CI-enforced under 5 KB; `:note`/`:try`/`:sprint` documented as demo-only; `docs/cli.md` scaffold output synced; VS Code install instructions corrected (build from source; Marketplace listing pending).
|
|
51
72
|
- **Site & tooling:** landing-page whitespace fixed (removed the forced `60vh` hero band); mobile nav no longer overflows; tagline locked to "Markdown that runs."; regenerated `package-lock.json` identity (`@zvndev/darkmown`).
|
|
52
73
|
|
|
53
74
|
## 0.8.1 — 2026-06-14
|
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 ~
|
|
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 ~4.7 KB gzipped, CI-enforced under 5 KB.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -47,7 +47,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
47
47
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
48
48
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
49
49
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
50
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~
|
|
50
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~4.7 KB gzipped, CI-enforced under 5 KB).
|
|
51
51
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
52
52
|
|
|
53
53
|
## Interpolation
|
|
@@ -425,7 +425,13 @@ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin
|
|
|
425
425
|
|
|
426
426
|
## Security
|
|
427
427
|
|
|
428
|
-
|
|
428
|
+
### Trust boundary
|
|
429
|
+
|
|
430
|
+
Darkmown is a **trusted-author** site generator: you compile content you wrote, the same way you trust your own source code. Three assumptions hold the model together — know them before you point Darkmown at content from anyone else.
|
|
431
|
+
|
|
432
|
+
- **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, form input — without sanitizing it first.
|
|
433
|
+
- **Raw HTML passes through by default.** Darkmown runs `markdown-it` with `html: true`, so raw HTML in your content is emitted verbatim — by design, like every Markdown site generator. There is no built-in sanitizer; a raw `<script>` or event-handler attribute in untrusted content would execute in the visitor's browser. A page can opt into strict mode with `html: false` frontmatter, which drops raw HTML entirely.
|
|
434
|
+
- **`:fetch` has no URL allowlist, and reactive pages need `unsafe-eval`.** A `:fetch` URL is taken straight from the page source, so the request goes wherever the author wrote. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
|
|
429
435
|
|
|
430
436
|
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
|
|
431
437
|
|
package/docs/cli.md
CHANGED
|
@@ -73,3 +73,9 @@ npm run smoke
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
The smoke script packs the local tarball, installs the packed CLI in a temporary driver project, scaffolds a consumer app through that installed bin, installs the same tarball into the app, builds it, verifies the reactive home route, and verifies the plain `.md` about route stays zero-JS.
|
|
76
|
+
|
|
77
|
+
## Deployment notes
|
|
78
|
+
|
|
79
|
+
### Cloudflare Pages
|
|
80
|
+
|
|
81
|
+
`wrangler.toml` builds and deploys the static `dist/` output for Cloudflare Pages. That configuration does **not** provide the demo `/__wd/echo` endpoint used by server-backed form examples; Vercel handles that route separately through `vercel.json` and `api/echo.js`. Add a Cloudflare Pages Function before advertising that demo endpoint on Cloudflare.
|
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` (~
|
|
9
|
+
- **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~4.7 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.12.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
"types": "./types/index.d.ts",
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "node src/cli.js build",
|
|
16
|
+
"runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 5120)'); if (size >= 5120) process.exit(1)\"",
|
|
16
17
|
"dev": "node src/cli.js dev",
|
|
17
18
|
"test": "node --test tests/*.test.js",
|
|
18
|
-
"test:cov": "node
|
|
19
|
+
"test:cov": "node scripts/test-coverage-gate.mjs 90",
|
|
19
20
|
"test:e2e": "playwright test",
|
|
20
21
|
"test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
|
|
21
22
|
"pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
|
|
22
23
|
"typecheck": "tsc --noEmit",
|
|
23
24
|
"types": "tsc -p tsconfig.types.json",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
25
|
+
"smoke": "node scripts/smoke-consumer.mjs",
|
|
26
|
+
"prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
|
|
27
|
+
"prepublishOnly": "npm run prepublish:check"
|
|
26
28
|
},
|
|
27
29
|
"files": [
|
|
28
30
|
"src",
|
package/site/_/team.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[
|
|
2
|
-
{ "id": 1, "name": "
|
|
3
|
-
{ "id": 2, "name": "
|
|
4
|
-
{ "id": 3, "name": "
|
|
5
|
-
{ "id": 4, "name": "
|
|
6
|
-
{ "id": 5, "name": "
|
|
2
|
+
{ "id": 1, "name": "Ada", "role": "Product", "lead": true },
|
|
3
|
+
{ "id": 2, "name": "Lin", "role": "Engineering", "lead": false },
|
|
4
|
+
{ "id": 3, "name": "Mateo", "role": "Design", "lead": false },
|
|
5
|
+
{ "id": 4, "name": "Priya", "role": "Research", "lead": false },
|
|
6
|
+
{ "id": 5, "name": "Noah", "role": "Support", "lead": false }
|
|
7
7
|
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Page not found — Darkmown
|
|
3
|
+
description: That route does not exist. Head back to the Darkmown homepage.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
@include /nav.wd
|
|
7
|
+
|
|
8
|
+
<main>
|
|
9
|
+
|
|
10
|
+
<section class="hero">
|
|
11
|
+
<p class="eyebrow">404</p>
|
|
12
|
+
|
|
13
|
+
# This page ran off the page.
|
|
14
|
+
|
|
15
|
+
The route you asked for is hidden, was never created, or has moved. Nothing was lost — Markdown that runs is still one click away.
|
|
16
|
+
|
|
17
|
+
<a class="btn primary" href="/">Back to home</a>
|
|
18
|
+
<a class="btn" href="/docs/">Read the docs</a>
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
</main>
|
package/site/pages/app.wd
CHANGED
|
@@ -16,10 +16,10 @@ Everything on this page — the prose, the fetched list, the badges, the form, t
|
|
|
16
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
17
|
|
|
18
18
|
@loop /features.json into feature
|
|
19
|
-
- **{ feature.
|
|
19
|
+
- **{ feature.title }** — { feature.body }
|
|
20
20
|
@endloop
|
|
21
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 ~
|
|
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 ~4.7 KB gzipped runtime loads (CI-enforced under 5 KB) — and only because the document asks for it.</aside>
|
|
23
23
|
|
|
24
24
|
## Fetched data, looped, with nested conditions
|
|
25
25
|
|
|
@@ -68,7 +68,7 @@ Nice — you are tracking the whole near-term roadmap.
|
|
|
68
68
|
|
|
69
69
|
## A form that talks to a real server
|
|
70
70
|
|
|
71
|
-
This form posts to
|
|
71
|
+
This form posts to the demo echo endpoint and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST. On Vercel/dev the sample endpoint replies; on static-only hosts, point `action=` at your own backend — Darkmown adapts instead of owning one:
|
|
72
72
|
|
|
73
73
|
:form action="/__wd/echo" into reply
|
|
74
74
|
:input note placeholder="Tell us what you'd build with this"
|
package/site/pages/data.wd
CHANGED
|
@@ -68,7 +68,7 @@ The reply will appear here.
|
|
|
68
68
|
The request failed: { reply_error }
|
|
69
69
|
:endif
|
|
70
70
|
|
|
71
|
-
In `darkmown dev` the echo endpoint is built into the dev server; on darkmown.com it is a real serverless function behind the same URL. Either way, Darkmown itself stays static — sessions ride on `:fetch` plus ordinary cookies against your real API.
|
|
71
|
+
In `darkmown dev` the echo endpoint is built into the dev server; on darkmown.com it is a real serverless function behind the same URL. On static-only hosts such as a plain Cloudflare Pages deploy, replace `action="/__wd/echo"` with your own API endpoint. Either way, Darkmown itself stays static — sessions ride on `:fetch` plus ordinary cookies against your real API.
|
|
72
72
|
|
|
73
73
|
## Persistent state survives reload
|
|
74
74
|
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Darkmown docs
|
|
3
|
+
description: Learn Darkmown routing, directives, state, loops, forms, and the static-to-reactive model.
|
|
3
4
|
---
|
|
4
5
|
|
|
5
6
|
@include /nav.wd
|
|
@@ -169,7 +170,7 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
169
170
|
|
|
170
171
|
## Reactive directives
|
|
171
172
|
|
|
172
|
-
Reactive pages opt into `/__wd/runtime.js` (~
|
|
173
|
+
Reactive pages opt into `/__wd/runtime.js` (~4.7 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
|
|
173
174
|
|
|
174
175
|
```wd
|
|
175
176
|
:state count = 0
|
|
@@ -264,6 +265,16 @@ You have { cart } items.
|
|
|
264
265
|
|
|
265
266
|
See it all live on the [Data & Forms page](/data/).
|
|
266
267
|
|
|
268
|
+
## Trust boundary
|
|
269
|
+
|
|
270
|
+
Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.
|
|
271
|
+
|
|
272
|
+
- **Compile only content you authored.** Don't run `.md`/`.wd` you didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first.
|
|
273
|
+
- **Raw HTML passes through by default.** Markdown renders with `html: true`, so raw HTML is emitted verbatim and there is no built-in sanitizer. Set `html: false` in a page's frontmatter to drop raw HTML for untrusted content.
|
|
274
|
+
- **`:fetch` has no URL allowlist, and reactive pages need `unsafe-eval`.** Fetch URLs come straight from the page source, and the runtime compiles `:computed` / `@loop … where` expressions via `new Function` — so a reactive page needs `script-src 'unsafe-eval'` in your CSP. Static pages need neither.
|
|
275
|
+
|
|
276
|
+
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in the package for the full security model.
|
|
277
|
+
|
|
267
278
|
## Colocation
|
|
268
279
|
|
|
269
280
|
- A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
|
package/site/pages/index.wd
CHANGED
|
@@ -96,14 +96,14 @@ Markdoc.renderers.react(content, React, {
|
|
|
96
96
|
|
|
97
97
|
</section>
|
|
98
98
|
|
|
99
|
-
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~
|
|
99
|
+
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~4.7 KB gzipped runtime loads (CI-enforced under 5 KB), never before. JS optional, CSS optional, always."
|
|
100
100
|
|
|
101
101
|
<section class="props">
|
|
102
102
|
|
|
103
103
|
## Three rules, no escape hatch
|
|
104
104
|
|
|
105
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 ~
|
|
106
|
+
- **One runtime.** Every reactive page shares the same ~4.7 KB gzipped runtime, CI-enforced under 5 KB. No per-component bundles, no hydration tax that scales with your UI.
|
|
107
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
108
|
|
|
109
109
|
</section>
|
|
@@ -151,18 +151,10 @@ This button is powered by `site/pages/index.js`, discovered by matching the page
|
|
|
151
151
|
|
|
152
152
|
## Deploy in one click
|
|
153
153
|
|
|
154
|
-
`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
|
|
154
|
+
`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. The demo form posts to Darkmown's dev/Vercel echo endpoint; on static-only hosts, point `action=` at your own backend or remove that demo.
|
|
155
155
|
|
|
156
156
|
<a class="btn primary" href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Vercel</a>
|
|
157
|
-
<a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Cloudflare</a>
|
|
157
|
+
<a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy static to Cloudflare</a>
|
|
158
158
|
<a class="btn" href="https://github.com/ZVN-DEV/darkmown">Star on GitHub</a>
|
|
159
159
|
|
|
160
|
-
## Built in the open
|
|
161
|
-
|
|
162
|
-
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:
|
|
163
|
-
|
|
164
|
-
:sprint min=4 max=6 roles="PM, Development, Research, Testing, Review"
|
|
165
|
-
|
|
166
|
-
@include /sprint-loop.wd
|
|
167
|
-
|
|
168
160
|
</main>
|
package/src/builder.js
CHANGED
|
@@ -38,6 +38,9 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
38
38
|
/** @type {Set<string>} */
|
|
39
39
|
const warned = new Set();
|
|
40
40
|
|
|
41
|
+
/** @type {string | undefined} HTML of the `/404/` route, copied to dist/404.html. */
|
|
42
|
+
let notFoundHtml;
|
|
43
|
+
|
|
41
44
|
for (const route of routes) {
|
|
42
45
|
const page = compilePage(route.file, paths);
|
|
43
46
|
for (const warning of page.warnings || []) {
|
|
@@ -48,6 +51,7 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
48
51
|
const outFile = outputPathForRoute(paths.distRoot, route.route);
|
|
49
52
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
50
53
|
fs.writeFileSync(outFile, page.html);
|
|
54
|
+
if (route.route === "/404/") notFoundHtml = page.html;
|
|
51
55
|
emitAssets(page.assets, paths);
|
|
52
56
|
if (page.assets.runtime) emitRuntime(paths);
|
|
53
57
|
manifest.push({
|
|
@@ -61,6 +65,11 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
61
65
|
});
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
// Hosts (Vercel, Cloudflare Pages) and the framework's own server auto-serve
|
|
69
|
+
// dist/404.html for misses. Prefer the compiled 404 route; fall back to a
|
|
70
|
+
// minimal page so a 404 is always available.
|
|
71
|
+
fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
|
|
72
|
+
|
|
64
73
|
fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
|
|
65
74
|
return { routes: manifest, distRoot: paths.distRoot };
|
|
66
75
|
}
|
|
@@ -89,6 +98,30 @@ export function stripRuntimeComments(source) {
|
|
|
89
98
|
.replace(/^[ \t]*\n/gm, "");
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
/**
|
|
102
|
+
* A minimal, zero-JS fallback 404 page used only when no `404.wd` route exists.
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
function defaultNotFoundHtml() {
|
|
106
|
+
return [
|
|
107
|
+
"<!doctype html>",
|
|
108
|
+
'<html lang="en">',
|
|
109
|
+
"<head>",
|
|
110
|
+
'<meta charset="utf-8">',
|
|
111
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
112
|
+
"<title>Page not found</title>",
|
|
113
|
+
"</head>",
|
|
114
|
+
"<body>",
|
|
115
|
+
"<main>",
|
|
116
|
+
"<h1>Page not found</h1>",
|
|
117
|
+
"<p>That route does not exist. <a href=\"/\">Back to home</a>.</p>",
|
|
118
|
+
"</main>",
|
|
119
|
+
"</body>",
|
|
120
|
+
"</html>",
|
|
121
|
+
""
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
92
125
|
/**
|
|
93
126
|
* @param {Paths} paths
|
|
94
127
|
* @returns {void}
|
package/src/cli.js
CHANGED
|
@@ -28,12 +28,24 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
28
28
|
console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
|
|
29
29
|
} else if (command === "dev") {
|
|
30
30
|
const port = Number(process.env.PORT || 5173);
|
|
31
|
+
const host = process.env.HOST || "127.0.0.1";
|
|
31
32
|
const distRoot = path.join(process.cwd(), "dist");
|
|
32
|
-
buildSite();
|
|
33
33
|
/** @type {Set<import("node:http").ServerResponse>} */
|
|
34
34
|
const clients = new Set();
|
|
35
35
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
36
36
|
let timer;
|
|
37
|
+
/** @type {string | undefined} Last build failure, replayed to clients on connect. */
|
|
38
|
+
let lastBuildError;
|
|
39
|
+
|
|
40
|
+
// A broken initial build must NOT crash the process before the HTTP server —
|
|
41
|
+
// and thus the error overlay — comes up. Record the error so new SSE clients
|
|
42
|
+
// see it on connect; the next successful rebuild clears it via `reload`.
|
|
43
|
+
try {
|
|
44
|
+
buildSite();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
lastBuildError = (error instanceof Error ? error.message : String(error)).trim();
|
|
47
|
+
console.error(lastBuildError);
|
|
48
|
+
}
|
|
37
49
|
|
|
38
50
|
// Rebuild in a child process so changes to Darkmown's own src/ always load
|
|
39
51
|
// fresh modules — an in-process rebuild would reuse the stale import cache.
|
|
@@ -44,10 +56,12 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
44
56
|
execFile(process.execPath, [cliPath, "build"], { cwd: process.cwd() }, (error, stdout, stderr) => {
|
|
45
57
|
if (error) {
|
|
46
58
|
const message = (stderr || stdout || String(error)).trim();
|
|
59
|
+
lastBuildError = message;
|
|
47
60
|
console.error(message);
|
|
48
61
|
broadcast(clients, `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`);
|
|
49
62
|
return;
|
|
50
63
|
}
|
|
64
|
+
lastBuildError = undefined;
|
|
51
65
|
const elapsed = Math.round(performance.now() - started);
|
|
52
66
|
if (stderr.trim()) console.warn(stderr.trim());
|
|
53
67
|
broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
|
|
@@ -73,6 +87,11 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
73
87
|
});
|
|
74
88
|
res.write("\n");
|
|
75
89
|
clients.add(res);
|
|
90
|
+
// Replay a broken build to clients that connect while it's still failing,
|
|
91
|
+
// so the overlay shows even after a startup or pre-connect compile error.
|
|
92
|
+
if (lastBuildError) {
|
|
93
|
+
res.write(`event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`);
|
|
94
|
+
}
|
|
76
95
|
req.on("close", () => clients.delete(res));
|
|
77
96
|
return;
|
|
78
97
|
}
|
|
@@ -100,19 +119,20 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
100
119
|
res.end((error instanceof Error && error.stack) || String(error));
|
|
101
120
|
}
|
|
102
121
|
});
|
|
103
|
-
server.listen(port, () => {
|
|
104
|
-
console.log(`Darkmown dev server ready at http
|
|
122
|
+
server.listen(port, host, () => {
|
|
123
|
+
console.log(`Darkmown dev server ready at http://${host}:${port}`);
|
|
105
124
|
console.log(`Live compiler watching site/ and src/`);
|
|
106
125
|
});
|
|
107
126
|
} else if (command === "serve") {
|
|
108
127
|
const port = Number(process.env.PORT || 4173);
|
|
128
|
+
const host = process.env.HOST || "127.0.0.1";
|
|
109
129
|
const distRoot = path.join(process.cwd(), "dist");
|
|
110
130
|
if (!fs.existsSync(distRoot)) {
|
|
111
131
|
console.error("No dist directory found. Run `darkmown build` first.");
|
|
112
132
|
process.exit(1);
|
|
113
133
|
}
|
|
114
|
-
http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, () => {
|
|
115
|
-
console.log(`Darkmown preview of dist at http
|
|
134
|
+
http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, host, () => {
|
|
135
|
+
console.log(`Darkmown preview of dist at http://${host}:${port}`);
|
|
116
136
|
});
|
|
117
137
|
} else {
|
|
118
138
|
console.error(`Unknown command: ${command}`);
|
package/src/compiler.js
CHANGED
|
@@ -400,7 +400,7 @@ function compileBody(lines, ctx) {
|
|
|
400
400
|
i = block.end;
|
|
401
401
|
continue;
|
|
402
402
|
}
|
|
403
|
-
const demo = renderDemoDirective(line);
|
|
403
|
+
const demo = renderDemoDirective(line, ctx);
|
|
404
404
|
if (demo) {
|
|
405
405
|
flush();
|
|
406
406
|
out.push(demo);
|
|
@@ -838,7 +838,10 @@ function compileComputedExpr(raw, ctx) {
|
|
|
838
838
|
/** @type {string[]} */
|
|
839
839
|
const strings = [];
|
|
840
840
|
let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
|
|
841
|
-
|
|
841
|
+
// JSON.stringify fully escapes embedded ", \, etc. so the re-inserted literal
|
|
842
|
+
// (line below) is an inert JS string and cannot terminate early to smuggle in
|
|
843
|
+
// live code. Mirrors the SAFE @loop where path (compileOperand).
|
|
844
|
+
strings.push(JSON.stringify(literal.slice(1, -1)));
|
|
842
845
|
return `__WDSTR${strings.length - 1}__`;
|
|
843
846
|
});
|
|
844
847
|
if (/["'\\`]/.test(expr)) {
|
|
@@ -910,6 +913,8 @@ function handleInput(line, ctx) {
|
|
|
910
913
|
if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
|
|
911
914
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
912
915
|
let type = "text";
|
|
916
|
+
let placeholder;
|
|
917
|
+
let hasAria = false;
|
|
913
918
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
914
919
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
915
920
|
if (token[3]) {
|
|
@@ -924,11 +929,19 @@ function handleInput(line, ctx) {
|
|
|
924
929
|
type = value;
|
|
925
930
|
continue;
|
|
926
931
|
}
|
|
927
|
-
if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete"].includes(token[1])) {
|
|
932
|
+
if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
928
933
|
throw new Error(`Unknown :input attribute "${token[1]}" in ${ctx.file}`);
|
|
929
934
|
}
|
|
935
|
+
if (token[1] === "placeholder") placeholder = value;
|
|
936
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
930
937
|
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
931
938
|
}
|
|
939
|
+
// Accessible name: if the author supplied neither aria-label nor aria-describedby,
|
|
940
|
+
// derive a non-visual aria-label from the placeholder, else from the field name.
|
|
941
|
+
// Never overrides an author-supplied aria attribute (zero layout impact).
|
|
942
|
+
if (!hasAria) {
|
|
943
|
+
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
944
|
+
}
|
|
932
945
|
return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
|
|
933
946
|
}
|
|
934
947
|
|
|
@@ -949,6 +962,8 @@ function handleBind(line, ctx) {
|
|
|
949
962
|
}
|
|
950
963
|
ctx.comp.assets.runtime = true;
|
|
951
964
|
let type = "text";
|
|
965
|
+
let placeholder;
|
|
966
|
+
let hasAria = false;
|
|
952
967
|
const attrs = [];
|
|
953
968
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
954
969
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
@@ -959,11 +974,19 @@ function handleBind(line, ctx) {
|
|
|
959
974
|
}
|
|
960
975
|
const value = stripQuotes(token[2]);
|
|
961
976
|
if (token[1] === "type") { type = value; continue; }
|
|
962
|
-
if (!["placeholder", "autocomplete"].includes(token[1])) {
|
|
977
|
+
if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
963
978
|
throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
|
|
964
979
|
}
|
|
980
|
+
if (token[1] === "placeholder") placeholder = value;
|
|
981
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
965
982
|
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
966
983
|
}
|
|
984
|
+
// Accessible name (see handleInput): default a non-visual aria-label from the
|
|
985
|
+
// placeholder, else a humanized version of the bound state key. The author's own
|
|
986
|
+
// aria-label/aria-describedby always wins.
|
|
987
|
+
if (!hasAria) {
|
|
988
|
+
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
989
|
+
}
|
|
967
990
|
const initial = ctx.comp.state.get(key);
|
|
968
991
|
const valueAttr = initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
969
992
|
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
@@ -1571,11 +1594,15 @@ export function loopKeyOf(item, counts) {
|
|
|
1571
1594
|
/**
|
|
1572
1595
|
* Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
|
|
1573
1596
|
* @param {string} line
|
|
1597
|
+
* @param {Ctx} ctx
|
|
1574
1598
|
* @returns {string}
|
|
1575
1599
|
*/
|
|
1576
|
-
function renderDemoDirective(line) {
|
|
1600
|
+
function renderDemoDirective(line, ctx) {
|
|
1577
1601
|
const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
|
|
1578
|
-
if (tryMatch)
|
|
1602
|
+
if (tryMatch) {
|
|
1603
|
+
const href = validateDemoHref(tryMatch[2], ctx);
|
|
1604
|
+
return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
1605
|
+
}
|
|
1579
1606
|
const note = line.match(/^:note\s+"([^"]+)"$/);
|
|
1580
1607
|
if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
|
|
1581
1608
|
const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
|
|
@@ -1586,6 +1613,30 @@ function renderDemoDirective(line) {
|
|
|
1586
1613
|
return "";
|
|
1587
1614
|
}
|
|
1588
1615
|
|
|
1616
|
+
/**
|
|
1617
|
+
* Validate demo-card links without adding arbitrary URL passthrough.
|
|
1618
|
+
* @param {string} href
|
|
1619
|
+
* @param {Ctx} ctx
|
|
1620
|
+
* @returns {string}
|
|
1621
|
+
*/
|
|
1622
|
+
function validateDemoHref(href, ctx) {
|
|
1623
|
+
const value = href.trim();
|
|
1624
|
+
if (value !== href || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
1625
|
+
throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
|
|
1626
|
+
}
|
|
1627
|
+
if (value.startsWith("//")) {
|
|
1628
|
+
throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http: or https: explicitly.`);
|
|
1629
|
+
}
|
|
1630
|
+
if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("#")) {
|
|
1631
|
+
return value;
|
|
1632
|
+
}
|
|
1633
|
+
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
1634
|
+
if (scheme && ["http", "https", "mailto"].includes(scheme[1].toLowerCase())) {
|
|
1635
|
+
return value;
|
|
1636
|
+
}
|
|
1637
|
+
throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1589
1640
|
// ---------------------------------------------------------------------------
|
|
1590
1641
|
// Interpolation: one syntax, { name } / { name.path }
|
|
1591
1642
|
// ---------------------------------------------------------------------------
|
|
@@ -2061,6 +2112,23 @@ function stripQuotes(value) {
|
|
|
2061
2112
|
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
2062
2113
|
}
|
|
2063
2114
|
|
|
2115
|
+
/**
|
|
2116
|
+
* Turn a field name / state key into a human-readable accessible name, e.g.
|
|
2117
|
+
* `quest` → "Quest", `user-name` → "User name", `firstName` → "First name".
|
|
2118
|
+
* Hyphens, underscores, and camelCase boundaries become spaces; the first letter
|
|
2119
|
+
* is capitalized. Used to auto-derive aria-label on generated form inputs.
|
|
2120
|
+
* @param {string} name
|
|
2121
|
+
* @returns {string}
|
|
2122
|
+
*/
|
|
2123
|
+
function humanizeName(name) {
|
|
2124
|
+
const words = name
|
|
2125
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // split camelCase
|
|
2126
|
+
.replace(/[-_]+/g, " ") // hyphens/underscores → spaces
|
|
2127
|
+
.trim()
|
|
2128
|
+
.toLowerCase();
|
|
2129
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2064
2132
|
/**
|
|
2065
2133
|
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
2066
2134
|
* @param {unknown} value
|
package/src/statics.js
CHANGED
|
@@ -12,7 +12,12 @@ import path from "node:path";
|
|
|
12
12
|
export function serve(distRoot, url, res) {
|
|
13
13
|
const file = resolvePublicFile(distRoot, url || "/");
|
|
14
14
|
if (!file || !fs.existsSync(file)) {
|
|
15
|
-
res.writeHead(404, { "content-type": "text/html" });
|
|
15
|
+
res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
|
|
16
|
+
const notFound = path.join(distRoot, "404.html");
|
|
17
|
+
if (fs.existsSync(notFound)) {
|
|
18
|
+
res.end(fs.readFileSync(notFound));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
16
21
|
res.end("<h1>Not found</h1><p>This route is hidden or has not been created.</p>");
|
|
17
22
|
return;
|
|
18
23
|
}
|
|
@@ -28,7 +33,12 @@ export function serve(distRoot, url, res) {
|
|
|
28
33
|
* @returns {string | null}
|
|
29
34
|
*/
|
|
30
35
|
export function resolvePublicFile(distRoot, url) {
|
|
31
|
-
|
|
36
|
+
let cleanUrl;
|
|
37
|
+
try {
|
|
38
|
+
cleanUrl = decodeURIComponent(url.split("?")[0]);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
32
42
|
const base = cleanUrl.startsWith("/__wd/")
|
|
33
43
|
? cleanUrl
|
|
34
44
|
: path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
|
|
@@ -39,13 +49,37 @@ export function resolvePublicFile(distRoot, url) {
|
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/**
|
|
42
|
-
* Map a file
|
|
52
|
+
* Map a file extension to a response content-type. Unknown types fall back to
|
|
53
|
+
* `application/octet-stream` so the browser never mis-sniffs an asset as HTML.
|
|
54
|
+
* @type {Record<string, string>}
|
|
55
|
+
*/
|
|
56
|
+
const contentTypes = {
|
|
57
|
+
".html": "text/html; charset=utf-8",
|
|
58
|
+
".css": "text/css; charset=utf-8",
|
|
59
|
+
".js": "text/javascript; charset=utf-8",
|
|
60
|
+
".json": "application/json",
|
|
61
|
+
".map": "application/json",
|
|
62
|
+
".svg": "image/svg+xml",
|
|
63
|
+
".png": "image/png",
|
|
64
|
+
".jpg": "image/jpeg",
|
|
65
|
+
".jpeg": "image/jpeg",
|
|
66
|
+
".gif": "image/gif",
|
|
67
|
+
".webp": "image/webp",
|
|
68
|
+
".ico": "image/x-icon",
|
|
69
|
+
".woff": "font/woff",
|
|
70
|
+
".woff2": "font/woff2",
|
|
71
|
+
".ttf": "font/ttf",
|
|
72
|
+
".otf": "font/otf",
|
|
73
|
+
".txt": "text/plain; charset=utf-8",
|
|
74
|
+
".xml": "application/xml"
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Map a file path to a response content-type by extension, defaulting to
|
|
79
|
+
* `application/octet-stream` for unknown types.
|
|
43
80
|
* @param {string} file File path or name.
|
|
44
81
|
* @returns {string}
|
|
45
82
|
*/
|
|
46
83
|
export function contentType(file) {
|
|
47
|
-
|
|
48
|
-
if (file.endsWith(".js")) return "text/javascript";
|
|
49
|
-
if (file.endsWith(".json")) return "application/json";
|
|
50
|
-
return "text/html";
|
|
84
|
+
return contentTypes[path.extname(file).toLowerCase()] || "application/octet-stream";
|
|
51
85
|
}
|
package/site/_/sprint-loop.wd
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
## Long-running session sprinting
|
|
2
|
-
|
|
3
|
-
Darkmown treats sprinting as a visible operating loop, not as hidden recursion inside Markdown.
|
|
4
|
-
|
|
5
|
-
- PM keeps the acceptance checklist and decides whether the demo proves the promise.
|
|
6
|
-
- Development owns compiler, router, runtime, and demo integration.
|
|
7
|
-
- Research checks the theory against prior art and keeps naming or platform drift from leaking in.
|
|
8
|
-
- Testing owns route, include, asset, and browser smoke evidence.
|
|
9
|
-
- Review inspects the final diff and blocks sloppy completion.
|
|
10
|
-
|
|
11
|
-
The standing team size is 4 to 6. If work expands, add a design or architecture lane; if work shrinks, keep PM, development, testing, and review active.
|