@zvndev/darkmown 2.9.0 → 2.10.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/AGENTS.md +23 -12
- package/CHANGELOG.md +29 -0
- package/README.md +105 -6
- package/SECURITY.md +1 -0
- package/docs/cli.md +1 -1
- package/docs/spec-alignment.md +16 -2
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +8 -4
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +47 -2
- package/site/pages/index.wd +1 -0
- package/site/pages/playground/index.js +58 -54
- package/src/api-runner.js +80 -8
- package/src/builder.js +70 -15
- package/src/catalog.js +13 -4
- package/src/cli.js +18 -6
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +13 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +6 -0
- package/src/compiler/state.js +75 -0
- package/src/compiler/structure.js +272 -4
- package/src/errors.js +74 -3
- package/src/font-subset-worker.js +131 -0
- package/src/font-subset.js +475 -0
- package/src/grammar.js +3 -2
- package/src/headers.js +28 -1
- package/src/image-variant-resize.js +4 -1
- package/src/image-variants.js +9 -4
- package/src/router.js +16 -0
- package/src/skin.js +111 -45
- package/src/statics.js +22 -14
- package/src/templates/dashboard/README.md +3 -1
- package/src/templates/landing/site/pages/index.skin +7 -0
- package/src/templates/landing/site/pages/index.wd +10 -3
- package/src/templates/store/README.md +5 -2
- package/src/templates/store/site/_/card.wd +19 -0
- package/src/templates/store/site/_/products.json +6 -6
- package/src/templates/store/site/pages/index.skin +17 -2
- package/src/templates/store/site/pages/index.wd +3 -12
- package/types/api-runner.d.ts +20 -4
- package/types/builder.d.ts +25 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +28 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/structure.d.ts +53 -1
- package/types/errors.d.ts +6 -4
- package/types/font-subset.d.ts +144 -0
- package/types/headers.d.ts +14 -0
- package/types/image-variants.d.ts +7 -3
- package/types/router.d.ts +5 -0
- package/types/skin.d.ts +9 -4
package/src/image-variants.js
CHANGED
|
@@ -228,21 +228,26 @@ export function generateImageVariants(paths, options = {}) {
|
|
|
228
228
|
* build is synchronous; sharp is not), communicating through temp JSON files
|
|
229
229
|
* rather than stdio so a large job list never hits a pipe buffer. The worker
|
|
230
230
|
* always writes a results file — a missing or unparsable one means the child
|
|
231
|
-
* itself died,
|
|
232
|
-
* if sharp were absent: a hint, no
|
|
231
|
+
* itself died (crashed, or killed by the spawn timeout), which is reported as
|
|
232
|
+
* `unavailable` (the build then behaves as if sharp were absent: a hint, no
|
|
233
|
+
* variants, no failure).
|
|
233
234
|
* @param {ResizeJob[]} jobs
|
|
234
235
|
* @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
|
|
235
236
|
* Injectable for tests; the real one is `spawnSync`.
|
|
237
|
+
* @param {number} [timeoutMs] The worker is killed after this long (default
|
|
238
|
+
* two minutes), so a hung sharp call cannot stall a build forever. The kill
|
|
239
|
+
* lands in the same `unavailable` path as any other dead child.
|
|
236
240
|
* @returns {ResizeResult}
|
|
237
241
|
*/
|
|
238
|
-
export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync)) {
|
|
242
|
+
export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
|
|
239
243
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-img-variants-"));
|
|
240
244
|
try {
|
|
241
245
|
const jobsFile = path.join(dir, "jobs.json");
|
|
242
246
|
const resultsFile = path.join(dir, "results.json");
|
|
243
247
|
fs.writeFileSync(jobsFile, JSON.stringify(jobs));
|
|
244
248
|
spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
|
|
245
|
-
stdio: ["ignore", "ignore", "inherit"]
|
|
249
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
250
|
+
timeout: timeoutMs
|
|
246
251
|
});
|
|
247
252
|
if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
|
|
248
253
|
try {
|
package/src/router.js
CHANGED
|
@@ -88,6 +88,11 @@ export function isDraft(meta) {
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Derive a public route path from a source file path.
|
|
91
|
+
*
|
|
92
|
+
* Fail-closed on control/whitespace characters: the route string flows RAW
|
|
93
|
+
* into generated config (`dist/_headers` blocks, llms.txt lines), where a
|
|
94
|
+
* newline in a filename would inject whole config lines. Rejecting here, at
|
|
95
|
+
* the one route-derivation chokepoint, protects every downstream sink at once.
|
|
91
96
|
* @param {string} routesRoot Absolute path to `site/pages`.
|
|
92
97
|
* @param {string} file Absolute path to the source file.
|
|
93
98
|
* @returns {string}
|
|
@@ -98,9 +103,20 @@ export function routeFromFile(routesRoot, file) {
|
|
|
98
103
|
const parts = rel.split(path.sep);
|
|
99
104
|
if (parts.at(-1) === "index") parts.pop();
|
|
100
105
|
const route = `/${parts.join("/")}`;
|
|
106
|
+
if (ROUTE_UNSAFE_CHARS.test(route)) {
|
|
107
|
+
throw wdError(
|
|
108
|
+
`Refusing to route ${file} — a page filename must not contain whitespace or ` +
|
|
109
|
+
"control characters: the route is written verbatim into generated config " +
|
|
110
|
+
"(dist/_headers, llms.txt), where such a character would corrupt the file.",
|
|
111
|
+
{ code: "WD908", file }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
101
114
|
return route === "/" ? "/" : `${route}/`;
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
/** Whitespace (`\s`) and control characters (`\p{Cc}`: C0, DEL, C1) a route string must never carry. */
|
|
118
|
+
const ROUTE_UNSAFE_CHARS = /[\s\p{Cc}]/u;
|
|
119
|
+
|
|
104
120
|
/**
|
|
105
121
|
* Map a public route to the `index.html` output path under `distRoot`.
|
|
106
122
|
*
|
package/src/skin.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { wdError } from "./compiler/context.js";
|
|
3
3
|
|
|
4
|
+
// Concrete, compilable skins for the at-rule error hints + the error registry
|
|
5
|
+
// (src/errors.js). Multi-line because both at-rules are block constructs; a
|
|
6
|
+
// test in tests/skin-atrules.test.js compiles each verbatim.
|
|
7
|
+
export const FONT_FACE_EXAMPLE = '@font-face\n font-family "Newsreader"';
|
|
8
|
+
export const KEYFRAMES_EXAMPLE = "@keyframes fade-in\n to\n opacity 1";
|
|
9
|
+
|
|
4
10
|
const aliases = new Map([
|
|
5
11
|
["radius", "border-radius"],
|
|
6
12
|
["shadow", "box-shadow"],
|
|
@@ -70,28 +76,37 @@ const PAGE_LEVEL_SELECTORS = new Set(["page", "*", "html", "body", "::selection"
|
|
|
70
76
|
* selectors), they are ALWAYS global (never scope-stamped, same contract as
|
|
71
77
|
* `tokens`), and their declaration values use the same alias/`$token` pipeline.
|
|
72
78
|
* @param {string} source
|
|
73
|
-
* @param {{ scope?: string, subjects?: Set<string
|
|
74
|
-
* id enabling scoped mode. `subjects`: an optional set the
|
|
75
|
-
* every scoped subject token (`.card`, `h2`, `#id`) for
|
|
76
|
-
* warning, so the caller need not re-parse the source.
|
|
79
|
+
* @param {{ scope?: string, subjects?: Set<string>, file?: string }} [opts]
|
|
80
|
+
* `scope`: the scope id enabling scoped mode. `subjects`: an optional set the
|
|
81
|
+
* compiler fills with every scoped subject token (`.card`, `h2`, `#id`) for
|
|
82
|
+
* the unused-selector warning, so the caller need not re-parse the source.
|
|
83
|
+
* `file`: the skin's path, threaded into author-facing errors so they report
|
|
84
|
+
* `file:line` (the builder always passes it; a bare call reports `on line N`).
|
|
77
85
|
* @returns {string}
|
|
78
86
|
*/
|
|
79
87
|
export function compileSkin(source, opts = {}) {
|
|
80
88
|
const scope = opts.scope;
|
|
81
89
|
const subjects = opts.subjects;
|
|
90
|
+
const file = opts.file;
|
|
82
91
|
const lines = source
|
|
83
92
|
.replace(/\r\n?/g, "\n")
|
|
84
|
-
|
|
93
|
+
// Strip /* … */ block comments (any span), KEEPING their newlines so the
|
|
94
|
+
// `line` recorded below stays the true file line even past a multi-line
|
|
95
|
+
// comment.
|
|
96
|
+
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
|
|
85
97
|
.split("\n")
|
|
86
|
-
.
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
.map((raw, index) => ({
|
|
99
|
+
indent: (raw.match(/^\s*/)?.[0] ?? "").length,
|
|
100
|
+
text: raw.trim(),
|
|
101
|
+
line: index + 1
|
|
102
|
+
}))
|
|
103
|
+
.filter(({ text }) => {
|
|
104
|
+
if (!text || text.startsWith("//")) return false;
|
|
89
105
|
// Decorative divider lines (----, ====, * * *, ~~~) carry no rule and would
|
|
90
106
|
// otherwise parse as a bogus selector/declaration. Anything with a letter or
|
|
91
107
|
// digit is real content; punctuation-only lines are skipped.
|
|
92
|
-
return /[A-Za-z0-9]/.test(
|
|
93
|
-
})
|
|
94
|
-
.map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
|
|
108
|
+
return /[A-Za-z0-9]/.test(text);
|
|
109
|
+
});
|
|
95
110
|
|
|
96
111
|
// The opt-in marker: `scoped` must be the FIRST meaningful line (the filter
|
|
97
112
|
// above has already dropped comments, blanks, and dividers). Anywhere else it
|
|
@@ -181,7 +196,7 @@ export function compileSkin(source, opts = {}) {
|
|
|
181
196
|
const atRule = /^@(font-face|keyframes)(?:\s+(.+))?$/.exec(text);
|
|
182
197
|
if (atRule) {
|
|
183
198
|
const media = stack.find((frame) => frame.media)?.media;
|
|
184
|
-
const block = collectAtRule(atRule[1], atRule[2], lines, i);
|
|
199
|
+
const block = collectAtRule(atRule[1], atRule[2], lines, i, file);
|
|
185
200
|
css.push(media ? `${media} {\n${block.css}\n}` : block.css);
|
|
186
201
|
i = block.end;
|
|
187
202
|
continue;
|
|
@@ -367,17 +382,19 @@ function normalizeSelector(text, parent) {
|
|
|
367
382
|
const KEYFRAME_SELECTOR = /^(?:from|to|\d+(?:\.\d+)?%)(?:\s*,\s*(?:from|to|\d+(?:\.\d+)?%))*$/;
|
|
368
383
|
|
|
369
384
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* @param {
|
|
377
|
-
*
|
|
385
|
+
* An author-facing at-rule error, routed through `wdError` like every other
|
|
386
|
+
* compile error: it carries a registered WD8xx code (src/errors.js), the true
|
|
387
|
+
* source line, and a corrective `Use:` hint. The file is named when the caller
|
|
388
|
+
* threaded one (the builder always does); a bare `compileSkin` call has no
|
|
389
|
+
* path, so the location falls back to `on line N`.
|
|
390
|
+
* @param {string} summary What is wrong, naming the offending text.
|
|
391
|
+
* @param {import("./compiler/context.js").WdErrorInfo} wd The structured mirror;
|
|
392
|
+
* `code` is required (every at-rule error is registered in src/errors.js).
|
|
393
|
+
* @returns {Error & { wd: import("./compiler/context.js").WdErrorInfo }}
|
|
378
394
|
*/
|
|
379
|
-
function skinError(
|
|
380
|
-
|
|
395
|
+
function skinError(summary, wd) {
|
|
396
|
+
const where = wd.file ? ` in ${wd.file}:${wd.line}` : ` on line ${wd.line}`;
|
|
397
|
+
return wdError(`${summary}${where}. Use: ${wd.hint}`, wd);
|
|
381
398
|
}
|
|
382
399
|
|
|
383
400
|
/**
|
|
@@ -403,21 +420,28 @@ function declaration(text) {
|
|
|
403
420
|
* `50%, 75%`), each with its own indented declarations; a keyframe selector is
|
|
404
421
|
* validated against KEYFRAME_SELECTOR so it can never be misread as a
|
|
405
422
|
* declaration or an element selector, and declarations cannot nest further.
|
|
423
|
+
* Every rejection is a coded WD8xx error pointing at the offending line.
|
|
406
424
|
* @param {string} kind `font-face` or `keyframes`.
|
|
407
425
|
* @param {string | undefined} rest Text after the at-rule keyword (the
|
|
408
426
|
* keyframes name; invalid on `@font-face`).
|
|
409
|
-
* @param {{ indent: number, text: string }[]} lines Preprocessed
|
|
427
|
+
* @param {{ indent: number, text: string, line: number }[]} lines Preprocessed
|
|
428
|
+
* skin lines, each carrying its true 1-based file line.
|
|
410
429
|
* @param {number} start Index of the at-rule line.
|
|
430
|
+
* @param {string | undefined} file The skin's path, when the caller knows it.
|
|
411
431
|
* @returns {{ css: string, end: number }} `end` = last consumed line index.
|
|
412
432
|
*/
|
|
413
|
-
function collectAtRule(kind, rest, lines, start) {
|
|
433
|
+
function collectAtRule(kind, rest, lines, start, file) {
|
|
414
434
|
const indent = lines[start].indent;
|
|
415
435
|
|
|
416
436
|
if (kind === "font-face") {
|
|
417
437
|
if (rest)
|
|
418
|
-
throw skinError(
|
|
419
|
-
|
|
420
|
-
|
|
438
|
+
throw skinError(`@font-face takes no name, got "${rest}"`, {
|
|
439
|
+
code: "WD804",
|
|
440
|
+
file,
|
|
441
|
+
line: lines[start].line,
|
|
442
|
+
hint: `write the descriptors indented beneath a bare @font-face line — e.g. font-family "Newsreader"`,
|
|
443
|
+
example: FONT_FACE_EXAMPLE
|
|
444
|
+
});
|
|
421
445
|
/** @type {string[]} */
|
|
422
446
|
const decls = [];
|
|
423
447
|
let i = start;
|
|
@@ -425,52 +449,94 @@ function collectAtRule(kind, rest, lines, start) {
|
|
|
425
449
|
i++;
|
|
426
450
|
if (i + 1 < lines.length && lines[i + 1].indent > lines[i].indent)
|
|
427
451
|
throw skinError(
|
|
428
|
-
`@font-face only takes declarations — "${lines[i].text}" opens a nested block, which is not valid inside @font-face
|
|
452
|
+
`@font-face only takes declarations — "${lines[i].text}" opens a nested block, which is not valid inside @font-face`,
|
|
453
|
+
{
|
|
454
|
+
code: "WD804",
|
|
455
|
+
file,
|
|
456
|
+
line: lines[i].line,
|
|
457
|
+
hint: `keep every @font-face child a flat prop value declaration — e.g. font-family "Newsreader"`,
|
|
458
|
+
example: FONT_FACE_EXAMPLE
|
|
459
|
+
}
|
|
429
460
|
);
|
|
430
461
|
decls.push(` ${declaration(lines[i].text)}`);
|
|
431
462
|
}
|
|
432
463
|
if (!decls.length)
|
|
433
|
-
throw skinError(
|
|
434
|
-
|
|
435
|
-
|
|
464
|
+
throw skinError("@font-face has no declarations", {
|
|
465
|
+
code: "WD804",
|
|
466
|
+
file,
|
|
467
|
+
line: lines[start].line,
|
|
468
|
+
hint: `indent its descriptors beneath it — e.g. font-family "Newsreader"`,
|
|
469
|
+
example: FONT_FACE_EXAMPLE
|
|
470
|
+
});
|
|
436
471
|
return { css: `@font-face {\n${decls.join("\n")}\n}`, end: i };
|
|
437
472
|
}
|
|
438
473
|
|
|
439
|
-
if (!rest)
|
|
474
|
+
if (!rest)
|
|
475
|
+
throw skinError("@keyframes needs a name", {
|
|
476
|
+
code: "WD805",
|
|
477
|
+
file,
|
|
478
|
+
line: lines[start].line,
|
|
479
|
+
hint: "@keyframes <name> — e.g. @keyframes fade-in",
|
|
480
|
+
example: KEYFRAMES_EXAMPLE
|
|
481
|
+
});
|
|
440
482
|
if (!/^-?[A-Za-z_][\w-]*$/.test(rest))
|
|
441
|
-
throw skinError(
|
|
442
|
-
|
|
443
|
-
|
|
483
|
+
throw skinError(`invalid @keyframes name "${rest}"`, {
|
|
484
|
+
code: "WD805",
|
|
485
|
+
file,
|
|
486
|
+
line: lines[start].line,
|
|
487
|
+
hint: "name it with a plain CSS identifier — e.g. @keyframes fade-in",
|
|
488
|
+
example: KEYFRAMES_EXAMPLE
|
|
489
|
+
});
|
|
444
490
|
/** @type {string[]} */
|
|
445
491
|
const stops = [];
|
|
446
492
|
let i = start;
|
|
447
493
|
while (i + 1 < lines.length && lines[i + 1].indent > indent) {
|
|
448
494
|
i++;
|
|
449
495
|
const selector = lines[i].text;
|
|
496
|
+
const selectorLine = lines[i].line;
|
|
450
497
|
const selectorIndent = lines[i].indent;
|
|
451
498
|
if (!KEYFRAME_SELECTOR.test(selector))
|
|
452
|
-
throw skinError(
|
|
453
|
-
|
|
454
|
-
|
|
499
|
+
throw skinError(`"${selector}" is not a keyframe selector in @keyframes ${rest}`, {
|
|
500
|
+
code: "WD806",
|
|
501
|
+
file,
|
|
502
|
+
line: selectorLine,
|
|
503
|
+
hint: "use from, to, or a percentage (50%, or a list like 50%, 75%)",
|
|
504
|
+
example: KEYFRAMES_EXAMPLE
|
|
505
|
+
});
|
|
455
506
|
/** @type {string[]} */
|
|
456
507
|
const decls = [];
|
|
457
508
|
while (i + 1 < lines.length && lines[i + 1].indent > selectorIndent) {
|
|
458
509
|
i++;
|
|
459
510
|
if (i + 1 < lines.length && lines[i + 1].indent > lines[i].indent)
|
|
460
511
|
throw skinError(
|
|
461
|
-
`@keyframes declarations cannot nest — "${lines[i].text}" opens a block inside keyframe "${selector}"
|
|
512
|
+
`@keyframes declarations cannot nest — "${lines[i].text}" opens a block inside keyframe "${selector}"`,
|
|
513
|
+
{
|
|
514
|
+
code: "WD807",
|
|
515
|
+
file,
|
|
516
|
+
line: lines[i].line,
|
|
517
|
+
hint: "keep each keyframe's declarations flat, one level beneath its selector — e.g. opacity 1",
|
|
518
|
+
example: KEYFRAMES_EXAMPLE
|
|
519
|
+
}
|
|
462
520
|
);
|
|
463
521
|
decls.push(` ${declaration(lines[i].text)}`);
|
|
464
522
|
}
|
|
465
523
|
if (!decls.length)
|
|
466
|
-
throw skinError(
|
|
467
|
-
|
|
468
|
-
|
|
524
|
+
throw skinError(`keyframe "${selector}" in @keyframes ${rest} has no declarations`, {
|
|
525
|
+
code: "WD807",
|
|
526
|
+
file,
|
|
527
|
+
line: selectorLine,
|
|
528
|
+
hint: "indent the declarations beneath the keyframe selector — e.g. opacity 1",
|
|
529
|
+
example: KEYFRAMES_EXAMPLE
|
|
530
|
+
});
|
|
469
531
|
stops.push(` ${selector} {\n${decls.join("\n")}\n }`);
|
|
470
532
|
}
|
|
471
533
|
if (!stops.length)
|
|
472
|
-
throw skinError(
|
|
473
|
-
|
|
474
|
-
|
|
534
|
+
throw skinError(`@keyframes ${rest} has no keyframe selectors`, {
|
|
535
|
+
code: "WD807",
|
|
536
|
+
file,
|
|
537
|
+
line: lines[start].line,
|
|
538
|
+
hint: "indent from/to/percentage blocks beneath it — e.g. from",
|
|
539
|
+
example: KEYFRAMES_EXAMPLE
|
|
540
|
+
});
|
|
475
541
|
return { css: `@keyframes ${rest} {\n${stops.join("\n")}\n}`, end: i };
|
|
476
542
|
}
|
package/src/statics.js
CHANGED
|
@@ -2,14 +2,16 @@ import fs from "node:fs";
|
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import zlib from "node:zlib";
|
|
5
|
-
import { BASE_SECURITY_HEADERS, REACTIVE_CSP,
|
|
5
|
+
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, routeCsp } from "./headers.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The security headers (including the right CSP variant) for an HTML response.
|
|
9
9
|
* Mirrors what production ships. Since 2.1 both variants are eval-free (the
|
|
10
10
|
* reactive runtime walks a validated AST, not `new Function`), so a reactive and a
|
|
11
|
-
* static route resolve to the same CSP — the per-route selection is kept for
|
|
12
|
-
* with the `_headers` / `vercel.json` structure the same build writes
|
|
11
|
+
* static route resolve to the same base CSP — the per-route selection is kept for
|
|
12
|
+
* parity with the `_headers` / `vercel.json` structure the same build writes, and
|
|
13
|
+
* it is what lets a route's own `:embed` hosts (recorded on its manifest entry as
|
|
14
|
+
* `assets.embedHosts`) widen `frame-src` for exactly that route. Anything not
|
|
13
15
|
* found in `routes.json` (an unknown route, a 404, a missing manifest) falls back
|
|
14
16
|
* to the reactive CSP, exactly like the production `_headers` catch-all block.
|
|
15
17
|
* @param {string} distRoot Absolute path to the build output directory.
|
|
@@ -17,9 +19,12 @@ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
|
17
19
|
* @returns {Record<string, string>}
|
|
18
20
|
*/
|
|
19
21
|
function htmlSecurityHeaders(distRoot, url) {
|
|
22
|
+
const entry = routeEntry(distRoot, routeOf(url));
|
|
20
23
|
return {
|
|
21
24
|
...BASE_SECURITY_HEADERS,
|
|
22
|
-
"Content-Security-Policy":
|
|
25
|
+
"Content-Security-Policy": entry
|
|
26
|
+
? routeCsp(Boolean(entry.assets?.runtime), entry.assets?.embedHosts ?? [])
|
|
27
|
+
: REACTIVE_CSP
|
|
23
28
|
};
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -43,24 +48,23 @@ function routeOf(url) {
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* never accidentally served the stricter policy.
|
|
51
|
+
* The route's own manifest entry from the build's `routes.json`, or null when
|
|
52
|
+
* the route is absent or the manifest can't be read — in which case the caller
|
|
53
|
+
* falls back to the reactive CSP, matching the production `_headers` catch-all,
|
|
54
|
+
* so an unknown route is never accidentally served the stricter policy.
|
|
50
55
|
* @param {string} distRoot
|
|
51
56
|
* @param {string | null} route
|
|
52
|
-
* @returns {boolean}
|
|
57
|
+
* @returns {{ route: string, assets?: { runtime?: boolean, embedHosts?: string[] } } | null}
|
|
53
58
|
*/
|
|
54
|
-
function
|
|
55
|
-
if (!route) return
|
|
59
|
+
function routeEntry(distRoot, route) {
|
|
60
|
+
if (!route) return null;
|
|
56
61
|
try {
|
|
57
62
|
const manifest = JSON.parse(fs.readFileSync(path.join(distRoot, "routes.json"), "utf8"));
|
|
58
|
-
|
|
59
|
-
if (entry) return Boolean(entry.assets?.runtime);
|
|
63
|
+
return manifest.find((/** @type {{ route: string }} */ e) => e.route === route) ?? null;
|
|
60
64
|
} catch {
|
|
61
65
|
/* no/unreadable manifest → reactive default (the catch-all policy) */
|
|
66
|
+
return null;
|
|
62
67
|
}
|
|
63
|
-
return true;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/**
|
|
@@ -256,6 +260,10 @@ export function resolvePublicFile(distRoot, url) {
|
|
|
256
260
|
} catch {
|
|
257
261
|
return null;
|
|
258
262
|
}
|
|
263
|
+
// A decoded NUL (%00) is a valid string here but every fs call on the
|
|
264
|
+
// resulting path throws ERR_INVALID_ARG_VALUE out of the request handler,
|
|
265
|
+
// killing the server. It can never name a built file, so it is a miss.
|
|
266
|
+
if (cleanUrl.includes("\0")) return null;
|
|
259
267
|
const base = cleanUrl.startsWith("/__wd/")
|
|
260
268
|
? cleanUrl
|
|
261
269
|
: path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
|
|
@@ -12,7 +12,9 @@ in `darkmown dev` and on every host. Loading and error states are handled inline
|
|
|
12
12
|
|
|
13
13
|
- `npm run dev` — live compiler + local `/api/*` runner on http://localhost:5173.
|
|
14
14
|
- `npm run build` — compile to `dist/`.
|
|
15
|
-
- `npm run preview
|
|
15
|
+
- `npm run preview`: serve the built `dist/` locally. Since Darkmown 2.8.0 this
|
|
16
|
+
runs your `api/` functions too, so the dashboard fetches live data in preview
|
|
17
|
+
exactly like in `dev`.
|
|
16
18
|
|
|
17
19
|
## Deploy
|
|
18
20
|
|
|
@@ -164,6 +164,13 @@ input:focus-visible, textarea:focus-visible, select:focus-visible
|
|
|
164
164
|
radius 12px
|
|
165
165
|
padding 1.25rem
|
|
166
166
|
|
|
167
|
+
.icon
|
|
168
|
+
color $accent
|
|
169
|
+
margin-bottom .35rem
|
|
170
|
+
|
|
171
|
+
.icon svg
|
|
172
|
+
display block
|
|
173
|
+
|
|
167
174
|
.feature h3
|
|
168
175
|
margin .25rem 0 .5rem
|
|
169
176
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
title: Acme — ship faster
|
|
3
3
|
description: A marketing one-pager built with Darkmown. Zero framework JS on the static parts, with a testimonials carousel.
|
|
4
4
|
transitions: true
|
|
5
|
+
html: true
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
@include /nav.wd
|
|
@@ -20,15 +21,21 @@ The all-in-one toolkit your team actually enjoys using.
|
|
|
20
21
|
|
|
21
22
|
::: features
|
|
22
23
|
::: feature
|
|
23
|
-
|
|
24
|
+
<div class="icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/></svg></div>
|
|
25
|
+
|
|
26
|
+
### Fast
|
|
24
27
|
Static pages, zero framework JavaScript. Instant loads, perfect Lighthouse.
|
|
25
28
|
:::
|
|
26
29
|
::: feature
|
|
27
|
-
|
|
30
|
+
<div class="icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg></div>
|
|
31
|
+
|
|
32
|
+
### Simple
|
|
28
33
|
Write Markdown. Add a directive only on the pages that need interactivity.
|
|
29
34
|
:::
|
|
30
35
|
::: feature
|
|
31
|
-
|
|
36
|
+
<div class="icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3"/><path d="m6 9 6-6 6 6"/><path d="M4 21h16"/></svg></div>
|
|
37
|
+
|
|
38
|
+
### Deploy anywhere
|
|
32
39
|
One command to Vercel or Cloudflare — your API comes along for free.
|
|
33
40
|
:::
|
|
34
41
|
:::
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
A storefront in Markdown: a product grid with live search, a shared `:store` cart,
|
|
4
4
|
and a checkout that POSTs to a serverless function.
|
|
5
5
|
|
|
6
|
-
- **Static catalog
|
|
6
|
+
- **Static catalog**: `site/pages/index.wd` loops `site/_/products.json`, rendering
|
|
7
|
+
each product through the `site/_/card.wd` include (a `defaults:` fallback plus a
|
|
8
|
+
`{ children }` slot for the add-to-cart button).
|
|
7
9
|
- **Cart** — a `:store` shared across the page; add/remove with `:button`.
|
|
8
10
|
- **Checkout** — `:form action="/api/checkout"` posts to `api/checkout.js`, a plain
|
|
9
11
|
Web-standard `(request) => Response` function that runs in `darkmown dev` and on
|
|
@@ -13,7 +15,8 @@ and a checkout that POSTs to a serverless function.
|
|
|
13
15
|
|
|
14
16
|
- `npm run dev` — live compiler + the local `/api/*` runner on http://localhost:5173.
|
|
15
17
|
- `npm run build` — compile to `dist/`.
|
|
16
|
-
- `npm run preview
|
|
18
|
+
- `npm run preview`: serve the built `dist/` locally. Since Darkmown 2.8.0 this
|
|
19
|
+
runs your `api/` functions too, so checkout works in preview exactly like in `dev`.
|
|
17
20
|
|
|
18
21
|
## Deploy
|
|
19
22
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[
|
|
2
|
-
{ "name": "Ceramic Mug", "
|
|
3
|
-
{ "name": "Linen Tote", "
|
|
4
|
-
{ "name": "Pocket Notebook", "
|
|
5
|
-
{ "name": "Desk Lamp", "
|
|
6
|
-
{ "name": "Plant Pot", "
|
|
7
|
-
{ "name": "Wall Clock", "
|
|
2
|
+
{ "name": "Ceramic Mug", "initial": "C", "price": 18 },
|
|
3
|
+
{ "name": "Linen Tote", "initial": "L", "price": 32 },
|
|
4
|
+
{ "name": "Pocket Notebook", "initial": "P", "price": 12 },
|
|
5
|
+
{ "name": "Desk Lamp", "initial": "D", "price": 64 },
|
|
6
|
+
{ "name": "Plant Pot", "initial": "P", "price": 24 },
|
|
7
|
+
{ "name": "Wall Clock", "initial": "W", "price": 48 }
|
|
8
8
|
]
|
|
@@ -93,13 +93,28 @@ input:focus-visible, select:focus-visible, textarea:focus-visible
|
|
|
93
93
|
border-color $accent
|
|
94
94
|
box-shadow 0 12px 26px -16px rgba(26,31,43,.4)
|
|
95
95
|
|
|
96
|
-
.
|
|
97
|
-
|
|
96
|
+
.tile
|
|
97
|
+
width 3rem
|
|
98
|
+
height 3rem
|
|
99
|
+
margin 0 auto
|
|
100
|
+
display grid
|
|
101
|
+
place-items center
|
|
102
|
+
font-size 1.3rem
|
|
103
|
+
font-weight 700
|
|
104
|
+
color $accent-deep
|
|
105
|
+
bg $paper
|
|
106
|
+
border 1px solid $line
|
|
107
|
+
radius 10px
|
|
98
108
|
|
|
99
109
|
.pname
|
|
100
110
|
font-weight 600
|
|
101
111
|
margin .35rem 0 .15rem
|
|
102
112
|
|
|
113
|
+
.note
|
|
114
|
+
color $muted
|
|
115
|
+
font-size .82rem
|
|
116
|
+
margin-bottom .6rem
|
|
117
|
+
|
|
103
118
|
.price
|
|
104
119
|
color $accent
|
|
105
120
|
font-weight 600
|
|
@@ -23,18 +23,9 @@ transitions: true
|
|
|
23
23
|
|
|
24
24
|
::: catalog
|
|
25
25
|
@loop /products.json into p where p.name contains q
|
|
26
|
-
|
|
27
|
-
::: emoji
|
|
28
|
-
{ p.emoji }
|
|
29
|
-
:::
|
|
30
|
-
::: pname
|
|
31
|
-
{ p.name }
|
|
32
|
-
:::
|
|
33
|
-
::: price
|
|
34
|
-
{ p.price | money }
|
|
35
|
-
:::
|
|
26
|
+
@include /card.wd
|
|
36
27
|
:button "Add to cart" -> cart += p
|
|
37
|
-
|
|
28
|
+
@endinclude
|
|
38
29
|
@empty
|
|
39
30
|
::: card .empty
|
|
40
31
|
Nothing matches "**{ q }**".
|
|
@@ -46,7 +37,7 @@ Nothing matches "**{ q }**".
|
|
|
46
37
|
|
|
47
38
|
@loop cart into item
|
|
48
39
|
::: card .line
|
|
49
|
-
|
|
40
|
+
**{ item.name }** — { item.price | money }
|
|
50
41
|
:button "Remove" -> cart remove item
|
|
51
42
|
:::
|
|
52
43
|
@empty
|
package/types/api-runner.d.ts
CHANGED
|
@@ -38,18 +38,25 @@ export function discoverApiRoutes(apiDir: string): ApiRoute[];
|
|
|
38
38
|
/**
|
|
39
39
|
* Match a request pathname against discovered routes. The pathname is expected
|
|
40
40
|
* to start with `/api`; anything else returns `null` (caller serves static).
|
|
41
|
+
* A dynamic segment whose percent-encoding is malformed (`%E0%A4%A`) makes
|
|
42
|
+
* `decodeURIComponent` throw — that is reported as `{ malformed: true }` so the
|
|
43
|
+
* caller answers a clean 400 instead of crashing the request handler.
|
|
41
44
|
* @param {ApiRoute[]} routes
|
|
42
45
|
* @param {string} pathname Request pathname (no query string).
|
|
43
|
-
* @returns {{ file: string, params: Record<string, string> } | null}
|
|
46
|
+
* @returns {{ file: string, params: Record<string, string> } | { malformed: true } | null}
|
|
44
47
|
*/
|
|
45
48
|
export function matchApiRoute(routes: ApiRoute[], pathname: string): {
|
|
46
49
|
file: string;
|
|
47
50
|
params: Record<string, string>;
|
|
51
|
+
} | {
|
|
52
|
+
malformed: true;
|
|
48
53
|
} | null;
|
|
49
54
|
/**
|
|
50
55
|
* Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
|
|
51
|
-
* methods that carry one) is buffered
|
|
52
|
-
* avoids the `duplex: "half"` streaming
|
|
56
|
+
* methods that carry one) is buffered up to {@link MAX_BODY_BYTES} — dev and
|
|
57
|
+
* preview endpoints are small and this avoids the `duplex: "half"` streaming
|
|
58
|
+
* dance. A larger body rejects with {@link BodyTooLargeError}, which
|
|
59
|
+
* `handleApiRequest` answers as a 413.
|
|
53
60
|
* @param {import("node:http").IncomingMessage} req
|
|
54
61
|
* @returns {Promise<Request>}
|
|
55
62
|
*/
|
|
@@ -75,19 +82,28 @@ export function writeWebResponse(response: Response, res: import("node:http").Se
|
|
|
75
82
|
* the handler succeeded or threw), `false` when nothing matched so the caller
|
|
76
83
|
* should fall through to static serving.
|
|
77
84
|
*
|
|
85
|
+
* Error disclosure: a handler throw is answered as JSON either way, but the
|
|
86
|
+
* `error` field carries the real message only when `discloseErrors` is set
|
|
87
|
+
* (the dev server's default — a documented dev convenience). The preview
|
|
88
|
+
* server passes `false` and the client gets the generic "Internal error",
|
|
89
|
+
* matching the shape the generated Cloudflare worker ships.
|
|
90
|
+
*
|
|
78
91
|
* @param {object} io
|
|
79
92
|
* @param {string} io.apiDir Absolute path to the project's `api/` directory.
|
|
80
93
|
* @param {import("node:http").IncomingMessage} io.req
|
|
81
94
|
* @param {import("node:http").ServerResponse} io.res
|
|
82
95
|
* @param {string | string[] | undefined} [io.acceptEncoding] The request's
|
|
83
96
|
* Accept-Encoding header, when the caller negotiates compression (preview).
|
|
97
|
+
* @param {boolean} [io.discloseErrors] Echo the thrown message to the client
|
|
98
|
+
* (default true, the dev behavior). Pass false on any production-shaped path.
|
|
84
99
|
* @returns {Promise<boolean>}
|
|
85
100
|
*/
|
|
86
|
-
export function handleApiRequest({ apiDir, req, res, acceptEncoding }: {
|
|
101
|
+
export function handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors }: {
|
|
87
102
|
apiDir: string;
|
|
88
103
|
req: import("node:http").IncomingMessage;
|
|
89
104
|
res: import("node:http").ServerResponse;
|
|
90
105
|
acceptEncoding?: string | string[] | undefined;
|
|
106
|
+
discloseErrors?: boolean | undefined;
|
|
91
107
|
}): Promise<boolean>;
|
|
92
108
|
/**
|
|
93
109
|
* A discovered API route.
|