@zvndev/darkmown 0.11.2 → 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/CHANGELOG.md +11 -0
- package/README.md +7 -1
- package/package.json +2 -2
- package/site/_/team.json +5 -5
- package/site/pages/404.wd +21 -0
- package/site/pages/docs/index.wd +10 -0
- package/src/builder.js +33 -0
- package/src/cli.js +19 -1
- package/src/compiler.js +41 -1
- package/src/statics.js +35 -6
- package/site/_/sprint-loop.wd +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 0.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
|
+
|
|
5
16
|
## 0.11.2 — 2026-06-17
|
|
6
17
|
|
|
7
18
|
Product trust and release-hardening sprint.
|
package/README.md
CHANGED
|
@@ -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/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"
|
|
@@ -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",
|
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/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
|
@@ -30,11 +30,22 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
30
30
|
const port = Number(process.env.PORT || 5173);
|
|
31
31
|
const host = process.env.HOST || "127.0.0.1";
|
|
32
32
|
const distRoot = path.join(process.cwd(), "dist");
|
|
33
|
-
buildSite();
|
|
34
33
|
/** @type {Set<import("node:http").ServerResponse>} */
|
|
35
34
|
const clients = new Set();
|
|
36
35
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
37
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
|
+
}
|
|
38
49
|
|
|
39
50
|
// Rebuild in a child process so changes to Darkmown's own src/ always load
|
|
40
51
|
// fresh modules — an in-process rebuild would reuse the stale import cache.
|
|
@@ -45,10 +56,12 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
45
56
|
execFile(process.execPath, [cliPath, "build"], { cwd: process.cwd() }, (error, stdout, stderr) => {
|
|
46
57
|
if (error) {
|
|
47
58
|
const message = (stderr || stdout || String(error)).trim();
|
|
59
|
+
lastBuildError = message;
|
|
48
60
|
console.error(message);
|
|
49
61
|
broadcast(clients, `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`);
|
|
50
62
|
return;
|
|
51
63
|
}
|
|
64
|
+
lastBuildError = undefined;
|
|
52
65
|
const elapsed = Math.round(performance.now() - started);
|
|
53
66
|
if (stderr.trim()) console.warn(stderr.trim());
|
|
54
67
|
broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
|
|
@@ -74,6 +87,11 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
74
87
|
});
|
|
75
88
|
res.write("\n");
|
|
76
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
|
+
}
|
|
77
95
|
req.on("close", () => clients.delete(res));
|
|
78
96
|
return;
|
|
79
97
|
}
|
package/src/compiler.js
CHANGED
|
@@ -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]) {
|
|
@@ -927,8 +932,16 @@ function handleInput(line, ctx) {
|
|
|
927
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)) {
|
|
@@ -962,8 +977,16 @@ function handleBind(line, ctx) {
|
|
|
962
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(" ")}>`;
|
|
@@ -2089,6 +2112,23 @@ function stripQuotes(value) {
|
|
|
2089
2112
|
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
2090
2113
|
}
|
|
2091
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
|
+
|
|
2092
2132
|
/**
|
|
2093
2133
|
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
2094
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
|
}
|
|
@@ -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.
|