@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 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
+ [![npm](https://img.shields.io/npm/v/@zvndev/darkmown)](https://www.npmjs.com/package/@zvndev/darkmown)
4
+ [![CI](https://github.com/ZVN-DEV/darkmown/actions/workflows/ci.yml/badge.svg)](https://github.com/ZVN-DEV/darkmown/actions/workflows/ci.yml)
5
+ [![provenance](https://img.shields.io/badge/npm-provenance-blue)](https://www.npmjs.com/package/@zvndev/darkmown)
6
+ [![license](https://img.shields.io/npm/l/@zvndev/darkmown)](./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: `![logo](/__wd/media/logo.svg)`. 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
- Darkmown renders Markdown with `html: true`, so **raw HTML in your content passes through verbatim** — by design, like every Markdown site generator. Treat content files as trusted input.
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.11.2",
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 80",
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": "PM", "role": "Acceptance & demo proof", "lead": true },
3
- { "id": 2, "name": "Dev", "role": "Compiler, router, runtime", "lead": false },
4
- { "id": 3, "name": "Research", "role": "Prior art & drift control", "lead": false },
5
- { "id": 4, "name": "Testing", "role": "Routes, includes, browser smoke", "lead": false },
6
- { "id": 5, "name": "Review", "role": "Blocks sloppy completion", "lead": false }
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>
@@ -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).
@@ -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
- const command = process.argv[2] || "build";
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
- const target = process.argv[3] || ".";
22
- const result = initProject(path.resolve(process.cwd(), target));
23
- const relativeRoot = path.relative(process.cwd(), result.root) || ".";
24
- console.log(`Created Darkmown project at ${relativeRoot}`);
25
- console.log(`Next: ${nextStep(relativeRoot)}`);
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
- const result = buildSite();
28
- console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
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
- const descriptionTag = description
114
- ? `\n <meta name="description" content="${escapeHtml(description)}">
115
- <meta property="og:title" content="${escapeHtml(title)}">
116
- <meta property="og:description" content="${escapeHtml(description)}">
117
- <meta property="og:type" content="website">
118
- <meta name="twitter:card" content="summary">`
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
- strings.push(`"${literal.slice(1, -1)}"`);
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 path to a response content-type, defaulting to `text/html`.
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
- if (file.endsWith(".css")) return "text/css";
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
  }
@@ -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.