@zvndev/darkmown 0.19.0 → 0.20.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 +12 -0
- package/README.md +2 -2
- package/package.json +21 -3
- package/src/builder.js +20 -14
- package/src/cli.js +216 -77
- package/src/compiler/body.js +432 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1149 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +189 -0
- package/src/compiler/loops.js +644 -0
- package/src/compiler/markdown.js +233 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +281 -0
- package/src/compiler.js +26 -2932
- package/src/dev.js +3 -1
- package/src/headers.js +3 -4
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/scaffold.js +158 -131
- package/src/skin.js +2 -1
- package/site/_/og.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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.20.0 — 2026-06-26
|
|
6
|
+
|
|
7
|
+
Engineering hardening: a real programmatic API, plus a gold-standard pass on tooling, tests, and internals. No behavior change for existing sites — the runtime is byte-for-byte the same (~5.8 KB gzipped).
|
|
8
|
+
|
|
9
|
+
- **Programmatic API.** `@zvndev/darkmown` now ships a proper entry point: `import { buildSite, compilePage, compileDocument, parseFrontmatter, escapeHtml, … } from "@zvndev/darkmown"` resolves through an `exports` map and a `src/index.js` barrel that mirrors the public surface already declared in `types/index.d.ts`. The package was CLI-only before; the `darkmown` CLI is unchanged.
|
|
10
|
+
- **Compiler internals split.** The ~2,900-line `src/compiler.js` is now a `src/compiler/` folder along its existing seams (page shell, frontmatter, includes, interpolation, predicates, markdown plugin, loops, directives, body). Public exports and output are identical — `src/compiler.js` stays as a thin re-export barrel. Purely internal.
|
|
11
|
+
- **Sharper compile errors.** Malformed-directive errors now report `file:line` (matching the unclosed-block errors that already did), and action-literal failures name the offending page. Easier to trace a mistake back to its source.
|
|
12
|
+
- **Lint + format with Biome.** Added Biome (one binary, lint + format) with `npm run lint` / `npm run format:check`, a `lint` CI job, and a husky + lint-staged pre-commit that checks staged files. `src/runtime.js` is excluded from the formatter so the byte budget is never moved by a reformat. Style commands are documented in CONTRIBUTING.md.
|
|
13
|
+
- **100% line coverage, CI-gated.** The test suite now covers every line of `src/`, and the coverage gate is raised from 90% to 100%. Covering the CLI and dev-server paths surfaced and fixed two real bugs: symlinked-bin entry-point detection and a dev-server response that wrote headers before reading the file.
|
|
14
|
+
- **Runtime size trend.** A committed `.size-snapshot.json` baseline lets CI report the gzipped-runtime delta on every change, alongside the existing hard < 6 KB gate.
|
|
15
|
+
- **Fixed a production-breaking `vercel.json`.** An invalid `comment` property was rejected by Vercel's strict schema and failed every deploy before install; removed, with a regression test that rejects any non-allowlisted key in a `headers[]` rule.
|
|
16
|
+
|
|
5
17
|
## 0.19.0 — 2026-06-25
|
|
6
18
|
|
|
7
19
|
Post-review sprint: deeper reactive loops, hardened deploys, and OS-aware theming.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
**[darkmown.com](https://darkmown.com)** · Markdown that runs.
|
|
9
9
|
|
|
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 ~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 ~5.8 KB gzipped, CI-enforced under 6 KB.
|
|
11
11
|
|
|
12
12
|
## Quick start
|
|
13
13
|
|
|
@@ -52,7 +52,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
52
52
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
53
53
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
54
54
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
55
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.
|
|
55
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.8 KB gzipped, CI-enforced under 6 KB).
|
|
56
56
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
57
57
|
|
|
58
58
|
## Interpolation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -10,26 +10,41 @@
|
|
|
10
10
|
"bin": {
|
|
11
11
|
"darkmown": "./src/cli.js"
|
|
12
12
|
},
|
|
13
|
+
"main": "./src/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
13
18
|
"types": "./types/index.d.ts",
|
|
14
19
|
"scripts": {
|
|
15
20
|
"build": "node src/cli.js build",
|
|
16
21
|
"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: 6144)'); if (size >= 6144) process.exit(1)\"",
|
|
17
22
|
"dev": "node src/cli.js dev",
|
|
18
23
|
"test": "node --test tests/*.test.js",
|
|
19
|
-
"test:cov": "node scripts/test-coverage-gate.mjs
|
|
24
|
+
"test:cov": "node scripts/test-coverage-gate.mjs 100",
|
|
20
25
|
"test:e2e": "playwright test",
|
|
21
26
|
"test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
|
|
22
27
|
"pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
|
|
23
28
|
"typecheck": "tsc --noEmit",
|
|
24
29
|
"types": "tsc -p tsconfig.types.json",
|
|
25
30
|
"smoke": "node scripts/smoke-consumer.mjs",
|
|
31
|
+
"lint": "biome check .",
|
|
32
|
+
"format": "biome format --write .",
|
|
33
|
+
"format:check": "biome format .",
|
|
34
|
+
"size:check": "node scripts/size-check.mjs",
|
|
35
|
+
"prepare": "husky || true",
|
|
26
36
|
"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"
|
|
37
|
+
"prepublishOnly": "npm run prepublish:check",
|
|
38
|
+
"og": "node scripts/og-image.mjs"
|
|
39
|
+
},
|
|
40
|
+
"lint-staged": {
|
|
41
|
+
"*.{js,json}": "biome check --no-errors-on-unmatched --files-ignore-unknown=true"
|
|
28
42
|
},
|
|
29
43
|
"files": [
|
|
30
44
|
"src",
|
|
31
45
|
"types",
|
|
32
46
|
"site",
|
|
47
|
+
"!site/_/og.png",
|
|
33
48
|
"docs/cli.md",
|
|
34
49
|
"docs/spec-alignment.md",
|
|
35
50
|
"AGENTS.md",
|
|
@@ -46,9 +61,12 @@
|
|
|
46
61
|
"markdown-it": "^14.2.0"
|
|
47
62
|
},
|
|
48
63
|
"devDependencies": {
|
|
64
|
+
"@biomejs/biome": "^2.5.1",
|
|
49
65
|
"@playwright/test": "^1.49.0",
|
|
50
66
|
"@types/markdown-it": "^14.1.2",
|
|
51
67
|
"@types/node": "^26.0.0",
|
|
68
|
+
"husky": "^9.1.7",
|
|
69
|
+
"lint-staged": "^16.4.0",
|
|
52
70
|
"typescript": "^6.0.3"
|
|
53
71
|
},
|
|
54
72
|
"repository": {
|
package/src/builder.js
CHANGED
|
@@ -60,7 +60,9 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
60
60
|
file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
|
|
61
61
|
assets: {
|
|
62
62
|
skins: [...page.assets.skins],
|
|
63
|
-
scripts: page.assets.runtime
|
|
63
|
+
scripts: page.assets.runtime
|
|
64
|
+
? ["/__wd/runtime.js", ...page.assets.scripts]
|
|
65
|
+
: [...page.assets.scripts],
|
|
64
66
|
runtime: page.assets.runtime
|
|
65
67
|
}
|
|
66
68
|
});
|
|
@@ -101,13 +103,15 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
101
103
|
* @returns {string}
|
|
102
104
|
*/
|
|
103
105
|
export function stripRuntimeComments(source) {
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
return (
|
|
107
|
+
source
|
|
108
|
+
// JSDoc blocks — type annotations the browser never needs.
|
|
109
|
+
.replace(/\/\*\*[\s\S]*?\*\//g, "")
|
|
110
|
+
// Whole-line `//` comments — developer notes that don't belong in the download.
|
|
111
|
+
.replace(/^[ \t]*\/\/.*$/gm, "")
|
|
112
|
+
// Drop the now blank/whitespace-only lines the removals leave behind.
|
|
113
|
+
.replace(/^[ \t]*\n/gm, "")
|
|
114
|
+
);
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/**
|
|
@@ -126,10 +130,10 @@ export function stripRuntimeComments(source) {
|
|
|
126
130
|
* @returns {string}
|
|
127
131
|
*/
|
|
128
132
|
export function renderCloudflareHeaders(manifest) {
|
|
129
|
-
const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
];
|
|
133
|
+
const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(
|
|
134
|
+
([name, value]) => ` ${name}: ${value}`
|
|
135
|
+
);
|
|
136
|
+
const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
|
|
133
137
|
|
|
134
138
|
for (const entry of manifest) {
|
|
135
139
|
if (entry.assets.runtime) continue; // reactive routes keep the catch-all (relaxed) CSP
|
|
@@ -169,7 +173,7 @@ function defaultNotFoundHtml() {
|
|
|
169
173
|
"<body>",
|
|
170
174
|
"<main>",
|
|
171
175
|
"<h1>Page not found</h1>",
|
|
172
|
-
|
|
176
|
+
'<p>That route does not exist. <a href="/">Back to home</a>.</p>',
|
|
173
177
|
"</main>",
|
|
174
178
|
"</body>",
|
|
175
179
|
"</html>",
|
|
@@ -243,7 +247,9 @@ function emitPageAssets(paths) {
|
|
|
243
247
|
if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
|
|
244
248
|
const out = path.join(paths.distRoot, rel);
|
|
245
249
|
if (fs.existsSync(out)) {
|
|
246
|
-
console.warn(
|
|
250
|
+
console.warn(
|
|
251
|
+
`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`
|
|
252
|
+
);
|
|
247
253
|
continue;
|
|
248
254
|
}
|
|
249
255
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
package/src/cli.js
CHANGED
|
@@ -10,38 +10,101 @@ 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run the Darkmown CLI. Extracted from the top-level script so every command
|
|
16
|
+
* branch is unit-testable in-process: it never reads `process.argv` or calls
|
|
17
|
+
* `process.exit` directly. Instead it takes the argv tail and an injectable
|
|
18
|
+
* environment, RETURNS a value (and a server/close handle for `dev`/`serve`),
|
|
19
|
+
* and throws `CliError` for the failure paths the binary maps to exit code 1.
|
|
20
|
+
*
|
|
21
|
+
* The thin top-level wrapper below preserves identical binary behavior: it reads
|
|
22
|
+
* `process.argv`, calls `run`, prints `CliError` messages the same way the old
|
|
23
|
+
* inline `failClean`/`console.error` did, and exits with the right code.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {object} RunEnv
|
|
26
|
+
* @property {string} [cwd] Working directory (defaults to `process.cwd()`).
|
|
27
|
+
* @property {(message?: unknown, ...rest: unknown[]) => void} [log] stdout sink.
|
|
28
|
+
* @property {(message?: unknown, ...rest: unknown[]) => void} [warn] stderr (warn) sink.
|
|
29
|
+
* @property {(message?: unknown, ...rest: unknown[]) => void} [error] stderr (error) sink.
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]} argv The argv tail (i.e. `process.argv.slice(2)`).
|
|
32
|
+
* @param {RunEnv} [env]
|
|
33
|
+
* @returns {Promise<{ command: string, server?: import("node:http").Server, close?: () => Promise<void> }>}
|
|
34
|
+
*/
|
|
35
|
+
export async function run(argv, env = {}) {
|
|
36
|
+
const cwd = env.cwd ?? process.cwd();
|
|
37
|
+
const log = env.log ?? console.log;
|
|
38
|
+
const warn = env.warn ?? console.warn;
|
|
39
|
+
const error = env.error ?? console.error;
|
|
40
|
+
// Bare `darkmown` prints help (discovery) rather than silently building — a new
|
|
41
|
+
// user typing the command alone expects to see what it can do, not a no-op build.
|
|
42
|
+
const command = argv[0] || "help";
|
|
43
|
+
|
|
44
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
45
|
+
log(helpText());
|
|
46
|
+
return { command: "help" };
|
|
31
47
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
49
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
50
|
+
log(pkg.version);
|
|
51
|
+
return { command: "version" };
|
|
52
|
+
}
|
|
53
|
+
if (command === "init") {
|
|
54
|
+
const target = argv[1] || ".";
|
|
55
|
+
const result = initProject(path.resolve(cwd, target));
|
|
56
|
+
const relativeRoot = path.relative(cwd, result.root) || ".";
|
|
57
|
+
log(`Created Darkmown project at ${relativeRoot}`);
|
|
58
|
+
log(`Next: ${nextStep(relativeRoot)}`);
|
|
59
|
+
return { command: "init" };
|
|
60
|
+
}
|
|
61
|
+
if (command === "build") {
|
|
62
|
+
const result = buildSite(cwd);
|
|
63
|
+
log(`Built ${result.routes.length} routes into ${path.relative(cwd, result.distRoot)}`);
|
|
64
|
+
return { command: "build" };
|
|
65
|
+
}
|
|
66
|
+
if (command === "dev") {
|
|
67
|
+
return startDevServer({ cwd, log, warn, error });
|
|
68
|
+
}
|
|
69
|
+
if (command === "serve") {
|
|
70
|
+
return startPreviewServer({ cwd, log, error });
|
|
71
|
+
}
|
|
72
|
+
// No command matched: report it, show help, and fail like the binary always
|
|
73
|
+
// did (exit 1). `silent` keeps `main` from re-printing a `✗` line on top.
|
|
74
|
+
error(`Unknown command: ${command}`);
|
|
75
|
+
log(helpText());
|
|
76
|
+
throw new CliError(`Unknown command: ${command}`, { silent: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* An error whose message is already user-facing. The binary prints it (prefixed
|
|
81
|
+
* with `✗` unless `silent`) and exits 1 — never a Node stack trace.
|
|
82
|
+
*/
|
|
83
|
+
export class CliError extends Error {
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} message
|
|
86
|
+
* @param {{ silent?: boolean }} [options] `silent` skips the `✗`-prefixed print
|
|
87
|
+
* (used when the message was already shown, e.g. unknown command + help).
|
|
88
|
+
*/
|
|
89
|
+
constructor(message, options = {}) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = "CliError";
|
|
92
|
+
this.silent = Boolean(options.silent);
|
|
40
93
|
}
|
|
41
|
-
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the live-compiler dev server. Builds once (recording, not crashing on, a
|
|
98
|
+
* broken initial build), watches `site/` and `src/`, and serves dist with the
|
|
99
|
+
* dev client + SSE reload channel injected. Returns the server and a clean
|
|
100
|
+
* `close()` that tears down the watchers and the rebuild debounce timer.
|
|
101
|
+
* @param {{ cwd: string, log: typeof console.log, warn: typeof console.warn, error: typeof console.error }} io
|
|
102
|
+
* @returns {Promise<{ command: "dev", server: import("node:http").Server, close: () => Promise<void> }>}
|
|
103
|
+
*/
|
|
104
|
+
function startDevServer({ cwd, log, warn, error }) {
|
|
42
105
|
const port = Number(process.env.PORT || 5173);
|
|
43
106
|
const host = process.env.HOST || "127.0.0.1";
|
|
44
|
-
const distRoot = path.join(
|
|
107
|
+
const distRoot = path.join(cwd, "dist");
|
|
45
108
|
/** @type {Set<import("node:http").ServerResponse>} */
|
|
46
109
|
const clients = new Set();
|
|
47
110
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
@@ -53,10 +116,10 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
53
116
|
// and thus the error overlay — comes up. Record the error so new SSE clients
|
|
54
117
|
// see it on connect; the next successful rebuild clears it via `reload`.
|
|
55
118
|
try {
|
|
56
|
-
buildSite();
|
|
57
|
-
} catch (
|
|
58
|
-
lastBuildError = (
|
|
59
|
-
|
|
119
|
+
buildSite(cwd);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
lastBuildError = (err instanceof Error ? err.message : String(err)).trim();
|
|
122
|
+
error(lastBuildError);
|
|
60
123
|
}
|
|
61
124
|
|
|
62
125
|
// Rebuild in a child process so changes to Darkmown's own src/ always load
|
|
@@ -65,26 +128,32 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
65
128
|
clearTimeout(timer);
|
|
66
129
|
timer = setTimeout(() => {
|
|
67
130
|
const started = performance.now();
|
|
68
|
-
execFile(process.execPath, [cliPath, "build"], { cwd
|
|
69
|
-
if (
|
|
70
|
-
const message = (stderr || stdout || String(
|
|
131
|
+
execFile(process.execPath, [cliPath, "build"], { cwd }, (err, stdout, stderr) => {
|
|
132
|
+
if (err) {
|
|
133
|
+
const message = (stderr || stdout || String(err)).trim();
|
|
71
134
|
lastBuildError = message;
|
|
72
|
-
|
|
73
|
-
broadcast(
|
|
135
|
+
error(message);
|
|
136
|
+
broadcast(
|
|
137
|
+
clients,
|
|
138
|
+
`event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`
|
|
139
|
+
);
|
|
74
140
|
return;
|
|
75
141
|
}
|
|
76
142
|
lastBuildError = undefined;
|
|
77
143
|
const elapsed = Math.round(performance.now() - started);
|
|
78
|
-
if (stderr.trim())
|
|
144
|
+
if (stderr.trim()) warn(stderr.trim());
|
|
79
145
|
broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
|
|
80
|
-
|
|
146
|
+
log(`${stdout.trim().split("\n").at(-1)} (${elapsed}ms)`);
|
|
81
147
|
});
|
|
82
148
|
}, 30);
|
|
83
149
|
};
|
|
84
150
|
|
|
151
|
+
/** @type {import("node:fs").FSWatcher[]} */
|
|
152
|
+
const watchers = [];
|
|
85
153
|
for (const dir of ["site", "src"]) {
|
|
86
|
-
|
|
87
|
-
|
|
154
|
+
const abs = path.join(cwd, dir);
|
|
155
|
+
if (fs.existsSync(abs)) {
|
|
156
|
+
watchers.push(fs.watch(abs, { recursive: true }, rebuild));
|
|
88
157
|
}
|
|
89
158
|
}
|
|
90
159
|
|
|
@@ -102,7 +171,9 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
102
171
|
// Replay a broken build to clients that connect while it's still failing,
|
|
103
172
|
// so the overlay shows even after a startup or pre-connect compile error.
|
|
104
173
|
if (lastBuildError) {
|
|
105
|
-
res.write(
|
|
174
|
+
res.write(
|
|
175
|
+
`event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`
|
|
176
|
+
);
|
|
106
177
|
}
|
|
107
178
|
req.on("close", () => clients.delete(res));
|
|
108
179
|
return;
|
|
@@ -117,39 +188,67 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
117
188
|
req.on("data", (chunk) => (body += chunk));
|
|
118
189
|
req.on("end", () => {
|
|
119
190
|
res.writeHead(200, { "content-type": "application/json" });
|
|
120
|
-
res.end(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
191
|
+
res.end(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
ok: true,
|
|
194
|
+
received: Object.fromEntries(new URLSearchParams(body)),
|
|
195
|
+
at: new Date().toISOString()
|
|
196
|
+
})
|
|
197
|
+
);
|
|
125
198
|
});
|
|
126
199
|
return;
|
|
127
200
|
}
|
|
128
201
|
serveDev(distRoot, url, res);
|
|
129
|
-
} catch (
|
|
202
|
+
} catch (err) {
|
|
130
203
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
131
|
-
res.end((
|
|
204
|
+
res.end((err instanceof Error && err.stack) || String(err));
|
|
132
205
|
}
|
|
133
206
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
207
|
+
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
server.listen(port, host, () => {
|
|
210
|
+
log(`Darkmown dev server ready at http://${host}:${port}`);
|
|
211
|
+
log(`Live compiler watching site/ and src/`);
|
|
212
|
+
resolve({
|
|
213
|
+
command: "dev",
|
|
214
|
+
server,
|
|
215
|
+
close: () => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
for (const watcher of watchers) watcher.close();
|
|
218
|
+
for (const client of clients) client.end();
|
|
219
|
+
clients.clear();
|
|
220
|
+
return new Promise((done) => server.close(() => done()));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
137
224
|
});
|
|
138
|
-
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Start the static preview server for an existing `dist/`. Throws `CliError`
|
|
229
|
+
* when there is nothing built to serve.
|
|
230
|
+
* @param {{ cwd: string, log: typeof console.log, error: typeof console.error }} io
|
|
231
|
+
* @returns {Promise<{ command: "serve", server: import("node:http").Server, close: () => Promise<void> }>}
|
|
232
|
+
*/
|
|
233
|
+
function startPreviewServer({ cwd, log, error }) {
|
|
139
234
|
const port = Number(process.env.PORT || 4173);
|
|
140
235
|
const host = process.env.HOST || "127.0.0.1";
|
|
141
|
-
const distRoot = path.join(
|
|
236
|
+
const distRoot = path.join(cwd, "dist");
|
|
142
237
|
if (!fs.existsSync(distRoot)) {
|
|
143
|
-
|
|
144
|
-
|
|
238
|
+
error("No dist directory found. Run `darkmown build` first.");
|
|
239
|
+
throw new CliError("No dist directory found. Run `darkmown build` first.", { silent: true });
|
|
145
240
|
}
|
|
146
|
-
http.createServer((req, res) => serve(distRoot, req.url || "/", res))
|
|
147
|
-
|
|
241
|
+
const server = http.createServer((req, res) => serve(distRoot, req.url || "/", res));
|
|
242
|
+
return new Promise((resolve) => {
|
|
243
|
+
server.listen(port, host, () => {
|
|
244
|
+
log(`Darkmown preview of dist at http://${host}:${port}`);
|
|
245
|
+
resolve({
|
|
246
|
+
command: "serve",
|
|
247
|
+
server,
|
|
248
|
+
close: () => new Promise((done) => server.close(() => done()))
|
|
249
|
+
});
|
|
250
|
+
});
|
|
148
251
|
});
|
|
149
|
-
} else {
|
|
150
|
-
console.error(`Unknown command: ${command}`);
|
|
151
|
-
printHelp();
|
|
152
|
-
process.exit(1);
|
|
153
252
|
}
|
|
154
253
|
|
|
155
254
|
/**
|
|
@@ -165,8 +264,12 @@ function serveDev(distRoot, url, res) {
|
|
|
165
264
|
return;
|
|
166
265
|
}
|
|
167
266
|
if (file.endsWith(".html")) {
|
|
267
|
+
// Read BEFORE writing headers: if the read throws (e.g. the path is a
|
|
268
|
+
// directory), the handler's catch can still send a clean 500 instead of
|
|
269
|
+
// failing to overwrite an already-sent 200.
|
|
270
|
+
const html = injectDevClient(fs.readFileSync(file, "utf8"));
|
|
168
271
|
res.writeHead(200, { "content-type": "text/html" });
|
|
169
|
-
res.end(
|
|
272
|
+
res.end(html);
|
|
170
273
|
return;
|
|
171
274
|
}
|
|
172
275
|
res.writeHead(200, { "content-type": contentType(file) });
|
|
@@ -183,18 +286,11 @@ function broadcast(clients, message) {
|
|
|
183
286
|
}
|
|
184
287
|
|
|
185
288
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* @param {unknown} error
|
|
189
|
-
* @returns {never}
|
|
289
|
+
* The CLI help text. Returned (not printed) so `run` and tests share one source.
|
|
290
|
+
* @returns {string}
|
|
190
291
|
*/
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function printHelp() {
|
|
197
|
-
console.log(`Darkmown
|
|
292
|
+
export function helpText() {
|
|
293
|
+
return `Darkmown
|
|
198
294
|
|
|
199
295
|
Usage:
|
|
200
296
|
darkmown init [dir] Scaffold a new Darkmown project
|
|
@@ -210,15 +306,58 @@ Authoring:
|
|
|
210
306
|
@loop x into item Loop over JSON files, in-scope values, or :state lists
|
|
211
307
|
*.skin Colocated indentation-based CSS
|
|
212
308
|
*.js Colocated page behavior
|
|
213
|
-
|
|
309
|
+
`;
|
|
214
310
|
}
|
|
215
311
|
|
|
216
|
-
|
|
217
312
|
/**
|
|
218
313
|
* @param {string} relativeRoot
|
|
219
314
|
* @returns {string}
|
|
220
315
|
*/
|
|
221
|
-
function nextStep(relativeRoot) {
|
|
316
|
+
export function nextStep(relativeRoot) {
|
|
222
317
|
if (relativeRoot === ".") return "npm install && npm run dev";
|
|
223
318
|
return `cd ${relativeRoot} && npm install && npm run dev`;
|
|
224
319
|
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Binary entry point: read argv, dispatch, and map failures to a clean
|
|
323
|
+
* `✗`-prefixed line + exit code 1 — never a Node stack trace. The thrown Error
|
|
324
|
+
* already carries the file path + corrective hint.
|
|
325
|
+
* @returns {Promise<void>}
|
|
326
|
+
*/
|
|
327
|
+
async function main() {
|
|
328
|
+
try {
|
|
329
|
+
await run(process.argv.slice(2));
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!(error instanceof CliError && error.silent)) {
|
|
332
|
+
// A compile error has a clear, file-pathed message — print just that.
|
|
333
|
+
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
334
|
+
}
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Whether `entryArg` (typically `process.argv[1]`) points at THIS module — i.e.
|
|
341
|
+
* the binary is being executed, not imported by a test. npm installs the
|
|
342
|
+
* `darkmown` bin as a SYMLINK in node_modules/.bin, so the entry arg is the link
|
|
343
|
+
* path while `import.meta.url` resolves to the real file; comparing realpaths
|
|
344
|
+
* lets the binary still self-execute. Exported so the symlink/missing-path
|
|
345
|
+
* branches are unit-testable.
|
|
346
|
+
* @param {string | undefined} entryArg
|
|
347
|
+
* @returns {boolean}
|
|
348
|
+
*/
|
|
349
|
+
export function isEntryPoint(entryArg) {
|
|
350
|
+
if (!entryArg) return false;
|
|
351
|
+
if (entryArg === cliPath) return true;
|
|
352
|
+
try {
|
|
353
|
+
// Resolve symlinks on BOTH sides: npm's .bin/darkmown is a link to this file.
|
|
354
|
+
return fs.realpathSync(entryArg) === fs.realpathSync(cliPath);
|
|
355
|
+
} catch {
|
|
356
|
+
return false; // a non-existent entry path can never be this module
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Auto-run only when this file is the process entry point.
|
|
361
|
+
if (isEntryPoint(process.argv[1])) {
|
|
362
|
+
main();
|
|
363
|
+
}
|