@zvndev/darkmown 0.8.1 → 0.9.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/src/config.js CHANGED
@@ -5,6 +5,20 @@ export const shelfDir = "site/_";
5
5
  export const distDir = "dist";
6
6
  export const pageExtensions = new Set([".md", ".wd"]);
7
7
 
8
+ /**
9
+ * Resolved project paths used across the build pipeline.
10
+ * @typedef {object} Paths
11
+ * @property {string} cwd Project working directory.
12
+ * @property {string} routesRoot Absolute path to `site/pages`.
13
+ * @property {string} shelfRoot Absolute path to the include shelf `site/_`.
14
+ * @property {string} distRoot Absolute path to the build output `dist`.
15
+ */
16
+
17
+ /**
18
+ * Build the absolute project paths for a given working directory.
19
+ * @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
20
+ * @returns {Paths}
21
+ */
8
22
  export function createPaths(cwd = process.cwd()) {
9
23
  return {
10
24
  cwd,
@@ -14,6 +28,11 @@ export function createPaths(cwd = process.cwd()) {
14
28
  };
15
29
  }
16
30
 
31
+ /**
32
+ * Whether a file or directory name is hidden from routing (starts with `.`, `-`, or `_`).
33
+ * @param {string} name
34
+ * @returns {boolean}
35
+ */
17
36
  export function isHiddenName(name) {
18
37
  return name.startsWith(".") || name.startsWith("-") || name.startsWith("_");
19
38
  }
package/src/dev.js CHANGED
@@ -1,11 +1,20 @@
1
1
  export const devClientPath = "/__wd/dev-client.js";
2
2
  export const devEventsPath = "/__wd/dev-events";
3
3
 
4
+ /**
5
+ * Inject the dev-client `<script>` before `</body>` (or append it if absent).
6
+ * @param {string} html Page HTML to augment.
7
+ * @returns {string}
8
+ */
4
9
  export function injectDevClient(html) {
5
10
  const script = `<script type="module" src="${devClientPath}"></script>`;
6
11
  return html.includes("</body>") ? html.replace("</body>", `${script}\n</body>`) : `${html}\n${script}`;
7
12
  }
8
13
 
14
+ /**
15
+ * The dev-client browser script that opens the SSE channel for live reload.
16
+ * @returns {string}
17
+ */
9
18
  export function devClientScript() {
10
19
  return `
11
20
  const source = new EventSource("${devEventsPath}");
package/src/router.js CHANGED
@@ -2,10 +2,24 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isHiddenName, pageExtensions } from "./config.js";
4
4
 
5
+ /**
6
+ * A discovered page route: the source file and its public route path.
7
+ * @typedef {object} Route
8
+ * @property {string} file Absolute path to the source `.md`/`.wd` file.
9
+ * @property {string} route Public route path (always trailing-slashed, e.g. `/about/`).
10
+ */
11
+
12
+ /**
13
+ * Walk `routesRoot` and collect page routes, sorted and de-duplicated.
14
+ * @param {string} routesRoot Absolute path to `site/pages`.
15
+ * @returns {Route[]}
16
+ */
5
17
  export function discoverRoutes(routesRoot) {
6
18
  if (!fs.existsSync(routesRoot)) return [];
19
+ /** @type {Route[]} */
7
20
  const routes = [];
8
21
 
22
+ /** @param {string} dir */
9
23
  function walk(dir) {
10
24
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
11
25
  if (isHiddenName(entry.name)) continue;
@@ -36,6 +50,12 @@ export function discoverRoutes(routesRoot) {
36
50
  return routes.sort((a, b) => a.route.localeCompare(b.route));
37
51
  }
38
52
 
53
+ /**
54
+ * Derive a public route path from a source file path.
55
+ * @param {string} routesRoot Absolute path to `site/pages`.
56
+ * @param {string} file Absolute path to the source file.
57
+ * @returns {string}
58
+ */
39
59
  export function routeFromFile(routesRoot, file) {
40
60
  const ext = path.extname(file);
41
61
  const rel = path.relative(routesRoot, file).slice(0, -ext.length);
@@ -45,6 +65,12 @@ export function routeFromFile(routesRoot, file) {
45
65
  return route === "/" ? "/" : `${route}/`;
46
66
  }
47
67
 
68
+ /**
69
+ * Map a public route to the `index.html` output path under `distRoot`.
70
+ * @param {string} distRoot Absolute path to the build output directory.
71
+ * @param {string} route Public route path.
72
+ * @returns {string}
73
+ */
48
74
  export function outputPathForRoute(distRoot, route) {
49
75
  const clean = route === "/" ? "" : route.replace(/^\/|\/$/g, "");
50
76
  return path.join(distRoot, clean, "index.html");
package/src/runtime.js CHANGED
@@ -1,4 +1,18 @@
1
+ /**
2
+ * Darkmown's tiny reactive runtime. Browser-only: it reads `data-wd-*` attributes
3
+ * emitted by the compiler, hydrates declared state, and re-renders on changes.
4
+ * @module runtime
5
+ */
6
+
7
+ /**
8
+ * A reconciled loop-row element. Carries its source row on `__wdItem` so per-row
9
+ * button actions can resolve which row was clicked.
10
+ * @typedef {Element & { __wdItem?: any }} WdRow
11
+ */
12
+
13
+ /** @type {Record<string, any>} */
1
14
  const state = {};
15
+ /** @type {Set<string>} */
2
16
  const persistKeys = new Set();
3
17
 
4
18
  for (const script of document.querySelectorAll("script[data-wd-state]")) {
@@ -23,6 +37,12 @@ function savePersisted() {
23
37
  }
24
38
  }
25
39
 
40
+ /**
41
+ * Safely read a dotted path off a value, rejecting prototype-pollution segments.
42
+ * @param {any} value
43
+ * @param {string | null} path
44
+ * @returns {any}
45
+ */
26
46
  function getPath(value, path) {
27
47
  if (!path) return value;
28
48
  let current = value;
@@ -35,23 +55,47 @@ function getPath(value, path) {
35
55
  }
36
56
 
37
57
  const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
38
- key: node.getAttribute("data-wd-computed-key"),
58
+ key: node.getAttribute("data-wd-computed-key") || "",
59
+ expr: node.getAttribute("data-wd-computed-expr") || "",
39
60
  evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
40
61
  }));
41
62
 
63
+ /**
64
+ * Log an expression failure when `window.wd.debug` is enabled.
65
+ * @param {string} expr
66
+ * @param {unknown} error
67
+ * @returns {void}
68
+ */
69
+ function warn(expr, error) {
70
+ const wd = /** @type {any} */ (window).wd;
71
+ if (wd && wd.debug) console.warn(`[wd] expression failed: ${expr}`, error);
72
+ }
73
+
74
+ /**
75
+ * Re-evaluate all `:computed` definitions into `state`.
76
+ * @returns {void}
77
+ */
42
78
  function recompute() {
43
- const read = (key, path) => getPath(state[key], path);
79
+ /** @param {string} key @param {string | null} [path] */
80
+ const read = (key, path) => getPath(state[key], path ?? null);
44
81
  for (const def of computedDefs) {
45
82
  try {
46
83
  state[def.key] = def.evaluate(read);
47
- } catch {
84
+ } catch (error) {
48
85
  state[def.key] = undefined;
86
+ warn(def.expr, error);
49
87
  }
50
88
  }
51
89
  }
52
90
 
91
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
53
92
  const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
93
+ /** @type {Map<string, Function>} */
54
94
  const predicateCache = new Map();
95
+ /**
96
+ * @param {string} body
97
+ * @returns {Function}
98
+ */
55
99
  function loopPredicate(body) {
56
100
  let fn = predicateCache.get(body);
57
101
  if (!fn) {
@@ -61,6 +105,12 @@ function loopPredicate(body) {
61
105
  return fn;
62
106
  }
63
107
 
108
+ /**
109
+ * Stable per-render key for a loop row, disambiguating duplicates with `#n`.
110
+ * @param {any} item
111
+ * @param {Map<string, number>} counts
112
+ * @returns {string}
113
+ */
64
114
  function loopKeyOf(item, counts) {
65
115
  const base =
66
116
  item && typeof item === "object"
@@ -71,13 +121,20 @@ function loopKeyOf(item, counts) {
71
121
  return seen ? `${base}#${seen}` : base;
72
122
  }
73
123
 
124
+ /**
125
+ * Fill a cloned loop row node: resolve per-item `:if` regions and text binds.
126
+ * @param {Element} node
127
+ * @param {any} item
128
+ * @returns {void}
129
+ */
74
130
  function fillItem(node, item) {
75
131
  // querySelectorAll does not descend into <template> content, so this only sees
76
132
  // the outermost regions; recursing into each injected branch fills the rest.
77
133
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
78
134
  const value = getPath(item, region.getAttribute("data-wd-path"));
79
135
  const output = region.querySelector("[data-wd-each-if-out]");
80
- const template = region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]");
136
+ if (!output) continue;
137
+ const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
81
138
  output.innerHTML = template?.innerHTML || "";
82
139
  fillItem(output, item);
83
140
  }
@@ -89,15 +146,21 @@ function fillItem(node, item) {
89
146
  }
90
147
  }
91
148
 
92
- function render() {
149
+ /**
150
+ * Synchronously render the whole document from current `state`:
151
+ * computed → if-regions → keyed loop reconcile → text/input binds.
152
+ * @returns {void}
153
+ */
154
+ function renderNow() {
93
155
  recompute();
94
156
  for (const node of document.querySelectorAll("[data-wd-if]")) {
95
- const value = getPath(state[node.getAttribute("data-wd-if")], node.getAttribute("data-wd-path"));
157
+ const value = getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
96
158
  const active = String(Boolean(value));
97
159
  if (node.getAttribute("data-wd-if-active") === active) continue;
98
160
  node.setAttribute("data-wd-if-active", active);
99
161
  const output = node.querySelector("[data-wd-if-out]");
100
- const template = node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]");
162
+ if (!output) continue;
163
+ const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
101
164
  output.innerHTML = template?.innerHTML || "";
102
165
  }
103
166
 
@@ -105,29 +168,38 @@ function render() {
105
168
  const key = region.getAttribute("data-wd-loop");
106
169
  const data = region.getAttribute("data-wd-loop-data");
107
170
  const rows = key ? state[key] : (data ? JSON.parse(data) : []);
108
- const template = region.querySelector("template[data-wd-loop-template]");
171
+ const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
109
172
  const out = region.querySelector("[data-wd-loop-out]");
110
173
  if (!template || !out) continue;
111
174
  let list = Array.isArray(rows) ? rows : [];
112
175
  const where = region.getAttribute("data-wd-loop-where");
113
176
  if (where) {
114
177
  const predicate = loopPredicate(where);
115
- list = list.filter((item) =>
116
- predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn)
117
- );
178
+ list = list.filter((/** @type {any} */ item) => {
179
+ try {
180
+ return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
181
+ } catch (error) {
182
+ warn(where, error);
183
+ return false;
184
+ }
185
+ });
118
186
  }
119
187
 
188
+ /** @type {Map<string, WdRow>} */
120
189
  const existing = new Map();
121
190
  for (const child of [...out.children]) {
122
- existing.set(child.getAttribute("data-wd-loop-key"), child);
191
+ existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
123
192
  }
193
+ /** @type {Map<string, number>} */
124
194
  const counts = new Map();
195
+ /** @type {Set<string>} */
125
196
  const used = new Set();
126
197
  for (const item of list) {
127
198
  const key = loopKeyOf(item, counts);
128
199
  let node = existing.get(key);
129
200
  if (!node || used.has(key)) {
130
- node = template.content.firstElementChild.cloneNode(true);
201
+ node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
202
+ if (!node) continue;
131
203
  node.setAttribute("data-wd-loop-key", key);
132
204
  }
133
205
  used.add(key);
@@ -141,35 +213,53 @@ function render() {
141
213
  }
142
214
 
143
215
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
144
- node.textContent = getPath(state[node.getAttribute("data-wd-bind")], node.getAttribute("data-wd-path")) ?? "";
216
+ node.textContent = getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path")) ?? "";
145
217
  }
146
218
 
147
219
  for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
148
- if (document.activeElement !== input) input.value = state[input.getAttribute("data-wd-bind-input")] ?? "";
220
+ if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
149
221
  }
150
222
  }
151
223
 
224
+ // Coalesce rapid state mutations into one render on the next tick. Each scheduled
225
+ // pass always reads the latest state, so the final update is never dropped.
226
+ const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask;
227
+ let scheduled = false;
228
+ function render() {
229
+ if (scheduled) return;
230
+ scheduled = true;
231
+ schedule(() => {
232
+ scheduled = false;
233
+ renderNow();
234
+ });
235
+ }
236
+
152
237
  document.addEventListener("input", (event) => {
153
- const input = event.target.closest("[data-wd-bind-input]");
238
+ const input = /** @type {HTMLInputElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-bind-input]"));
154
239
  if (!input) return;
155
- state[input.getAttribute("data-wd-bind-input")] = input.value;
240
+ state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
156
241
  savePersisted();
157
242
  render();
158
243
  });
159
244
 
160
245
  // The clicked button's row: the nearest reconciled loop node carries its item.
246
+ /**
247
+ * Resolve the loop source and row item for a clicked element, if inside a loop.
248
+ * @param {Element} el
249
+ * @returns {{ srcKey: string | null, item: any } | null}
250
+ */
161
251
  function clickedRow(el) {
162
- const row = el.closest("[data-wd-loop-key]");
252
+ const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
163
253
  const region = el.closest("[data-wd-loop]");
164
254
  if (!row || !region) return null;
165
255
  return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
166
256
  }
167
257
 
168
258
  document.addEventListener("click", (event) => {
169
- const action = event.target.closest("[data-wd-action]");
259
+ const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action]");
170
260
  if (!action) return;
171
261
  const op = action.getAttribute("data-wd-action");
172
- const target = action.getAttribute("data-wd-target");
262
+ const target = action.getAttribute("data-wd-target") || "";
173
263
  const rawValue = action.getAttribute("data-wd-value");
174
264
  const value = rawValue === null ? undefined : JSON.parse(rawValue);
175
265
 
@@ -180,7 +270,7 @@ document.addEventListener("click", (event) => {
180
270
  if (op === "set") state[target] = value;
181
271
  if (op === "remove") {
182
272
  const row = clickedRow(action);
183
- if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((x) => x !== row.item);
273
+ if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((/** @type {any} */ x) => x !== row.item);
184
274
  }
185
275
  if (op === "append-row") {
186
276
  const row = clickedRow(action);
@@ -197,10 +287,10 @@ document.addEventListener("click", (event) => {
197
287
  });
198
288
 
199
289
  document.addEventListener("submit", (event) => {
200
- const form = event.target.closest("[data-wd-form]");
290
+ const form = /** @type {HTMLFormElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-form]"));
201
291
  if (!form) return;
202
292
  event.preventDefault();
203
- const key = form.getAttribute("data-wd-form");
293
+ const key = form.getAttribute("data-wd-form") || "";
204
294
  const action = form.getAttribute("action");
205
295
 
206
296
  if (!action) {
@@ -213,7 +303,7 @@ document.addEventListener("submit", (event) => {
213
303
  fetch(action, {
214
304
  method: form.getAttribute("method") || "post",
215
305
  headers: { "content-type": "application/x-www-form-urlencoded" },
216
- body: new URLSearchParams(new FormData(form)).toString()
306
+ body: new URLSearchParams(/** @type {any} */ (new FormData(form))).toString()
217
307
  })
218
308
  .then(async (response) => {
219
309
  const text = await response.text();
@@ -235,9 +325,14 @@ document.addEventListener("submit", (event) => {
235
325
  });
236
326
  });
237
327
 
328
+ /**
329
+ * Kick off a `:fetch` request and write the result (or error) into state.
330
+ * @param {Element} node
331
+ * @returns {void}
332
+ */
238
333
  function startFetch(node) {
239
- const key = node.getAttribute("data-wd-fetch-key");
240
- fetch(node.getAttribute("data-wd-fetch-url"))
334
+ const key = node.getAttribute("data-wd-fetch-key") || "";
335
+ fetch(node.getAttribute("data-wd-fetch-url") || "")
241
336
  .then((response) => {
242
337
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
243
338
  return response.json();
@@ -266,15 +361,20 @@ for (const node of document.querySelectorAll("[data-wd-fetch]")) {
266
361
  }
267
362
  }
268
363
 
269
- window.wd = {
364
+ // Public escape hatch on `window.wd`. Set `window.wd.debug = true` to log failing
365
+ // computed/where expressions to the console.
366
+ /** @type {any} */ (window).wd = {
270
367
  state,
368
+ debug: false,
369
+ /** @param {string} key */
271
370
  get: (key) => state[key],
371
+ /** @param {string} key @param {any} value */
272
372
  set: (key, value) => {
273
373
  state[key] = value;
274
374
  savePersisted();
275
375
  render();
276
376
  },
277
- render
377
+ render: renderNow
278
378
  };
279
379
 
280
- render();
380
+ renderNow();
package/src/scaffold.js CHANGED
@@ -5,6 +5,11 @@ const darkmownVersion = JSON.parse(
5
5
  fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")
6
6
  ).version;
7
7
 
8
+ /**
9
+ * Scaffold a new Darkmown project into `root`, creating files that don't exist.
10
+ * @param {string} root Absolute path to the target project directory.
11
+ * @returns {{ root: string }}
12
+ */
8
13
  export function initProject(root) {
9
14
  fs.mkdirSync(root, { recursive: true });
10
15
  writeNew(root, "package.json", JSON.stringify({
@@ -128,6 +133,13 @@ export function initProject(root) {
128
133
  return { root };
129
134
  }
130
135
 
136
+ /**
137
+ * Write a file relative to `root` only if it does not already exist.
138
+ * @param {string} root Project root directory.
139
+ * @param {string} file Path relative to `root`.
140
+ * @param {string} content File contents (a trailing newline is added).
141
+ * @returns {void}
142
+ */
131
143
  function writeNew(root, file, content) {
132
144
  const target = path.join(root, file);
133
145
  if (fs.existsSync(target)) return;
package/src/skin.js CHANGED
@@ -8,11 +8,29 @@ const aliases = new Map([
8
8
  // `font` is aliased to font-family for the common `font <stack>` case, but the
9
9
  // real CSS `font` shorthand (`font 16px/1.4 system-ui`) must pass through. If
10
10
  // the value looks like a shorthand (leads with a size or contains `/`), keep it.
11
+ /**
12
+ * @param {string} prop
13
+ * @param {string[]} rest
14
+ * @returns {string}
15
+ */
11
16
  function resolveProp(prop, rest) {
12
17
  if (prop === "font" && (/^[\d.]/.test(rest[0] || "") || rest.join(" ").includes("/"))) return "font";
13
18
  return aliases.get(prop) || prop;
14
19
  }
15
20
 
21
+ /**
22
+ * A nesting frame in the indentation stack.
23
+ * @typedef {object} SkinFrame
24
+ * @property {number} indent
25
+ * @property {string | null} selector
26
+ * @property {string} [media]
27
+ */
28
+
29
+ /**
30
+ * Compile indentation-based `.skin` source into CSS.
31
+ * @param {string} source
32
+ * @returns {string}
33
+ */
16
34
  export function compileSkin(source) {
17
35
  const lines = source
18
36
  .replace(/\r\n?/g, "\n")
@@ -26,11 +44,15 @@ export function compileSkin(source) {
26
44
  // digit is real content; punctuation-only lines are skipped.
27
45
  return /[A-Za-z0-9]/.test(t);
28
46
  })
29
- .map((raw) => ({ indent: raw.match(/^\s*/)[0].length, text: raw.trim() }));
47
+ .map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
30
48
 
49
+ /** @type {SkinFrame[]} */
31
50
  const stack = [];
51
+ /** @type {string[]} */
32
52
  const css = [];
53
+ /** @type {number | null} */
33
54
  let tokenIndent = null;
55
+ /** @type {string[]} */
34
56
  const rootVars = [];
35
57
 
36
58
  for (let i = 0; i < lines.length; i++) {
@@ -51,7 +73,7 @@ export function compileSkin(source) {
51
73
  }
52
74
 
53
75
  if (tokenIndent !== null && indent <= tokenIndent) tokenIndent = null;
54
- while (stack.length && stack.at(-1).indent >= indent) stack.pop();
76
+ while (stack.length && (stack.at(-1)?.indent ?? -1) >= indent) stack.pop();
55
77
 
56
78
  if (opensBlock) {
57
79
  const parent = stack.at(-1)?.selector;
@@ -78,12 +100,18 @@ export function compileSkin(source) {
78
100
  return css.join("\n");
79
101
  }
80
102
 
103
+ /**
104
+ * Combine a selector with its parent (handles `&` nesting and comma lists).
105
+ * @param {string} text
106
+ * @param {string | null | undefined} parent
107
+ * @returns {string}
108
+ */
81
109
  function normalizeSelector(text, parent) {
82
110
  const selector = text === "page" ? "body" : text;
83
111
  if (!parent || parent === ":root") return selector;
84
112
  return selector
85
113
  .split(",")
86
- .map((part) => {
114
+ .map((/** @type {string} */ part) => {
87
115
  const clean = part.trim();
88
116
  return clean.startsWith("&") ? clean.replace("&", parent) : `${parent} ${clean}`;
89
117
  })
package/src/statics.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import fs from "node:fs";
2
+ import http from "node:http";
2
3
  import path from "node:path";
3
4
 
5
+ /**
6
+ * Serve a built file for a request URL out of `distRoot`, or a 404 page.
7
+ * @param {string} distRoot Absolute path to the build output directory.
8
+ * @param {string} url Request URL (may include a query string).
9
+ * @param {http.ServerResponse} res Response to write to.
10
+ * @returns {void}
11
+ */
4
12
  export function serve(distRoot, url, res) {
5
13
  const file = resolvePublicFile(distRoot, url || "/");
6
14
  if (!file || !fs.existsSync(file)) {
@@ -12,6 +20,13 @@ export function serve(distRoot, url, res) {
12
20
  fs.createReadStream(file).pipe(res);
13
21
  }
14
22
 
23
+ /**
24
+ * Resolve a request URL to an absolute file inside `distRoot`, guarding against
25
+ * path traversal. Returns null when the resolved path escapes the root.
26
+ * @param {string} distRoot Absolute path to the build output directory.
27
+ * @param {string} url Request URL (may include a query string).
28
+ * @returns {string | null}
29
+ */
15
30
  export function resolvePublicFile(distRoot, url) {
16
31
  const cleanUrl = decodeURIComponent(url.split("?")[0]);
17
32
  const base = cleanUrl.startsWith("/__wd/")
@@ -23,6 +38,11 @@ export function resolvePublicFile(distRoot, url) {
23
38
  return requested;
24
39
  }
25
40
 
41
+ /**
42
+ * Map a file path to a response content-type, defaulting to `text/html`.
43
+ * @param {string} file File path or name.
44
+ * @returns {string}
45
+ */
26
46
  export function contentType(file) {
27
47
  if (file.endsWith(".css")) return "text/css";
28
48
  if (file.endsWith(".js")) return "text/javascript";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Compile the whole site under `cwd` into `dist`, returning the route manifest.
3
+ * @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
4
+ * @returns {{ routes: RouteManifestEntry[], distRoot: string }}
5
+ */
6
+ export function buildSite(cwd?: string): {
7
+ routes: RouteManifestEntry[];
8
+ distRoot: string;
9
+ };
10
+ /**
11
+ * Strip JSDoc `/** ... *\/` blocks from the runtime before it ships.
12
+ * The source keeps full type annotations (for `checkJs` + `.d.ts`), but the
13
+ * browser never needs to download them — this keeps the shipped runtime lean
14
+ * and the gzip budget honest (measured against what users actually receive).
15
+ * Only `/** ... *\/` blocks are removed; code and string literals are untouched.
16
+ * @param {string} source
17
+ * @returns {string}
18
+ */
19
+ export function stripRuntimeComments(source: string): string;
20
+ export type Paths = import("./config.js").Paths;
21
+ export type Assets = import("./compiler.js").Assets;
22
+ /**
23
+ * A built route entry written to `dist/routes.json`.
24
+ */
25
+ export type RouteManifestEntry = {
26
+ /**
27
+ * Public route path.
28
+ */
29
+ route: string;
30
+ /**
31
+ * Source file path, relative to cwd, POSIX-separated.
32
+ */
33
+ file: string;
34
+ assets: {
35
+ skins: string[];
36
+ scripts: string[];
37
+ runtime: boolean;
38
+ };
39
+ };