@zvndev/darkmown 0.8.2 → 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,17 +55,29 @@ 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"),
39
- expr: node.getAttribute("data-wd-computed-expr"),
58
+ key: node.getAttribute("data-wd-computed-key") || "",
59
+ expr: node.getAttribute("data-wd-computed-expr") || "",
40
60
  evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
41
61
  }));
42
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
+ */
43
69
  function warn(expr, error) {
44
- if (window.wd && window.wd.debug) console.warn(`[wd] expression failed: ${expr}`, error);
70
+ const wd = /** @type {any} */ (window).wd;
71
+ if (wd && wd.debug) console.warn(`[wd] expression failed: ${expr}`, error);
45
72
  }
46
73
 
74
+ /**
75
+ * Re-evaluate all `:computed` definitions into `state`.
76
+ * @returns {void}
77
+ */
47
78
  function recompute() {
48
- 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);
49
81
  for (const def of computedDefs) {
50
82
  try {
51
83
  state[def.key] = def.evaluate(read);
@@ -56,8 +88,14 @@ function recompute() {
56
88
  }
57
89
  }
58
90
 
91
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
59
92
  const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
93
+ /** @type {Map<string, Function>} */
60
94
  const predicateCache = new Map();
95
+ /**
96
+ * @param {string} body
97
+ * @returns {Function}
98
+ */
61
99
  function loopPredicate(body) {
62
100
  let fn = predicateCache.get(body);
63
101
  if (!fn) {
@@ -67,6 +105,12 @@ function loopPredicate(body) {
67
105
  return fn;
68
106
  }
69
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
+ */
70
114
  function loopKeyOf(item, counts) {
71
115
  const base =
72
116
  item && typeof item === "object"
@@ -77,13 +121,20 @@ function loopKeyOf(item, counts) {
77
121
  return seen ? `${base}#${seen}` : base;
78
122
  }
79
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
+ */
80
130
  function fillItem(node, item) {
81
131
  // querySelectorAll does not descend into <template> content, so this only sees
82
132
  // the outermost regions; recursing into each injected branch fills the rest.
83
133
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
84
134
  const value = getPath(item, region.getAttribute("data-wd-path"));
85
135
  const output = region.querySelector("[data-wd-each-if-out]");
86
- 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]"));
87
138
  output.innerHTML = template?.innerHTML || "";
88
139
  fillItem(output, item);
89
140
  }
@@ -95,15 +146,21 @@ function fillItem(node, item) {
95
146
  }
96
147
  }
97
148
 
149
+ /**
150
+ * Synchronously render the whole document from current `state`:
151
+ * computed → if-regions → keyed loop reconcile → text/input binds.
152
+ * @returns {void}
153
+ */
98
154
  function renderNow() {
99
155
  recompute();
100
156
  for (const node of document.querySelectorAll("[data-wd-if]")) {
101
- 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"));
102
158
  const active = String(Boolean(value));
103
159
  if (node.getAttribute("data-wd-if-active") === active) continue;
104
160
  node.setAttribute("data-wd-if-active", active);
105
161
  const output = node.querySelector("[data-wd-if-out]");
106
- 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]"));
107
164
  output.innerHTML = template?.innerHTML || "";
108
165
  }
109
166
 
@@ -111,16 +168,16 @@ function renderNow() {
111
168
  const key = region.getAttribute("data-wd-loop");
112
169
  const data = region.getAttribute("data-wd-loop-data");
113
170
  const rows = key ? state[key] : (data ? JSON.parse(data) : []);
114
- const template = region.querySelector("template[data-wd-loop-template]");
171
+ const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
115
172
  const out = region.querySelector("[data-wd-loop-out]");
116
173
  if (!template || !out) continue;
117
174
  let list = Array.isArray(rows) ? rows : [];
118
175
  const where = region.getAttribute("data-wd-loop-where");
119
176
  if (where) {
120
177
  const predicate = loopPredicate(where);
121
- list = list.filter((item) => {
178
+ list = list.filter((/** @type {any} */ item) => {
122
179
  try {
123
- return predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn);
180
+ return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
124
181
  } catch (error) {
125
182
  warn(where, error);
126
183
  return false;
@@ -128,17 +185,21 @@ function renderNow() {
128
185
  });
129
186
  }
130
187
 
188
+ /** @type {Map<string, WdRow>} */
131
189
  const existing = new Map();
132
190
  for (const child of [...out.children]) {
133
- existing.set(child.getAttribute("data-wd-loop-key"), child);
191
+ existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
134
192
  }
193
+ /** @type {Map<string, number>} */
135
194
  const counts = new Map();
195
+ /** @type {Set<string>} */
136
196
  const used = new Set();
137
197
  for (const item of list) {
138
198
  const key = loopKeyOf(item, counts);
139
199
  let node = existing.get(key);
140
200
  if (!node || used.has(key)) {
141
- node = template.content.firstElementChild.cloneNode(true);
201
+ node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
202
+ if (!node) continue;
142
203
  node.setAttribute("data-wd-loop-key", key);
143
204
  }
144
205
  used.add(key);
@@ -152,11 +213,11 @@ function renderNow() {
152
213
  }
153
214
 
154
215
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
155
- 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")) ?? "";
156
217
  }
157
218
 
158
219
  for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
159
- 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") || ""] ?? "";
160
221
  }
161
222
  }
162
223
 
@@ -174,26 +235,31 @@ function render() {
174
235
  }
175
236
 
176
237
  document.addEventListener("input", (event) => {
177
- const input = event.target.closest("[data-wd-bind-input]");
238
+ const input = /** @type {HTMLInputElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-bind-input]"));
178
239
  if (!input) return;
179
- state[input.getAttribute("data-wd-bind-input")] = input.value;
240
+ state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
180
241
  savePersisted();
181
242
  render();
182
243
  });
183
244
 
184
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
+ */
185
251
  function clickedRow(el) {
186
- const row = el.closest("[data-wd-loop-key]");
252
+ const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
187
253
  const region = el.closest("[data-wd-loop]");
188
254
  if (!row || !region) return null;
189
255
  return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
190
256
  }
191
257
 
192
258
  document.addEventListener("click", (event) => {
193
- const action = event.target.closest("[data-wd-action]");
259
+ const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action]");
194
260
  if (!action) return;
195
261
  const op = action.getAttribute("data-wd-action");
196
- const target = action.getAttribute("data-wd-target");
262
+ const target = action.getAttribute("data-wd-target") || "";
197
263
  const rawValue = action.getAttribute("data-wd-value");
198
264
  const value = rawValue === null ? undefined : JSON.parse(rawValue);
199
265
 
@@ -204,7 +270,7 @@ document.addEventListener("click", (event) => {
204
270
  if (op === "set") state[target] = value;
205
271
  if (op === "remove") {
206
272
  const row = clickedRow(action);
207
- 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);
208
274
  }
209
275
  if (op === "append-row") {
210
276
  const row = clickedRow(action);
@@ -221,10 +287,10 @@ document.addEventListener("click", (event) => {
221
287
  });
222
288
 
223
289
  document.addEventListener("submit", (event) => {
224
- const form = event.target.closest("[data-wd-form]");
290
+ const form = /** @type {HTMLFormElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-form]"));
225
291
  if (!form) return;
226
292
  event.preventDefault();
227
- const key = form.getAttribute("data-wd-form");
293
+ const key = form.getAttribute("data-wd-form") || "";
228
294
  const action = form.getAttribute("action");
229
295
 
230
296
  if (!action) {
@@ -237,7 +303,7 @@ document.addEventListener("submit", (event) => {
237
303
  fetch(action, {
238
304
  method: form.getAttribute("method") || "post",
239
305
  headers: { "content-type": "application/x-www-form-urlencoded" },
240
- body: new URLSearchParams(new FormData(form)).toString()
306
+ body: new URLSearchParams(/** @type {any} */ (new FormData(form))).toString()
241
307
  })
242
308
  .then(async (response) => {
243
309
  const text = await response.text();
@@ -259,9 +325,14 @@ document.addEventListener("submit", (event) => {
259
325
  });
260
326
  });
261
327
 
328
+ /**
329
+ * Kick off a `:fetch` request and write the result (or error) into state.
330
+ * @param {Element} node
331
+ * @returns {void}
332
+ */
262
333
  function startFetch(node) {
263
- const key = node.getAttribute("data-wd-fetch-key");
264
- fetch(node.getAttribute("data-wd-fetch-url"))
334
+ const key = node.getAttribute("data-wd-fetch-key") || "";
335
+ fetch(node.getAttribute("data-wd-fetch-url") || "")
265
336
  .then((response) => {
266
337
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
267
338
  return response.json();
@@ -290,9 +361,14 @@ for (const node of document.querySelectorAll("[data-wd-fetch]")) {
290
361
  }
291
362
  }
292
363
 
293
- 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 = {
294
367
  state,
368
+ debug: false,
369
+ /** @param {string} key */
295
370
  get: (key) => state[key],
371
+ /** @param {string} key @param {any} value */
296
372
  set: (key, value) => {
297
373
  state[key] = value;
298
374
  savePersisted();
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
+ };