@zvndev/darkmown 0.11.2 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +23 -1
- package/SECURITY.md +39 -0
- package/package.json +3 -2
- package/site/_/og.png +0 -0
- package/site/_/team.json +5 -5
- package/site/pages/404.wd +21 -0
- package/site/pages/docs/index.wd +10 -0
- package/site/pages/index.wd +1 -0
- package/src/builder.js +33 -0
- package/src/cli.js +50 -9
- package/src/compiler.js +59 -8
- package/src/statics.js +35 -6
- package/site/_/sprint-loop.wd +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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.1 — 2026-06-18
|
|
6
|
+
|
|
7
|
+
Release-hygiene and DX polish (post-0.12.0 smoke audit).
|
|
8
|
+
|
|
9
|
+
- **Clean compile errors.** `darkmown build` prints a single `✗ <message>` line on a compile error instead of a raw Node.js stack trace (the dev rebuild log inherits the same clean output). The message still carries the file path and corrective hint.
|
|
10
|
+
- **Bare `darkmown` prints help.** Running the command with no arguments shows usage instead of silently running a build.
|
|
11
|
+
- **Social-share images.** A new `image:` frontmatter key sets `og:image` / `twitter:image` and upgrades the Twitter card to `summary_large_image`. The home page now ships an Open Graph card.
|
|
12
|
+
- **`SECURITY.md` ships in the package** so the README's in-package security link resolves for npm consumers.
|
|
13
|
+
- **Static assets documented.** README explains that non-page shelf files (`site/_/`) are copied to `/__wd/media/` (and `.json` to `/__wd/data/`).
|
|
14
|
+
- **README badges** added (npm version, CI, provenance, license).
|
|
15
|
+
|
|
16
|
+
## 0.12.0 — 2026-06-18
|
|
17
|
+
|
|
18
|
+
Security and deploy-hardening sprint.
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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'`.
|
|
26
|
+
|
|
5
27
|
## 0.11.2 — 2026-06-17
|
|
6
28
|
|
|
7
29
|
Product trust and release-hardening sprint.
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Darkmown
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@zvndev/darkmown)
|
|
4
|
+
[](https://github.com/ZVN-DEV/darkmown/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/@zvndev/darkmown)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
3
8
|
**[darkmown.com](https://darkmown.com)** · Markdown that runs.
|
|
4
9
|
|
|
5
10
|
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~4.7 KB gzipped, CI-enforced under 5 KB.
|
|
@@ -74,6 +79,17 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
74
79
|
- `@loop meta.tags into tag` iterates an array field at build time (stays static, zero-JS).
|
|
75
80
|
- Arrays are inline flow only (`[a, b]`); quoted items keep internal commas (`"q1, q2"`). A value without a leading `[` stays a plain string.
|
|
76
81
|
|
|
82
|
+
Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card).
|
|
83
|
+
|
|
84
|
+
## Static assets
|
|
85
|
+
|
|
86
|
+
Images, fonts, icons, and other non-page files live on the include shelf at `site/_/` and are copied into the build untouched:
|
|
87
|
+
|
|
88
|
+
- Any non-`.md`/`.wd` file in `site/_/` → served at `/__wd/media/<path>` (e.g. `site/_/logo.svg` → `/__wd/media/logo.svg`).
|
|
89
|
+
- `.json` files → served at `/__wd/data/<name>` (this is what `:fetch` reads).
|
|
90
|
+
|
|
91
|
+
Reference them with a normal URL: ``. The preview/`serve` layer returns the correct `Content-Type` per extension. (Files dropped in `site/pages/` are treated as routes, not assets — put shareable assets on the shelf.)
|
|
92
|
+
|
|
77
93
|
## Loops
|
|
78
94
|
|
|
79
95
|
`@loop <things> into <thing>` is the only loop. The source decides the behavior:
|
|
@@ -425,7 +441,13 @@ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin
|
|
|
425
441
|
|
|
426
442
|
## Security
|
|
427
443
|
|
|
428
|
-
|
|
444
|
+
### Trust boundary
|
|
445
|
+
|
|
446
|
+
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.
|
|
447
|
+
|
|
448
|
+
- **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.
|
|
449
|
+
- **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.
|
|
450
|
+
- **`: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
451
|
|
|
430
452
|
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
|
|
431
453
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Darkmown is pre-1.0. Only the latest published minor version receives security fixes.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
Please report vulnerabilities privately via [GitHub Security Advisories](https://github.com/ZVN-DEV/darkmown/security/advisories/new). Do not open public issues for security reports.
|
|
10
|
+
|
|
11
|
+
You can expect an initial response within 72 hours. If the report is accepted, a fix ships as the next patch release and the advisory is published after the fix is available.
|
|
12
|
+
|
|
13
|
+
## Trust boundary / threat model
|
|
14
|
+
|
|
15
|
+
Darkmown is a **trusted-author** static site generator. The threat model assumes you compile content you wrote yourself, the same way you trust your own source code — it does not assume the content is hostile. Within that boundary the guarantees below hold; three assumptions define the boundary itself:
|
|
16
|
+
|
|
17
|
+
1. **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, or form input — without applying your own sanitization first.
|
|
18
|
+
2. **Raw HTML passes through by default.** `markdown-it` runs with `html: true`, so raw HTML in content is emitted verbatim. There is no built-in sanitizer, so 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 strips raw HTML.
|
|
19
|
+
3. **`:fetch` has no URL allowlist, and reactive pages require `unsafe-eval`.** A `:fetch` URL is read directly from the page source, so requests go wherever the author wrote them. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
|
|
20
|
+
|
|
21
|
+
This is the same trust posture as every Markdown SSG: the framework is safe for the trusted-author use case it targets, and unsafe content is the author's responsibility to sanitize before it reaches the compiler.
|
|
22
|
+
|
|
23
|
+
## Security model
|
|
24
|
+
|
|
25
|
+
Things Darkmown deliberately guards at compile time and runtime:
|
|
26
|
+
|
|
27
|
+
- **No arbitrary JavaScript in content files.** Directive actions (`:button -> …`), `:computed` expressions, and `@loop … where` predicates compile through strict whitelisted grammars — only item paths, declared `:state`, numbers, and strings are allowed. Assignment, function calls, and unknown syntax are compile errors. Raw user content is never `eval`'d; what runs at runtime is the *validated* expression, compiled to the whitelisted grammar and executed via `new Function` (see `src/runtime.js`).
|
|
28
|
+
- **Prototype-walk protection.** Path lookups (`{ a.b.c }`, computed expressions) reject `constructor`, `prototype`, and `__proto__` segments in both the compiler and the runtime.
|
|
29
|
+
- **Include sandboxing.** `@include` and `@loop` data sources must resolve inside `site/pages` or `site/_`; traversal outside the site tree is a compile error, and include cycles are detected.
|
|
30
|
+
- **Output escaping.** Interpolated values are HTML-escaped; state scripts escape `<` to prevent script-tag breakout.
|
|
31
|
+
- **Static server path containment.** The dev/preview servers resolve requests strictly inside `dist`.
|
|
32
|
+
|
|
33
|
+
## The biggest footgun: raw HTML passthrough
|
|
34
|
+
|
|
35
|
+
Darkmown configures markdown-it with `html: true`, so **raw HTML in `.md`/`.wd` files is passed through verbatim** — by design, like every Markdown site generator. This is the single most important thing to understand about Darkmown's security model.
|
|
36
|
+
|
|
37
|
+
- Treat all content files as **trusted input**, the same way you trust your own source code.
|
|
38
|
+
- **Do not compile untrusted or user-submitted Markdown** (comments, form input, third-party docs) without sanitizing it first. Darkmown ships no built-in sanitizer; raw `<script>` and event-handler attributes in untrusted content would execute in the visitor's browser.
|
|
39
|
+
- If you must render untrusted content, sanitize it (for example with a library like DOMPurify) before it reaches the compiler.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
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)\"",
|
|
17
17
|
"dev": "node src/cli.js dev",
|
|
18
18
|
"test": "node --test tests/*.test.js",
|
|
19
|
-
"test:cov": "node scripts/test-coverage-gate.mjs
|
|
19
|
+
"test:cov": "node scripts/test-coverage-gate.mjs 90",
|
|
20
20
|
"test:e2e": "playwright test",
|
|
21
21
|
"test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
|
|
22
22
|
"pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"docs/spec-alignment.md",
|
|
35
35
|
"AGENTS.md",
|
|
36
36
|
"README.md",
|
|
37
|
+
"SECURITY.md",
|
|
37
38
|
"LICENSE",
|
|
38
39
|
"CHANGELOG.md"
|
|
39
40
|
],
|
package/site/_/og.png
ADDED
|
Binary file
|
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/docs/index.wd
CHANGED
|
@@ -265,6 +265,16 @@ You have { cart } items.
|
|
|
265
265
|
|
|
266
266
|
See it all live on the [Data & Forms page](/data/).
|
|
267
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
|
+
|
|
268
278
|
## Colocation
|
|
269
279
|
|
|
270
280
|
- A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
|
package/site/pages/index.wd
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Darkmown — Markdown that runs, not Markdown that needs React
|
|
3
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
|
+
image: https://darkmown.com/__wd/media/og.png
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
@include /nav.wd
|
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
|
@@ -10,7 +10,9 @@ import { initProject } from "./scaffold.js";
|
|
|
10
10
|
import { contentType, resolvePublicFile, serve } from "./statics.js";
|
|
11
11
|
|
|
12
12
|
const cliPath = fileURLToPath(import.meta.url);
|
|
13
|
-
|
|
13
|
+
// Bare `darkmown` prints help (discovery) rather than silently building — a new
|
|
14
|
+
// user typing the command alone expects to see what it can do, not a no-op build.
|
|
15
|
+
const command = process.argv[2] || "help";
|
|
14
16
|
|
|
15
17
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
16
18
|
printHelp();
|
|
@@ -18,23 +20,44 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
18
20
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
19
21
|
console.log(pkg.version);
|
|
20
22
|
} else if (command === "init") {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
try {
|
|
24
|
+
const target = process.argv[3] || ".";
|
|
25
|
+
const result = initProject(path.resolve(process.cwd(), target));
|
|
26
|
+
const relativeRoot = path.relative(process.cwd(), result.root) || ".";
|
|
27
|
+
console.log(`Created Darkmown project at ${relativeRoot}`);
|
|
28
|
+
console.log(`Next: ${nextStep(relativeRoot)}`);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
failClean(error);
|
|
31
|
+
}
|
|
26
32
|
} else if (command === "build") {
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
try {
|
|
34
|
+
const result = buildSite();
|
|
35
|
+
console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// A compile error has a clear, file-pathed message — print just that, not a
|
|
38
|
+
// Node stack trace, so a typo reads as "fix your page", not "the tool crashed".
|
|
39
|
+
failClean(error);
|
|
40
|
+
}
|
|
29
41
|
} else if (command === "dev") {
|
|
30
42
|
const port = Number(process.env.PORT || 5173);
|
|
31
43
|
const host = process.env.HOST || "127.0.0.1";
|
|
32
44
|
const distRoot = path.join(process.cwd(), "dist");
|
|
33
|
-
buildSite();
|
|
34
45
|
/** @type {Set<import("node:http").ServerResponse>} */
|
|
35
46
|
const clients = new Set();
|
|
36
47
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
37
48
|
let timer;
|
|
49
|
+
/** @type {string | undefined} Last build failure, replayed to clients on connect. */
|
|
50
|
+
let lastBuildError;
|
|
51
|
+
|
|
52
|
+
// A broken initial build must NOT crash the process before the HTTP server —
|
|
53
|
+
// and thus the error overlay — comes up. Record the error so new SSE clients
|
|
54
|
+
// see it on connect; the next successful rebuild clears it via `reload`.
|
|
55
|
+
try {
|
|
56
|
+
buildSite();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
lastBuildError = (error instanceof Error ? error.message : String(error)).trim();
|
|
59
|
+
console.error(lastBuildError);
|
|
60
|
+
}
|
|
38
61
|
|
|
39
62
|
// Rebuild in a child process so changes to Darkmown's own src/ always load
|
|
40
63
|
// fresh modules — an in-process rebuild would reuse the stale import cache.
|
|
@@ -45,10 +68,12 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
45
68
|
execFile(process.execPath, [cliPath, "build"], { cwd: process.cwd() }, (error, stdout, stderr) => {
|
|
46
69
|
if (error) {
|
|
47
70
|
const message = (stderr || stdout || String(error)).trim();
|
|
71
|
+
lastBuildError = message;
|
|
48
72
|
console.error(message);
|
|
49
73
|
broadcast(clients, `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`);
|
|
50
74
|
return;
|
|
51
75
|
}
|
|
76
|
+
lastBuildError = undefined;
|
|
52
77
|
const elapsed = Math.round(performance.now() - started);
|
|
53
78
|
if (stderr.trim()) console.warn(stderr.trim());
|
|
54
79
|
broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
|
|
@@ -74,6 +99,11 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
74
99
|
});
|
|
75
100
|
res.write("\n");
|
|
76
101
|
clients.add(res);
|
|
102
|
+
// Replay a broken build to clients that connect while it's still failing,
|
|
103
|
+
// so the overlay shows even after a startup or pre-connect compile error.
|
|
104
|
+
if (lastBuildError) {
|
|
105
|
+
res.write(`event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`);
|
|
106
|
+
}
|
|
77
107
|
req.on("close", () => clients.delete(res));
|
|
78
108
|
return;
|
|
79
109
|
}
|
|
@@ -152,6 +182,17 @@ function broadcast(clients, message) {
|
|
|
152
182
|
for (const client of clients) client.write(message);
|
|
153
183
|
}
|
|
154
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Print a compile/CLI error as a single clean line and exit non-zero — no Node
|
|
187
|
+
* stack trace. The thrown Error already carries the file path + corrective hint.
|
|
188
|
+
* @param {unknown} error
|
|
189
|
+
* @returns {never}
|
|
190
|
+
*/
|
|
191
|
+
function failClean(error) {
|
|
192
|
+
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
155
196
|
function printHelp() {
|
|
156
197
|
console.log(`Darkmown
|
|
157
198
|
|
package/src/compiler.js
CHANGED
|
@@ -110,13 +110,24 @@ export function compilePage(file, context) {
|
|
|
110
110
|
const compiled = compileDocument(file, context);
|
|
111
111
|
const title = compiled.meta.title || "Darkmown";
|
|
112
112
|
const description = compiled.meta.description || "";
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
: "";
|
|
113
|
+
// Optional `image:` frontmatter sets the social-share preview (absolute URL).
|
|
114
|
+
const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
|
|
115
|
+
/** @type {string[]} */
|
|
116
|
+
const social = [];
|
|
117
|
+
if (description) social.push(`<meta name="description" content="${escapeHtml(description)}">`);
|
|
118
|
+
if (description || image) {
|
|
119
|
+
social.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
|
|
120
|
+
if (description) social.push(`<meta property="og:description" content="${escapeHtml(description)}">`);
|
|
121
|
+
social.push(`<meta property="og:type" content="website">`);
|
|
122
|
+
}
|
|
123
|
+
if (image) {
|
|
124
|
+
social.push(`<meta property="og:image" content="${escapeHtml(image)}">`);
|
|
125
|
+
social.push(`<meta name="twitter:image" content="${escapeHtml(image)}">`);
|
|
126
|
+
}
|
|
127
|
+
if (description || image) {
|
|
128
|
+
social.push(`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`);
|
|
129
|
+
}
|
|
130
|
+
const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
|
|
120
131
|
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";
|
|
121
132
|
const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
|
|
122
133
|
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
@@ -838,7 +849,10 @@ function compileComputedExpr(raw, ctx) {
|
|
|
838
849
|
/** @type {string[]} */
|
|
839
850
|
const strings = [];
|
|
840
851
|
let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
|
|
841
|
-
|
|
852
|
+
// JSON.stringify fully escapes embedded ", \, etc. so the re-inserted literal
|
|
853
|
+
// (line below) is an inert JS string and cannot terminate early to smuggle in
|
|
854
|
+
// live code. Mirrors the SAFE @loop where path (compileOperand).
|
|
855
|
+
strings.push(JSON.stringify(literal.slice(1, -1)));
|
|
842
856
|
return `__WDSTR${strings.length - 1}__`;
|
|
843
857
|
});
|
|
844
858
|
if (/["'\\`]/.test(expr)) {
|
|
@@ -910,6 +924,8 @@ function handleInput(line, ctx) {
|
|
|
910
924
|
if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
|
|
911
925
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
912
926
|
let type = "text";
|
|
927
|
+
let placeholder;
|
|
928
|
+
let hasAria = false;
|
|
913
929
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
914
930
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
915
931
|
if (token[3]) {
|
|
@@ -927,8 +943,16 @@ function handleInput(line, ctx) {
|
|
|
927
943
|
if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
928
944
|
throw new Error(`Unknown :input attribute "${token[1]}" in ${ctx.file}`);
|
|
929
945
|
}
|
|
946
|
+
if (token[1] === "placeholder") placeholder = value;
|
|
947
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
930
948
|
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
931
949
|
}
|
|
950
|
+
// Accessible name: if the author supplied neither aria-label nor aria-describedby,
|
|
951
|
+
// derive a non-visual aria-label from the placeholder, else from the field name.
|
|
952
|
+
// Never overrides an author-supplied aria attribute (zero layout impact).
|
|
953
|
+
if (!hasAria) {
|
|
954
|
+
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
955
|
+
}
|
|
932
956
|
return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
|
|
933
957
|
}
|
|
934
958
|
|
|
@@ -949,6 +973,8 @@ function handleBind(line, ctx) {
|
|
|
949
973
|
}
|
|
950
974
|
ctx.comp.assets.runtime = true;
|
|
951
975
|
let type = "text";
|
|
976
|
+
let placeholder;
|
|
977
|
+
let hasAria = false;
|
|
952
978
|
const attrs = [];
|
|
953
979
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
954
980
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
@@ -962,8 +988,16 @@ function handleBind(line, ctx) {
|
|
|
962
988
|
if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
963
989
|
throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
|
|
964
990
|
}
|
|
991
|
+
if (token[1] === "placeholder") placeholder = value;
|
|
992
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
965
993
|
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
966
994
|
}
|
|
995
|
+
// Accessible name (see handleInput): default a non-visual aria-label from the
|
|
996
|
+
// placeholder, else a humanized version of the bound state key. The author's own
|
|
997
|
+
// aria-label/aria-describedby always wins.
|
|
998
|
+
if (!hasAria) {
|
|
999
|
+
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
1000
|
+
}
|
|
967
1001
|
const initial = ctx.comp.state.get(key);
|
|
968
1002
|
const valueAttr = initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
969
1003
|
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
@@ -2089,6 +2123,23 @@ function stripQuotes(value) {
|
|
|
2089
2123
|
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
2090
2124
|
}
|
|
2091
2125
|
|
|
2126
|
+
/**
|
|
2127
|
+
* Turn a field name / state key into a human-readable accessible name, e.g.
|
|
2128
|
+
* `quest` → "Quest", `user-name` → "User name", `firstName` → "First name".
|
|
2129
|
+
* Hyphens, underscores, and camelCase boundaries become spaces; the first letter
|
|
2130
|
+
* is capitalized. Used to auto-derive aria-label on generated form inputs.
|
|
2131
|
+
* @param {string} name
|
|
2132
|
+
* @returns {string}
|
|
2133
|
+
*/
|
|
2134
|
+
function humanizeName(name) {
|
|
2135
|
+
const words = name
|
|
2136
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // split camelCase
|
|
2137
|
+
.replace(/[-_]+/g, " ") // hyphens/underscores → spaces
|
|
2138
|
+
.trim()
|
|
2139
|
+
.toLowerCase();
|
|
2140
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2092
2143
|
/**
|
|
2093
2144
|
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
2094
2145
|
* @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
|
}
|
|
@@ -44,13 +49,37 @@ export function resolvePublicFile(distRoot, url) {
|
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
|
-
* 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.
|
|
48
80
|
* @param {string} file File path or name.
|
|
49
81
|
* @returns {string}
|
|
50
82
|
*/
|
|
51
83
|
export function contentType(file) {
|
|
52
|
-
|
|
53
|
-
if (file.endsWith(".js")) return "text/javascript";
|
|
54
|
-
if (file.endsWith(".json")) return "application/json";
|
|
55
|
-
return "text/html";
|
|
84
|
+
return contentTypes[path.extname(file).toLowerCase()] || "application/octet-stream";
|
|
56
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.
|