@zvndev/darkmown 2.1.0 → 2.3.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 +65 -0
- package/docs/cli.md +8 -0
- package/docs/spec-alignment.md +18 -0
- package/grammar/wd-directives.gbnf +58 -0
- package/package.json +17 -10
- package/site/pages/docs/index.wd +29 -0
- package/site/pages/playground/index.js +47 -46
- package/src/builder.js +5 -0
- package/src/catalog.js +601 -0
- package/src/cli.js +8 -0
- package/src/compiler/actions.js +87 -34
- package/src/compiler/body.js +36 -14
- package/src/compiler/collections.js +16 -10
- package/src/compiler/context.js +70 -1
- package/src/compiler/fetch.js +51 -19
- package/src/compiler/format.js +5 -2
- package/src/compiler/forms.js +169 -50
- package/src/compiler/frontmatter.js +11 -2
- package/src/compiler/fs-reader.js +2 -1
- package/src/compiler/image-size.js +340 -0
- package/src/compiler/includes.js +8 -4
- package/src/compiler/interpolation.js +15 -7
- package/src/compiler/loops.js +78 -39
- package/src/compiler/markdown.js +4 -3
- package/src/compiler/media.js +38 -10
- package/src/compiler/page.js +8 -6
- package/src/compiler/predicates.js +74 -30
- package/src/compiler/reader.js +12 -2
- package/src/compiler/state.js +83 -18
- package/src/compiler/structure.js +57 -24
- package/src/deploy.js +8 -4
- package/src/errors.js +1156 -0
- package/src/grammar.js +131 -0
- package/src/index.js +1 -0
- package/src/router.js +7 -4
- package/src/scaffold.js +4 -2
- package/src/skin.js +17 -6
- package/types/api-runner.d.ts +94 -0
- package/types/builder.d.ts +141 -10
- package/types/catalog.d.ts +97 -0
- package/types/compiler/actions.d.ts +41 -0
- package/types/compiler/body.d.ts +11 -0
- package/types/compiler/collections.d.ts +76 -0
- package/types/compiler/context.d.ts +661 -0
- package/types/compiler/directives.d.ts +6 -0
- package/types/compiler/expr-ast.d.ts +20 -0
- package/types/compiler/fetch.d.ts +29 -0
- package/types/compiler/format.d.ts +49 -0
- package/types/compiler/forms.d.ts +94 -0
- package/types/compiler/frontmatter.d.ts +33 -0
- package/types/compiler/fs-reader.d.ts +7 -0
- package/types/compiler/image-size.d.ts +14 -0
- package/types/compiler/includes.d.ts +100 -0
- package/types/compiler/index.d.ts +5 -0
- package/types/compiler/interpolation.d.ts +115 -0
- package/types/compiler/loops.d.ts +36 -0
- package/types/compiler/markdown.d.ts +34 -0
- package/types/compiler/media.d.ts +32 -0
- package/types/compiler/page.d.ts +109 -0
- package/types/compiler/predicates.d.ts +50 -0
- package/types/compiler/reader.d.ts +56 -0
- package/types/compiler/state.d.ts +68 -0
- package/types/compiler/structure.d.ts +56 -0
- package/types/compiler.d.ts +35 -223
- package/types/dev.d.ts +47 -2
- package/types/errors.d.ts +100 -0
- package/types/feeds.d.ts +150 -0
- package/types/headers.d.ts +44 -0
- package/types/highlight.d.ts +24 -0
- package/types/router.d.ts +42 -3
- package/types/scaffold.d.ts +15 -3
- package/types/skin.d.ts +28 -1
- package/types/statics.d.ts +2 -1
package/src/grammar.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// GBNF grammar generator for `.wd` DIRECTIVE LINES.
|
|
3
|
+
//
|
|
4
|
+
// `generateGrammar()` emits a llama.cpp-compatible GBNF grammar that constrains
|
|
5
|
+
// a small model's decoding to well-formed Darkmown directive lines (plus the
|
|
6
|
+
// `{ path | pipe }` interpolation token). It is generated from the SAME catalog
|
|
7
|
+
// the cheatsheet uses, so the keyword set, `@loop` clauses, comparison operators,
|
|
8
|
+
// and format-pipe names can never drift from what compiles.
|
|
9
|
+
//
|
|
10
|
+
// Scope (stated deliberately): this grammar constrains the SHAPE of a directive
|
|
11
|
+
// line — the leading keyword must be a real directive, `@loop` must be
|
|
12
|
+
// `@loop <src> into <item> …clauses`, a `where`/`:if` condition must be
|
|
13
|
+
// `operand <op> operand`, a `:button` must be `"…" -> <action-chain>`, and an
|
|
14
|
+
// interpolation must pipe through a known formatter. It does NOT try to grammar-
|
|
15
|
+
// check free-form values (arbitrary JSON seeds, URLs, prose) — those are `text`.
|
|
16
|
+
// That is enough to delete the on-device failure class (JS/HTML muscle-memory
|
|
17
|
+
// openers like `::: card class=` or `cart.filter(...)` become unrepresentable)
|
|
18
|
+
// while staying small and maintainable.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { directiveCatalog } from "./catalog.js";
|
|
22
|
+
|
|
23
|
+
/** Slugify a directive token into a GBNF rule name (`@loop` → `dir-loop`). */
|
|
24
|
+
/** @type {Record<string, string>} */
|
|
25
|
+
const SLUG = {
|
|
26
|
+
"@include": "dir-include",
|
|
27
|
+
"@loop": "dir-loop",
|
|
28
|
+
":::": "dir-container",
|
|
29
|
+
":if": "dir-if",
|
|
30
|
+
":state": "dir-state",
|
|
31
|
+
":store": "dir-store",
|
|
32
|
+
":computed": "dir-computed",
|
|
33
|
+
":fetch": "dir-fetch",
|
|
34
|
+
":effect": "dir-effect",
|
|
35
|
+
":every": "dir-every",
|
|
36
|
+
":theme": "dir-theme",
|
|
37
|
+
":button": "dir-button",
|
|
38
|
+
":form": "dir-form",
|
|
39
|
+
":input": "dir-input",
|
|
40
|
+
":textarea": "dir-textarea",
|
|
41
|
+
":select": "dir-select",
|
|
42
|
+
":checkbox": "dir-checkbox",
|
|
43
|
+
":radio": "dir-radio",
|
|
44
|
+
":submit": "dir-submit",
|
|
45
|
+
":bind": "dir-bind",
|
|
46
|
+
":slider": "dir-slider",
|
|
47
|
+
":video": "dir-video",
|
|
48
|
+
":audio": "dir-audio",
|
|
49
|
+
":embed": "dir-embed",
|
|
50
|
+
":carousel": "dir-carousel"
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The directives given a structured RHS below; every other directive gets the
|
|
54
|
+
// generic `"<keyword>" restofline` rule (keyword pinned, argument tail free).
|
|
55
|
+
const STRUCTURED = new Set(["@loop", ":if", ":button", ":::"]);
|
|
56
|
+
|
|
57
|
+
/** A GBNF alternation literal list, e.g. `"a" | "b"`. */
|
|
58
|
+
const alt = (/** @type {string[]} */ items) => items.map((s) => `"${s}"`).join(" | ");
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generate the GBNF grammar for `.wd` directive lines from the directive catalog.
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function generateGrammar() {
|
|
65
|
+
const cat = directiveCatalog();
|
|
66
|
+
const dirRules = cat.directives.map((d) => SLUG[d.name]).filter(Boolean);
|
|
67
|
+
|
|
68
|
+
/** @type {string[]} */
|
|
69
|
+
const lines = [];
|
|
70
|
+
lines.push("# Darkmown .wd directive-line grammar — generated by src/grammar.js");
|
|
71
|
+
lines.push(
|
|
72
|
+
"# Constrains a directive line (or a { path | pipe } interpolation). Do not edit by hand."
|
|
73
|
+
);
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push("root ::= directive | binding");
|
|
76
|
+
lines.push(`directive ::= ${dirRules.join(" | ")}`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
|
|
79
|
+
// Structured directives.
|
|
80
|
+
lines.push('dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses');
|
|
81
|
+
lines.push('dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )');
|
|
82
|
+
lines.push('dir-button ::= ":button" sp dqstring sp "->" sp action-chain');
|
|
83
|
+
lines.push('dir-container ::= ":::" restofline');
|
|
84
|
+
lines.push("");
|
|
85
|
+
|
|
86
|
+
// Generic directives: keyword pinned, argument tail free to end of line.
|
|
87
|
+
for (const d of cat.directives) {
|
|
88
|
+
if (STRUCTURED.has(d.name)) continue;
|
|
89
|
+
lines.push(`${SLUG[d.name]} ::= "${d.name}" restofline`);
|
|
90
|
+
}
|
|
91
|
+
lines.push("");
|
|
92
|
+
|
|
93
|
+
// @loop clause vocabulary (mirrors the fixed clause order in the catalog).
|
|
94
|
+
lines.push("loop-clauses ::= ( sp loop-clause )*");
|
|
95
|
+
lines.push(
|
|
96
|
+
'loop-clause ::= where-clause | "sort by" sp path ( sp ( "asc" | "desc" ) )? | "reverse" | "offset" sp number | "limit" sp number | "paginate" sp number | "sortable"'
|
|
97
|
+
);
|
|
98
|
+
lines.push('where-clause ::= "where" sp condition ( sp joiner sp condition )*');
|
|
99
|
+
lines.push("condition ::= operand sp op sp operand");
|
|
100
|
+
lines.push(`op ::= ${alt(cat.predicateOps.map((o) => o.name))}`);
|
|
101
|
+
lines.push(`joiner ::= ${alt(cat.predicateJoiners.filter((j) => j !== "not"))}`);
|
|
102
|
+
lines.push("");
|
|
103
|
+
|
|
104
|
+
// Button-action vocabulary. Each operator token below is asserted present
|
|
105
|
+
// against the catalog's action tokens in tests/grammar.test.js.
|
|
106
|
+
lines.push('action-chain ::= action ( wsopt ";" wsopt action )*');
|
|
107
|
+
lines.push("action ::= ident action-op");
|
|
108
|
+
lines.push(
|
|
109
|
+
'action-op ::= "++" | "--" | sp "+=" sp value | sp "-=" sp value | sp "=" sp value | sp "toggle" ( sp value )? | sp "append" sp value | sp "prepend" sp value | sp "remove" sp value | sp "clear" | sp "merge" sp ident | sp "delete" sp value | sp "reset" | sp "refetch"'
|
|
110
|
+
);
|
|
111
|
+
lines.push("");
|
|
112
|
+
|
|
113
|
+
// Interpolation binding with format pipes.
|
|
114
|
+
lines.push('binding ::= "{" wsopt path pipe-chain wsopt "}"');
|
|
115
|
+
lines.push('pipe-chain ::= ( wsopt "|" wsopt pipe ( ":" value )* )*');
|
|
116
|
+
lines.push(`pipe ::= ${alt(cat.formatPipes.map((p) => p.name))}`);
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
// Terminals.
|
|
120
|
+
lines.push("operand ::= path | dqstring | number");
|
|
121
|
+
lines.push("value ::= dqstring | number | path");
|
|
122
|
+
lines.push("ident ::= [a-zA-Z_$] [a-zA-Z0-9_$]*");
|
|
123
|
+
lines.push("path ::= [a-zA-Z0-9_$/.~-]+");
|
|
124
|
+
lines.push("number ::= [0-9]+");
|
|
125
|
+
lines.push('dqstring ::= "\\"" [^"]* "\\""');
|
|
126
|
+
lines.push("restofline ::= [^\\n]*");
|
|
127
|
+
lines.push('sp ::= " "+');
|
|
128
|
+
lines.push('wsopt ::= " "*');
|
|
129
|
+
lines.push("");
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
package/src/index.js
CHANGED
package/src/router.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { wdError } from "./compiler/context.js";
|
|
3
4
|
import { parseFrontmatter } from "./compiler.js";
|
|
4
5
|
import { isHiddenName, pageExtensions } from "./config.js";
|
|
5
6
|
|
|
@@ -64,8 +65,9 @@ export function discoverRoutes(routesRoot, options = {}) {
|
|
|
64
65
|
const seen = new Map();
|
|
65
66
|
for (const route of routes) {
|
|
66
67
|
if (seen.has(route.route)) {
|
|
67
|
-
throw
|
|
68
|
-
`Duplicate route "${route.route}" from ${seen.get(route.route)} and ${route.file}
|
|
68
|
+
throw wdError(
|
|
69
|
+
`Duplicate route "${route.route}" from ${seen.get(route.route)} and ${route.file}`,
|
|
70
|
+
{ code: "WD901", file: route.file }
|
|
69
71
|
);
|
|
70
72
|
}
|
|
71
73
|
seen.set(route.route, route.file);
|
|
@@ -114,8 +116,9 @@ export function outputPathForRoute(distRoot, route) {
|
|
|
114
116
|
const clean = route === "/" ? "" : route.replace(/^\/|\/$/g, "");
|
|
115
117
|
const out = path.join(distRoot, clean, "index.html");
|
|
116
118
|
if (!path.resolve(out).startsWith(path.resolve(distRoot) + path.sep)) {
|
|
117
|
-
throw
|
|
118
|
-
`Route "${route}" resolves outside the build output directory ${distRoot} — routes must not contain traversal segments
|
|
119
|
+
throw wdError(
|
|
120
|
+
`Route "${route}" resolves outside the build output directory ${distRoot} — routes must not contain traversal segments`,
|
|
121
|
+
{ code: "WD902" }
|
|
119
122
|
);
|
|
120
123
|
}
|
|
121
124
|
return out;
|
package/src/scaffold.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { wdError } from "./compiler/context.js";
|
|
4
5
|
|
|
5
6
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const templatesDir = path.join(moduleDir, "templates");
|
|
@@ -36,9 +37,10 @@ export function initProject(root, options = {}) {
|
|
|
36
37
|
const templateDir = path.join(templatesDir, template);
|
|
37
38
|
if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) {
|
|
38
39
|
const list = availableTemplates().join(", ");
|
|
39
|
-
throw
|
|
40
|
+
throw wdError(
|
|
40
41
|
`Unknown template "${template}". Available templates: ${list || "(none)"}. ` +
|
|
41
|
-
"Use: darkmown init [dir] --template <name>"
|
|
42
|
+
"Use: darkmown init [dir] --template <name>",
|
|
43
|
+
{ code: "WD903", hint: "darkmown init [dir] --template <name>" }
|
|
42
44
|
);
|
|
43
45
|
}
|
|
44
46
|
|
package/src/skin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { wdError } from "./compiler/context.js";
|
|
2
3
|
|
|
3
4
|
const aliases = new Map([
|
|
4
5
|
["radius", "border-radius"],
|
|
@@ -96,7 +97,7 @@ export function compileSkin(source, opts = {}) {
|
|
|
96
97
|
}
|
|
97
98
|
for (let i = start; i < lines.length; i++) {
|
|
98
99
|
if (lines[i].text === "scoped" && lines[i].indent === 0) {
|
|
99
|
-
throw
|
|
100
|
+
throw wdError('"scoped" must be the first line of a .skin file', { code: "WD801" });
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -184,8 +185,9 @@ export function compileSkin(source, opts = {}) {
|
|
|
184
185
|
// place a filtered bare `*`/`page` reset would otherwise leak through to), so
|
|
185
186
|
// it errors with the same hint instead of emitting a never-matching rule.
|
|
186
187
|
if (scope && current === ":root") {
|
|
187
|
-
throw
|
|
188
|
-
`page-level declaration "${text}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one
|
|
188
|
+
throw wdError(
|
|
189
|
+
`page-level declaration "${text}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`,
|
|
190
|
+
{ code: "WD802" }
|
|
189
191
|
);
|
|
190
192
|
}
|
|
191
193
|
const media = stack.find((frame) => frame.media)?.media;
|
|
@@ -235,8 +237,9 @@ function guardPageLevel(text, parent) {
|
|
|
235
237
|
for (const part of text.split(",")) {
|
|
236
238
|
const clean = part.trim();
|
|
237
239
|
if (PAGE_LEVEL_SELECTORS.has(clean)) {
|
|
238
|
-
throw
|
|
239
|
-
`page-level selector "${clean}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one
|
|
240
|
+
throw wdError(
|
|
241
|
+
`page-level selector "${clean}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`,
|
|
242
|
+
{ code: "WD803" }
|
|
240
243
|
);
|
|
241
244
|
}
|
|
242
245
|
}
|
|
@@ -308,7 +311,15 @@ function subjectBase(base) {
|
|
|
308
311
|
*/
|
|
309
312
|
function normalizeAttr(bracketed) {
|
|
310
313
|
const m = bracketed.match(/^\[\s*([^\]=\s]+)\s*=\s*"?([^\]"]*)"?\s*\]$/);
|
|
311
|
-
|
|
314
|
+
if (!m) return null;
|
|
315
|
+
// The value is quoted on the way out, but the NAME lands in the selector
|
|
316
|
+
// verbatim — so it must be validated, not just captured. Without this an
|
|
317
|
+
// input like `tokens [{=v]` emitted `:root[{="v"]`, whose stray brace
|
|
318
|
+
// unbalances the stylesheet and makes a CSS parser swallow every rule that
|
|
319
|
+
// follows it. Anything that is not a plain CSS identifier falls through to
|
|
320
|
+
// the caller's `:root` fallback. (Found by tests/fuzz-skin.test.js.)
|
|
321
|
+
if (!/^[A-Za-z_-][\w-]*$/.test(m[1])) return null;
|
|
322
|
+
return `[${m[1]}="${m[2]}"]`;
|
|
312
323
|
}
|
|
313
324
|
|
|
314
325
|
/**
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local-dev serverless API runner. Gives `darkmown dev` the SAME `/api/*`
|
|
3
|
+
* behavior a project gets on Vercel / Cloudflare in production, so a `:fetch
|
|
4
|
+
* /api/x` or a form POST works identically before you ever deploy.
|
|
5
|
+
*
|
|
6
|
+
* The convention is deliberately plain: a backend endpoint is a plain-JS file
|
|
7
|
+
* under the project's top-level `api/` directory that default-exports a
|
|
8
|
+
* Web-standard handler `(request: Request, context) => Response | Promise<Response>`.
|
|
9
|
+
* That is the exact signature Vercel Edge, Cloudflare Pages, Netlify Edge and
|
|
10
|
+
* Deno already run — Darkmown owns no server, it just emulates the host locally.
|
|
11
|
+
*
|
|
12
|
+
* File → route, rooted at the `/api` URL prefix (matching Vercel's `api/`):
|
|
13
|
+
* api/echo.js → /api/echo
|
|
14
|
+
* api/users/index.js → /api/users
|
|
15
|
+
* api/users/list.js → /api/users/list
|
|
16
|
+
* api/users/[id].js → /api/users/:id (context.params.id)
|
|
17
|
+
*
|
|
18
|
+
* Nothing here mutates module state: routes are discovered per call so a newly
|
|
19
|
+
* added function is picked up without a restart, and each handler is imported
|
|
20
|
+
* with an mtime cache-buster so edits hot-reload.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* A discovered API route.
|
|
24
|
+
* @typedef {object} ApiRoute
|
|
25
|
+
* @property {string} file Absolute path to the handler module.
|
|
26
|
+
* @property {string[]} segments Route segments after `/api`; a dynamic segment
|
|
27
|
+
* is stored as `[name]` (e.g. `["users", "[id]"]`).
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Walk `apiDir` and return every `.js`/`.mjs` handler as an {@link ApiRoute}.
|
|
31
|
+
* Files/dirs whose name starts with `.`, `_`, or `-` are treated as private
|
|
32
|
+
* (helpers, fixtures) and never routed, matching the router's hidden-path rule.
|
|
33
|
+
* Returns `[]` when `apiDir` does not exist.
|
|
34
|
+
* @param {string} apiDir Absolute path to the project's `api/` directory.
|
|
35
|
+
* @returns {ApiRoute[]}
|
|
36
|
+
*/
|
|
37
|
+
export function discoverApiRoutes(apiDir: string): ApiRoute[];
|
|
38
|
+
/**
|
|
39
|
+
* Match a request pathname against discovered routes. The pathname is expected
|
|
40
|
+
* to start with `/api`; anything else returns `null` (caller serves static).
|
|
41
|
+
* @param {ApiRoute[]} routes
|
|
42
|
+
* @param {string} pathname Request pathname (no query string).
|
|
43
|
+
* @returns {{ file: string, params: Record<string, string> } | null}
|
|
44
|
+
*/
|
|
45
|
+
export function matchApiRoute(routes: ApiRoute[], pathname: string): {
|
|
46
|
+
file: string;
|
|
47
|
+
params: Record<string, string>;
|
|
48
|
+
} | null;
|
|
49
|
+
/**
|
|
50
|
+
* Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
|
|
51
|
+
* methods that carry one) is buffered fully — dev endpoints are small and this
|
|
52
|
+
* avoids the `duplex: "half"` streaming dance.
|
|
53
|
+
* @param {import("node:http").IncomingMessage} req
|
|
54
|
+
* @returns {Promise<Request>}
|
|
55
|
+
*/
|
|
56
|
+
export function nodeRequestToWeb(req: import("node:http").IncomingMessage): Promise<Request>;
|
|
57
|
+
/**
|
|
58
|
+
* Write a Web-standard `Response` back onto a Node `ServerResponse`.
|
|
59
|
+
* @param {Response} response
|
|
60
|
+
* @param {import("node:http").ServerResponse} res
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
export function writeWebResponse(response: Response, res: import("node:http").ServerResponse): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Handle a request as an API call if its pathname matches a function under
|
|
66
|
+
* `apiDir`. Returns `true` when it owned the request (a route matched, whether
|
|
67
|
+
* the handler succeeded or threw), `false` when nothing matched so the caller
|
|
68
|
+
* should fall through to static serving.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} io
|
|
71
|
+
* @param {string} io.apiDir Absolute path to the project's `api/` directory.
|
|
72
|
+
* @param {import("node:http").IncomingMessage} io.req
|
|
73
|
+
* @param {import("node:http").ServerResponse} io.res
|
|
74
|
+
* @returns {Promise<boolean>}
|
|
75
|
+
*/
|
|
76
|
+
export function handleApiRequest({ apiDir, req, res }: {
|
|
77
|
+
apiDir: string;
|
|
78
|
+
req: import("node:http").IncomingMessage;
|
|
79
|
+
res: import("node:http").ServerResponse;
|
|
80
|
+
}): Promise<boolean>;
|
|
81
|
+
/**
|
|
82
|
+
* A discovered API route.
|
|
83
|
+
*/
|
|
84
|
+
export type ApiRoute = {
|
|
85
|
+
/**
|
|
86
|
+
* Absolute path to the handler module.
|
|
87
|
+
*/
|
|
88
|
+
file: string;
|
|
89
|
+
/**
|
|
90
|
+
* Route segments after `/api`; a dynamic segment
|
|
91
|
+
* is stored as `[name]` (e.g. `["users", "[id]"]`).
|
|
92
|
+
*/
|
|
93
|
+
segments: string[];
|
|
94
|
+
};
|
package/types/builder.d.ts
CHANGED
|
@@ -1,24 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Compile the whole site under `cwd` into `dist`, returning the route manifest.
|
|
3
|
+
*
|
|
4
|
+
* The build is always 100% static, CDN-cacheable HTML. Backend `api/*.js`
|
|
5
|
+
* functions are NOT compiled — they are the host's concern. `target` only
|
|
6
|
+
* decides the small amount of host glue Darkmown emits alongside the static
|
|
7
|
+
* output: the default (`vercel`/anything) leaves `api/` for the platform to pick
|
|
8
|
+
* up natively; `cloudflare` emits a `dist/_worker.js/` module that routes
|
|
9
|
+
* `/api/*` to those same functions and serves everything else as a static asset.
|
|
10
|
+
*
|
|
3
11
|
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
4
|
-
* @
|
|
12
|
+
* @param {{ target?: string, includeDrafts?: boolean, depMap?: boolean, changed?: string[], quietFeedHints?: boolean }} [options]
|
|
13
|
+
* `target: "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps
|
|
14
|
+
* `draft: true` pages in the build + feeds (dev / `build --drafts`); default
|
|
15
|
+
* excludes them at route discovery so feeds never see a draft. `depMap: true`
|
|
16
|
+
* (dev builds) writes the per-route dependency map to `dist/.wd-dev-deps.json`.
|
|
17
|
+
* `changed` (project-relative paths, dev only; implies `depMap`) attempts an
|
|
18
|
+
* incremental rebuild of just the affected routes, falling back to a full
|
|
19
|
+
* rebuild on ANY uncertainty — a stale page is worse than a slow rebuild.
|
|
20
|
+
* @returns {BuildResult}
|
|
5
21
|
*/
|
|
6
|
-
export function buildSite(cwd?: string
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
22
|
+
export function buildSite(cwd?: string, options?: {
|
|
23
|
+
target?: string;
|
|
24
|
+
includeDrafts?: boolean;
|
|
25
|
+
depMap?: boolean;
|
|
26
|
+
changed?: string[];
|
|
27
|
+
quietFeedHints?: boolean;
|
|
28
|
+
}): BuildResult;
|
|
10
29
|
/**
|
|
11
|
-
* Strip
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* and the gzip budget honest (measured against what users
|
|
15
|
-
*
|
|
30
|
+
* Strip developer comments from the runtime before it ships. The source keeps
|
|
31
|
+
* full JSDoc type annotations (for `checkJs` + `.d.ts`) and explanatory comments
|
|
32
|
+
* so it stays readable; the browser downloads neither. This keeps the shipped
|
|
33
|
+
* runtime lean and the gzip budget honest (measured against what users receive),
|
|
34
|
+
* WITHOUT minifying — identifiers, structure, and whitespace are all preserved.
|
|
35
|
+
*
|
|
36
|
+
* Two passes, each conservative: `/** ... *\/` JSDoc blocks, then whole-line
|
|
37
|
+
* `//` comments (a line that is nothing but a comment). Trailing `//` after code
|
|
38
|
+
* and `//` inside strings/URLs are deliberately left untouched, so no string
|
|
39
|
+
* literal is ever harmed.
|
|
16
40
|
* @param {string} source
|
|
17
41
|
* @returns {string}
|
|
18
42
|
*/
|
|
19
43
|
export function stripRuntimeComments(source: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Render the Cloudflare Pages `_headers` file from the route manifest.
|
|
46
|
+
*
|
|
47
|
+
* Cloudflare applies every matching block in order; when blocks set the same
|
|
48
|
+
* header, the later block wins. So we emit the catch-all `/*` first (baseline
|
|
49
|
+
* security headers + the reactive CSP, which since 2.1 is eval-free and satisfies
|
|
50
|
+
* every page), then one block per static route that re-states the same eval-free
|
|
51
|
+
* CSP as defense-in-depth. Reactive routes need no override — the catch-all
|
|
52
|
+
* already carries the eval-free CSP.
|
|
53
|
+
*
|
|
54
|
+
* Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
|
|
55
|
+
* so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
|
|
56
|
+
* @param {RouteManifestEntry[]} manifest
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function renderCloudflareHeaders(manifest: RouteManifestEntry[]): string;
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {import("./config.js").Paths} Paths
|
|
62
|
+
* @typedef {import("./compiler.js").Assets} Assets
|
|
63
|
+
* @typedef {import("./compiler.js").Meta} Meta
|
|
64
|
+
* @typedef {import("./router.js").Route} Route
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* A built route entry written to `dist/routes.json`.
|
|
68
|
+
* @typedef {object} RouteManifestEntry
|
|
69
|
+
* @property {string} route Public route path.
|
|
70
|
+
* @property {string} file Source file path, relative to cwd, POSIX-separated.
|
|
71
|
+
* @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* One source route's record in the dev dependency map (`dist/.wd-dev-deps.json`),
|
|
75
|
+
* written by dev builds so a later `--changed` build can rebuild only the routes
|
|
76
|
+
* whose dependency graph contains the changed file.
|
|
77
|
+
* @typedef {object} DepMapEntry
|
|
78
|
+
* @property {string} file Source file, project-relative, POSIX-separated.
|
|
79
|
+
* @property {string[]} deps Every source file this route's compile read (the page
|
|
80
|
+
* itself, includes, loop JSON data files, colocated `.skin`/`.js`) —
|
|
81
|
+
* project-relative, POSIX-separated, sorted.
|
|
82
|
+
* @property {string[]} collections Collection names the route loops, so a change
|
|
83
|
+
* to any entry of the collection rebuilds this route.
|
|
84
|
+
* @property {RouteManifestEntry[]} pages The manifest entries this route emitted
|
|
85
|
+
* (one, or one per pagination page), so `routes.json`/`_headers`/the sitemap
|
|
86
|
+
* can be reassembled globally without recompiling untouched routes.
|
|
87
|
+
*/
|
|
88
|
+
/** The dev dependency map's filename under `dist/`. */
|
|
89
|
+
export const DEP_MAP_FILE: ".wd-dev-deps.json";
|
|
90
|
+
/**
|
|
91
|
+
* The value `buildSite` returns. `incremental` is present only when a `changed`
|
|
92
|
+
* build actually rebuilt a subset of routes (absent on every full rebuild).
|
|
93
|
+
*/
|
|
94
|
+
export type BuildResult = {
|
|
95
|
+
routes: RouteManifestEntry[];
|
|
96
|
+
distRoot: string;
|
|
97
|
+
feeds: {
|
|
98
|
+
sitemap: number | null;
|
|
99
|
+
rss: number | null;
|
|
100
|
+
};
|
|
101
|
+
incremental?: {
|
|
102
|
+
changed: string[];
|
|
103
|
+
rebuilt: string[];
|
|
104
|
+
total: number;
|
|
105
|
+
} | undefined;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* A compiled page bound for a concrete output route. Mirrors `CompiledPage` plus
|
|
109
|
+
* the public `route` it's written to — for a paginated listing, page 1 keeps the
|
|
110
|
+
* listing route and pages 2+ get `/<route>/page/<n>/`.
|
|
111
|
+
*/
|
|
112
|
+
export type EmittedPage = {
|
|
113
|
+
/**
|
|
114
|
+
* Public route path this page is written to.
|
|
115
|
+
*/
|
|
116
|
+
route: string;
|
|
117
|
+
html: string;
|
|
118
|
+
assets: import("./compiler.js").Assets;
|
|
119
|
+
warnings: string[];
|
|
120
|
+
};
|
|
20
121
|
export type Paths = import("./config.js").Paths;
|
|
21
122
|
export type Assets = import("./compiler.js").Assets;
|
|
123
|
+
export type Meta = import("./compiler.js").Meta;
|
|
124
|
+
export type Route = import("./router.js").Route;
|
|
22
125
|
/**
|
|
23
126
|
* A built route entry written to `dist/routes.json`.
|
|
24
127
|
*/
|
|
@@ -37,3 +140,31 @@ export type RouteManifestEntry = {
|
|
|
37
140
|
runtime: boolean;
|
|
38
141
|
};
|
|
39
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* One source route's record in the dev dependency map (`dist/.wd-dev-deps.json`),
|
|
145
|
+
* written by dev builds so a later `--changed` build can rebuild only the routes
|
|
146
|
+
* whose dependency graph contains the changed file.
|
|
147
|
+
*/
|
|
148
|
+
export type DepMapEntry = {
|
|
149
|
+
/**
|
|
150
|
+
* Source file, project-relative, POSIX-separated.
|
|
151
|
+
*/
|
|
152
|
+
file: string;
|
|
153
|
+
/**
|
|
154
|
+
* Every source file this route's compile read (the page
|
|
155
|
+
* itself, includes, loop JSON data files, colocated `.skin`/`.js`) —
|
|
156
|
+
* project-relative, POSIX-separated, sorted.
|
|
157
|
+
*/
|
|
158
|
+
deps: string[];
|
|
159
|
+
/**
|
|
160
|
+
* Collection names the route loops, so a change
|
|
161
|
+
* to any entry of the collection rebuilds this route.
|
|
162
|
+
*/
|
|
163
|
+
collections: string[];
|
|
164
|
+
/**
|
|
165
|
+
* The manifest entries this route emitted
|
|
166
|
+
* (one, or one per pagination page), so `routes.json`/`_headers`/the sitemap
|
|
167
|
+
* can be reassembled globally without recompiling untouched routes.
|
|
168
|
+
*/
|
|
169
|
+
pages: RouteManifestEntry[];
|
|
170
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structured, machine-readable catalog of the whole `.wd` authoring surface.
|
|
3
|
+
* @returns {DirectiveCatalog}
|
|
4
|
+
*/
|
|
5
|
+
export function directiveCatalog(): DirectiveCatalog;
|
|
6
|
+
/**
|
|
7
|
+
* Render the catalog as a compact llms.txt-style markdown cheatsheet — the
|
|
8
|
+
* artifact an app pastes into a small model's system prompt. Generated entirely
|
|
9
|
+
* from {@link directiveCatalog}, so it can never disagree with what compiles.
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function llmsText(): string;
|
|
13
|
+
/** The action tokens the catalog exposes — used by the drift guard. */
|
|
14
|
+
export const CATALOG_ACTION_TOKENS: string[];
|
|
15
|
+
/**
|
|
16
|
+
* Whether the directive
|
|
17
|
+
* ships zero JS (static), needs the reactive runtime (reactive), or can be
|
|
18
|
+
* either depending on whether it reads `:state`/`:store` (either).
|
|
19
|
+
*/
|
|
20
|
+
export type Reactivity = "static" | "reactive" | "either";
|
|
21
|
+
export type DirectiveEntry = {
|
|
22
|
+
/**
|
|
23
|
+
* The directive token (`@loop`, `:state`, `:::`, …).
|
|
24
|
+
*/
|
|
25
|
+
name: string;
|
|
26
|
+
/**
|
|
27
|
+
* A single line, or an opener with a closer.
|
|
28
|
+
*/
|
|
29
|
+
kind: "line" | "block";
|
|
30
|
+
/**
|
|
31
|
+
* One-line schematic (`[…]` = optional, `<…>` = slot).
|
|
32
|
+
*/
|
|
33
|
+
syntax: string;
|
|
34
|
+
/**
|
|
35
|
+
* One-line, plain-language summary.
|
|
36
|
+
*/
|
|
37
|
+
description: string;
|
|
38
|
+
/**
|
|
39
|
+
* One concrete, compilable line.
|
|
40
|
+
*/
|
|
41
|
+
example: string;
|
|
42
|
+
reactive: Reactivity;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A clause / action-op / pipe / operator entry.
|
|
46
|
+
*/
|
|
47
|
+
export type CatalogEntry = {
|
|
48
|
+
name: string;
|
|
49
|
+
syntax?: string | undefined;
|
|
50
|
+
description: string;
|
|
51
|
+
example?: string | undefined;
|
|
52
|
+
};
|
|
53
|
+
export type DirectiveCatalog = {
|
|
54
|
+
/**
|
|
55
|
+
* The installed Darkmown version.
|
|
56
|
+
*/
|
|
57
|
+
version: string;
|
|
58
|
+
/**
|
|
59
|
+
* Always `.wd` — the file extension that gates directives.
|
|
60
|
+
*/
|
|
61
|
+
format: string;
|
|
62
|
+
directives: DirectiveEntry[];
|
|
63
|
+
/**
|
|
64
|
+
* Clauses of the `@loop` header (fixed order).
|
|
65
|
+
*/
|
|
66
|
+
loopClauses: CatalogEntry[];
|
|
67
|
+
/**
|
|
68
|
+
* Per-row meta variables valid in a loop body.
|
|
69
|
+
*/
|
|
70
|
+
loopVariables: CatalogEntry[];
|
|
71
|
+
/**
|
|
72
|
+
* The `:button`/`:effect`/`:every` action vocabulary.
|
|
73
|
+
*/
|
|
74
|
+
actionOps: CatalogEntry[];
|
|
75
|
+
/**
|
|
76
|
+
* The `{ value | pipe }` formatter whitelist.
|
|
77
|
+
*/
|
|
78
|
+
formatPipes: CatalogEntry[];
|
|
79
|
+
/**
|
|
80
|
+
* Comparison operators for `where`/`:if`/`when`.
|
|
81
|
+
*/
|
|
82
|
+
predicateOps: CatalogEntry[];
|
|
83
|
+
/**
|
|
84
|
+
* Logical joiners (`and`/`or`/`not`).
|
|
85
|
+
*/
|
|
86
|
+
predicateJoiners: string[];
|
|
87
|
+
/**
|
|
88
|
+
* The `WDxxx` code blocks.
|
|
89
|
+
*/
|
|
90
|
+
errorAreas: import("./errors.js").ErrorArea[];
|
|
91
|
+
/**
|
|
92
|
+
* Every stable compile-error
|
|
93
|
+
* code, with its cause and fix. A thrown error's message starts with its code
|
|
94
|
+
* (`[WD201] …`) and mirrors it on `err.wd.code`.
|
|
95
|
+
*/
|
|
96
|
+
errors: import("./errors.js").ErrorEntry[];
|
|
97
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} line
|
|
3
|
+
* @param {Ctx} ctx
|
|
4
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function handleButton(line: string, ctx: Ctx, index: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
10
|
+
* watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
|
|
11
|
+
* action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
|
|
12
|
+
* (derive state) and fetch deps (auto-refetch).
|
|
13
|
+
* @param {string} line
|
|
14
|
+
* @param {Ctx} ctx
|
|
15
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function handleEffect(line: string, ctx: Ctx, index: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Parse `:every <duration> -> <actions>` into a marker the runtime drives on a
|
|
21
|
+
* timer: every `<duration>` it runs `<actions>` (the same `:button` vocabulary,
|
|
22
|
+
* `;`-chained), and the interval auto-pauses while the tab is hidden. The one time
|
|
23
|
+
* primitive — behind live polling (`:every 5s -> board refetch`), clocks and
|
|
24
|
+
* countdowns (`:every 1s -> seconds++`), and slideshow autoplay (`:every 4s -> slide++`).
|
|
25
|
+
* @param {string} line
|
|
26
|
+
* @param {Ctx} ctx
|
|
27
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function handleEvery(line: string, ctx: Ctx, index: number): string;
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
33
|
+
* @typedef {import("./context.js").Action} Action
|
|
34
|
+
*/
|
|
35
|
+
export const BUTTON_EXAMPLE: ":button \"Add one\" -> count++";
|
|
36
|
+
export const EFFECT_EXAMPLE: ":effect query -> searches++";
|
|
37
|
+
export const EVERY_EXAMPLE: ":every 5s -> seconds++";
|
|
38
|
+
export const ACTION_EXAMPLE: "count++";
|
|
39
|
+
export const ACTION_USE: "Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with \";\" — e.g. count++";
|
|
40
|
+
export type Ctx = import("./context.js").Ctx;
|
|
41
|
+
export type Action = import("./context.js").Action;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Parse a `.wd` body line-by-line into HTML, mixing directives and prose.
|
|
6
|
+
* @param {string[]} lines
|
|
7
|
+
* @param {Ctx} ctx
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function compileBody(lines: string[], ctx: Ctx): string;
|
|
11
|
+
export type Ctx = import("./context.js").Ctx;
|