@zvndev/darkmown 0.18.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 +22 -0
- package/README.md +60 -3
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +9 -0
- package/package.json +23 -5
- package/site/_/nav.wd +1 -0
- package/site/pages/loops.wd +22 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +66 -10
- 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 -2753
- package/src/dev.js +3 -1
- package/src/headers.js +93 -0
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +83 -59
- package/src/scaffold.js +158 -131
- package/src/skin.js +62 -3
- package/src/statics.js +19 -2
- package/site/_/og.png +0 -0
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
|
+
}
|