@zvndev/darkmown 2.12.2 → 2.13.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/builder.js CHANGED
@@ -1343,7 +1343,12 @@ function match(pathname) {
1343
1343
 
1344
1344
  export default {
1345
1345
  async fetch(request, env, ctx) {
1346
- const matched = match(new URL(request.url).pathname);
1346
+ let matched;
1347
+ try {
1348
+ matched = match(new URL(request.url).pathname);
1349
+ } catch {
1350
+ return Response.json({ ok: false, error: "Malformed URL encoding" }, { status: 400 });
1351
+ }
1347
1352
  if (!matched) return env.ASSETS.fetch(request);
1348
1353
  try {
1349
1354
  const response = await matched.handler(request, { params: matched.params, env });
package/src/cli.js CHANGED
@@ -16,7 +16,8 @@ import {
16
16
  devClientPath,
17
17
  devClientScript,
18
18
  devEventsPath,
19
- injectDevClient
19
+ injectDevClient,
20
+ refuseLocalRequest
20
21
  } from "./dev.js";
21
22
  import { createDispatcher, serveMcp } from "./mcp.js";
22
23
  import { mcpConfig } from "./mcp-tools.js";
@@ -421,6 +422,7 @@ function startDevServer({ cwd, log, warn, error }) {
421
422
 
422
423
  const apiDir = path.join(cwd, "api");
423
424
  const server = http.createServer((req, res) => {
425
+ if (refuseForeign(req, res, host)) return;
424
426
  const url = req.url || "/";
425
427
  if (url.split("?")[0] === devEventsPath) {
426
428
  res.writeHead(200, {
@@ -486,6 +488,25 @@ function startDevServer({ cwd, log, warn, error }) {
486
488
  });
487
489
  }
488
490
 
491
+ /**
492
+ * Answer 403 when `refuseLocalRequest` rejects the request (DNS rebinding or a
493
+ * cross-site call into `api/`). Shared by `dev` and `serve`.
494
+ * @param {import("node:http").IncomingMessage} req
495
+ * @param {import("node:http").ServerResponse} res
496
+ * @param {string} bindHost
497
+ * @returns {boolean} true when the request was refused and answered
498
+ */
499
+ function refuseForeign(req, res, bindHost) {
500
+ const reason = refuseLocalRequest(req, {
501
+ bindHost,
502
+ allowedHosts: process.env.DARKMOWN_ALLOWED_HOSTS || ""
503
+ });
504
+ if (!reason) return false;
505
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
506
+ res.end(`${reason}\n`);
507
+ return true;
508
+ }
509
+
489
510
  /**
490
511
  * Start the static preview server for an existing `dist/`. Throws `CliError`
491
512
  * when there is nothing built to serve.
@@ -510,6 +531,7 @@ function startPreviewServer({ cwd, log, error }) {
510
531
  }
511
532
  const apiDir = path.join(cwd, "api");
512
533
  const server = http.createServer((req, res) => {
534
+ if (refuseForeign(req, res, host)) return;
513
535
  const acceptEncoding = req.headers["accept-encoding"];
514
536
  // Serverless parity, exactly as in dev: run a project api/*.js function when
515
537
  // the path matches; otherwise serve static (compressed when negotiated).
@@ -64,6 +64,8 @@
64
64
  * @property {Assets} assets
65
65
  * @property {Map<string, unknown>} state Declared state keys → initial values.
66
66
  * @property {Set<string>} stores Page-global store names (a subset of state keys).
67
+ * @property {{ name: string, url: string, at: string }[]} fetchPlaceholders Every
68
+ * `{ name }` a `:fetch` URL reads, checked once the whole page has declared its state.
67
69
  * @property {Set<string>} fetchKeys Base state keys declared by `:fetch`, so
68
70
  * bare `:if <key>_loading` / `:if <key>_error` regions get compile-time
69
71
  * `role`/`aria-live` announcements.
@@ -320,6 +322,7 @@ export function createCompilation(reader) {
320
322
  state: new Map(),
321
323
  stores: new Set(),
322
324
  fetchKeys: new Set(),
325
+ fetchPlaceholders: [],
323
326
  warnings: [],
324
327
  sectionCounter: 0,
325
328
  headingSlugs: new Map(),
@@ -117,6 +117,7 @@ export function handleFetch(line, ctx, index) {
117
117
  validatePath(dep[1], ctx, FETCH_USE);
118
118
  const headKey = dep[1].split(".")[0];
119
119
  if (!deps.includes(headKey)) deps.push(headKey);
120
+ ctx.comp.fetchPlaceholders.push({ name: headKey, url, at: at(ctx, index) });
120
121
  }
121
122
 
122
123
  // Auto-declare the lifecycle keys. `name` is declared via declareState
@@ -67,8 +67,9 @@ const dateOnlyTZ = (v) =>
67
67
  export const AGGREGATES = {
68
68
  sum: (list, f) => nums(list, f || "").reduce((a, n) => a + n, 0),
69
69
  avg: (list, f) => (list.length ? AGGREGATES.sum(list, f) / list.length : 0),
70
- min: (list, f) => (list.length ? Math.min(...nums(list, f || "")) : 0),
71
- max: (list, f) => (list.length ? Math.max(...nums(list, f || "")) : 0),
70
+ // reduce, not spread: `Math.max(...list)` throws RangeError past ~100k items.
71
+ min: (list, f) => (list.length ? nums(list, f || "").reduce((a, n) => Math.min(a, n)) : 0),
72
+ max: (list, f) => (list.length ? nums(list, f || "").reduce((a, n) => Math.max(a, n)) : 0),
72
73
  count: (list) => list.length
73
74
  };
74
75
 
@@ -56,7 +56,8 @@ export function parseFrontmatter(raw, file) {
56
56
  const quotedKeys = new Set();
57
57
  for (const line of front.split("\n")) {
58
58
  const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
59
- if (!match) continue;
59
+ // `__proto__: [a]` would swap meta's prototype rather than set a key.
60
+ if (!match || match[1] === "__proto__") continue;
60
61
  const parsed = parseFrontmatterValue(match[2]);
61
62
  meta[match[1]] = parsed.value;
62
63
  if (parsed.quoted) quotedKeys.add(match[1]);
@@ -11,6 +11,7 @@ import { highlightCode } from "../highlight.js";
11
11
  import { LOOP_META, lineOf, recordRead, wdError } from "./context.js";
12
12
  import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
13
13
  import { imageSize } from "./image-size.js";
14
+ import { isAllowedInclude } from "./includes.js";
14
15
  import {
15
16
  escapeHtml,
16
17
  getPath,
@@ -37,6 +38,8 @@ export { unsafeUrlValue };
37
38
  // instances share it so `html: false` pages highlight too.
38
39
  const md = new MarkdownIt({ html: false, highlight: highlightCode });
39
40
  md.use(bindingPlugin);
41
+ md.use(unresolvedPlugin);
42
+ md.use(taskListPlugin);
40
43
  md.use(attrsPlugin);
41
44
  md.use(attrBindPlugin);
42
45
  md.use(imageVariantsPlugin);
@@ -62,6 +65,8 @@ export function selectMd(meta) {
62
65
  if (!mdRawHtml) {
63
66
  mdRawHtml = new MarkdownIt({ html: true, highlight: highlightCode });
64
67
  mdRawHtml.use(bindingPlugin);
68
+ mdRawHtml.use(unresolvedPlugin);
69
+ mdRawHtml.use(taskListPlugin);
65
70
  mdRawHtml.use(attrsPlugin);
66
71
  mdRawHtml.use(attrBindPlugin);
67
72
  mdRawHtml.use(imageVariantsPlugin);
@@ -144,6 +149,61 @@ function warnUnbindable(ctx, index, expr, painted) {
144
149
  );
145
150
  }
146
151
 
152
+ /**
153
+ * Names in reach of `head`, for the "did you mean" in {@link warnUnresolved}.
154
+ * @param {Ctx} ctx
155
+ * @returns {string[]}
156
+ */
157
+ function knownNames(ctx) {
158
+ const names = new Set([...ctx.comp.state.keys()].map((key) => key.split(":").pop() ?? key));
159
+ /** @type {typeof ctx.scope | null} */
160
+ let scope = ctx.scope;
161
+ for (; scope; scope = scope.parent) {
162
+ for (const name of Object.keys(scope.vars)) names.add(name);
163
+ }
164
+ return [...names];
165
+ }
166
+
167
+ /**
168
+ * Levenshtein distance, stopping the moment it can no longer be 2 or less.
169
+ * @param {string} a
170
+ * @param {string} b
171
+ * @returns {number}
172
+ */
173
+ function editDistance(a, b) {
174
+ if (Math.abs(a.length - b.length) > 2) return 3;
175
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
176
+ for (let i = 1; i <= a.length; i++) {
177
+ const next = [i];
178
+ for (let j = 1; j <= b.length; j++) {
179
+ next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
180
+ }
181
+ row = next;
182
+ }
183
+ return row[b.length];
184
+ }
185
+
186
+ /**
187
+ * Warn once that `{ expr }` matched nothing, so it ships as the literal braces.
188
+ * A real page has no reason to show them, and `\{ name }` says it on purpose.
189
+ * @param {Ctx} ctx
190
+ * @param {number} index 0-based line index, for `file:line`.
191
+ * @param {string} expr
192
+ * @returns {void}
193
+ */
194
+ function warnUnresolved(ctx, index, expr) {
195
+ const head = expr.split(/[.|\s]/)[0];
196
+ const near = knownNames(ctx)
197
+ .map((name) => /** @type {[string, number]} */ ([name, editDistance(head, name)]))
198
+ .filter(([, distance]) => distance <= 2)
199
+ .sort((a, b) => a[1] - b[1])[0];
200
+ const hint = near ? `Did you mean "${near[0]}"? ` : "";
201
+ const message =
202
+ `${ctx.file}:${lineOf(ctx, index)}: "{ ${expr} }" matches no :state, include argument or in-scope value, so it ships as literal text. ` +
203
+ `${hint}Declare it above this line, or write \\{ ${expr} } if the braces are meant to show.`;
204
+ if (!ctx.comp.warnings.includes(message)) ctx.comp.warnings.push(message);
205
+ }
206
+
147
207
  // ---------------------------------------------------------------------------
148
208
  // REACTIVE ATTRIBUTE BINDING (a link/image destination).
149
209
  //
@@ -267,7 +327,10 @@ function resolveDestinationBindings(text, ctx, attrs, index) {
267
327
  /\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
268
328
  (/** @type {string} */ brace, /** @type {string} */ expr) => {
269
329
  const found = resolveBinding(expr, ctx);
270
- if (!found) return brace; // not in scope — leave the author's text alone
330
+ if (!found) {
331
+ warnUnresolved(ctx, index + i, expr);
332
+ return brace; // not in scope — leave the author's text alone
333
+ }
271
334
  if (found.kind === "static") {
272
335
  // The scheme test runs on the RAW value, before encoding, because
273
336
  // that is the string the runtime's guard sees. Encoding first would
@@ -487,11 +550,14 @@ function buildSrcset(src, ctx) {
487
550
  return null;
488
551
  }
489
552
  const reader = ctx.comp.reader;
553
+ // Same sandbox as measureImage/@include: a data-driven `/../../x.png` must not
554
+ // probe (or read the header of) files outside site/pages and site/_.
555
+ if (!isAllowedInclude(path.join(root, `${relBase}${ext}`), ctx.context, reader)) return null;
490
556
  /** @type {{ href: string, width: number }[]} */
491
557
  const candidates = [];
492
558
  for (const width of VARIANT_WIDTHS) {
493
559
  const abs = path.join(root, `${relBase}@${width}${ext}`);
494
- if (!reader.exists(abs)) continue;
560
+ if (!isAllowedInclude(abs, ctx.context, reader) || !reader.exists(abs)) continue;
495
561
  candidates.push({ href: `${base}@${width}${ext}`, width: measureWidth(abs, reader) ?? width });
496
562
  }
497
563
  // Builder-generated variants (2.9.0): resized into dist by the pre-pass, so
@@ -681,8 +747,128 @@ function rawHtmlPlugin(mdInstance) {
681
747
  // happened to resolve to. Escaping is no defense here: `href` and friends
682
748
  // resolve `javascript:` without any quote breakout, and unlike a markdown
683
749
  // destination nothing upstream vets an author's own raw `<a href="…">`.
684
- const URL_ATTR_TAIL =
685
- /\b(?:href|src|action|formaction|xlink:href)\s*=\s*(?:"([^"]*)|'([^']*)|([^\s>"']*))$/i;
750
+ const URL_ATTRS = new Set([
751
+ "href",
752
+ "src",
753
+ "action",
754
+ "formaction",
755
+ "xlink:href",
756
+ "data",
757
+ "poster",
758
+ "cite",
759
+ "background",
760
+ "ping",
761
+ "to",
762
+ "from",
763
+ "by"
764
+ ]);
765
+
766
+ // `{ path }` plus an optional `| pipe:arg` chain, as the inline binding rule reads it.
767
+ const BINDING_BRACE = /\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*(?:\|[^}]*)?)\}/g;
768
+
769
+ /** @param {string | undefined} c */
770
+ const isHtmlSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
771
+ const TEXT_CONTEXT = /** @type {RawContext} */ ({ kind: "text" });
772
+
773
+ /**
774
+ * @typedef {{ kind: "text" }
775
+ * | { kind: "tag" }
776
+ * | { kind: "value", name: string, head: string, quoted: boolean }
777
+ * | { kind: "rawtext", tag: string, type: string }} RawContext
778
+ */
779
+
780
+ /**
781
+ * Where the end of a raw-HTML fragment sits, as the browser's tokenizer would
782
+ * read it: in text, inside a tag but between attributes, inside an attribute
783
+ * value (with the value so far), or inside a `<script>`/`<style>` body. It walks
784
+ * quoted values rather than pattern-matching back from the end, because a `>`
785
+ * inside `title=">"` does not close the tag.
786
+ * @param {string} html
787
+ * @returns {RawContext}
788
+ */
789
+ function rawHtmlContext(html) {
790
+ const n = html.length;
791
+ let i = 0;
792
+ for (;;) {
793
+ const lt = html.indexOf("<", i);
794
+ if (lt < 0) return TEXT_CONTEXT;
795
+ const next = html[lt + 1];
796
+ // `<` then the value: whatever the value starts with decides the tag name.
797
+ if (next === undefined) return { kind: "tag" };
798
+ if (html.startsWith("<!--", lt)) {
799
+ const end = html.indexOf("-->", lt + 4);
800
+ if (end < 0) return TEXT_CONTEXT;
801
+ i = end + 3;
802
+ continue;
803
+ }
804
+ if (next === "/" && lt + 2 === n) return { kind: "tag" };
805
+ const closing = next === "/" && /[A-Za-z]/.test(html[lt + 2]);
806
+ if (!closing && !/[A-Za-z]/.test(next)) {
807
+ if (next !== "!" && next !== "?" && next !== "/") {
808
+ i = lt + 1;
809
+ continue;
810
+ }
811
+ // A bogus comment runs to the next `>`, and an escaped value cannot hold one.
812
+ const end = html.indexOf(">", lt + 2);
813
+ if (end < 0) return TEXT_CONTEXT;
814
+ i = end + 1;
815
+ continue;
816
+ }
817
+ let j = lt + (closing ? 2 : 1);
818
+ const nameStart = j;
819
+ while (j < n && !isHtmlSpace(html[j]) && html[j] !== "/" && html[j] !== ">") j++;
820
+ const tag = html.slice(nameStart, j).toLowerCase();
821
+ let type = "";
822
+ for (;;) {
823
+ while (j < n && (isHtmlSpace(html[j]) || html[j] === "/")) j++;
824
+ if (j >= n) return { kind: "tag" };
825
+ if (html[j] === ">") {
826
+ j++;
827
+ break;
828
+ }
829
+ const attrStart = j++;
830
+ while (j < n && !isHtmlSpace(html[j]) && !"/>=".includes(html[j])) j++;
831
+ const name = html.slice(attrStart, j).toLowerCase();
832
+ while (j < n && isHtmlSpace(html[j])) j++;
833
+ if (j >= n) return { kind: "tag" };
834
+ if (html[j] !== "=") continue;
835
+ j++;
836
+ while (j < n && isHtmlSpace(html[j])) j++;
837
+ if (j >= n) return { kind: "value", name, head: "", quoted: false };
838
+ let value;
839
+ const quote = html[j];
840
+ if (quote === '"' || quote === "'") {
841
+ const end = html.indexOf(quote, j + 1);
842
+ if (end < 0) return { kind: "value", name, head: html.slice(j + 1), quoted: true };
843
+ value = html.slice(j + 1, end);
844
+ j = end + 1;
845
+ } else {
846
+ const valueStart = j;
847
+ while (j < n && !isHtmlSpace(html[j]) && html[j] !== ">") j++;
848
+ value = html.slice(valueStart, j);
849
+ if (j >= n) return { kind: "value", name, head: value, quoted: false };
850
+ }
851
+ if (name === "type") type = value.trim().toLowerCase();
852
+ }
853
+ if (!closing && (tag === "script" || tag === "style")) {
854
+ const end = html.slice(j).search(new RegExp(`</${tag}[\\s/>]`, "i"));
855
+ if (end < 0) return { kind: "rawtext", tag, type };
856
+ i = j + end + 2;
857
+ continue;
858
+ }
859
+ i = j;
860
+ }
861
+ }
862
+
863
+ /**
864
+ * A JSON `<script>` is data the browser never runs, and an escaped value cannot
865
+ * close it, so a binding there is safe. Every other script or style body is code.
866
+ * @param {string} type
867
+ * @returns {boolean}
868
+ */
869
+ function isDataScript(type) {
870
+ return /^application\/(?:[\w.-]+\+)?json$/.test(type);
871
+ }
686
872
 
687
873
  /**
688
874
  * Warn that a value in URL position carries a scheme the browser would execute.
@@ -698,6 +884,22 @@ function warnUnsafeUrl(ctx, index, expr) {
698
884
  );
699
885
  }
700
886
 
887
+ /**
888
+ * Warn that a value landed where HTML escaping cannot contain it (an event
889
+ * handler, `srcdoc`, or bare tag position), so it was dropped.
890
+ * @param {Ctx} ctx
891
+ * @param {number} index 0-based line index, for `file:line`.
892
+ * @param {string} expr
893
+ * @param {string} where
894
+ * @returns {void}
895
+ */
896
+ function warnUnsafeSlot(ctx, index, expr, where) {
897
+ ctx.comp.warnings.push(
898
+ `${ctx.file}:${lineOf(ctx, index)}: "{ ${expr} }" sits ${where}, where escaping cannot stop a value from running as code, so it is emitted empty. ` +
899
+ `Put the value in a quoted attribute such as data-*, or render it as text, and read it from a colocated .js file.`
900
+ );
901
+ }
902
+
701
903
  /**
702
904
  * Substitute every `{ expr }` in a raw-HTML fragment with its escaped value.
703
905
  *
@@ -716,7 +918,7 @@ function fillRawHtml(html, ctx, index) {
716
918
  // The capture mirrors the inline `wd_binding` rule: path plus an optional
717
919
  // `| pipe:arg` chain. A pipe-only divergence here left `{ d | date }` literal
718
920
  // in raw HTML while the same expression folded in prose.
719
- const brace = /\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*(?:\|[^}]*)?)\}/g;
921
+ const brace = new RegExp(BINDING_BRACE);
720
922
  let out = "";
721
923
  let last = 0;
722
924
  /** @type {RegExpExecArray | null} */
@@ -726,7 +928,10 @@ function fillRawHtml(html, ctx, index) {
726
928
  last = hit.index + hit[0].length;
727
929
  const expr = hit[1].trim();
728
930
  const found = resolveBinding(expr, ctx);
931
+ const context = rawHtmlContext(out);
729
932
  if (!found) {
933
+ // Inside a script or style, `{ x }` is usually the language's own braces.
934
+ if (context.kind !== "rawtext") warnUnresolved(ctx, index, expr);
730
935
  out += hit[0];
731
936
  continue;
732
937
  }
@@ -736,18 +941,103 @@ function fillRawHtml(html, ctx, index) {
736
941
  continue;
737
942
  }
738
943
  if (found.kind === "state") warnUnbindable(ctx, index, expr, true);
739
- const open = URL_ATTR_TAIL.exec(out);
740
- const head = open ? (open[1] ?? open[2] ?? open[3]) : "";
741
- if (open && unsafeUrlValue(head + found.text)) {
944
+ // Escaping only holds inside a quoted attribute or text. In a script-valued
945
+ // attribute the browser entity-decodes `&#39;` back into a live quote, and
946
+ // in bare tag position (`<div { x }>`) the value IS markup.
947
+ if (context.kind === "tag") {
948
+ warnUnsafeSlot(ctx, index, expr, "inside a tag but outside any attribute value");
949
+ continue;
950
+ }
951
+ if (context.kind === "rawtext") {
952
+ if (context.tag === "style" || !isDataScript(context.type)) {
953
+ warnUnsafeSlot(ctx, index, expr, `in a <${context.tag}> body`);
954
+ continue;
955
+ }
956
+ out += escapeHtml(found.text);
957
+ continue;
958
+ }
959
+ if (context.kind === "text") {
960
+ out += escapeHtml(found.text);
961
+ continue;
962
+ }
963
+ const { name, head, quoted } = context;
964
+ if (name.startsWith("on") || name === "srcdoc") {
965
+ warnUnsafeSlot(ctx, index, expr, `in the ${name}= attribute`);
966
+ continue;
967
+ }
968
+ // SVG `values` is a `;`-separated list, and any entry can animate a link's href.
969
+ const assembled = head + found.text;
970
+ const unsafe =
971
+ name === "values"
972
+ ? assembled.split(";").some(unsafeUrlValue)
973
+ : URL_ATTRS.has(name) && unsafeUrlValue(assembled);
974
+ if (unsafe) {
742
975
  warnUnsafeUrl(ctx, index, expr);
743
976
  out = out.slice(0, out.length - head.length); // the head belongs to the refused value
744
977
  continue;
745
978
  }
746
- out += escapeHtml(found.text);
979
+ // An unquoted value ends at whitespace, so `x onmouseover=…` would open a
980
+ // new attribute. Entities keep it one value and decode back to the text.
981
+ out += quoted
982
+ ? escapeHtml(found.text)
983
+ : escapeHtml(found.text).replace(/[\s=`]/g, (c) => `&#${c.charCodeAt(0)};`);
747
984
  }
748
985
  return out + html.slice(last);
749
986
  }
750
987
 
988
+ /**
989
+ * markdown-it plugin (core rule): a `{ name }` still in a text token after the
990
+ * inline pass is one {@link bindingPlugin} could not resolve. Runs before
991
+ * `text_join`, which would merge an escaped `\{` back in and make it look unresolved.
992
+ * @param {MarkdownIt} mdInstance
993
+ * @returns {void}
994
+ */
995
+ function unresolvedPlugin(mdInstance) {
996
+ mdInstance.core.ruler.after("inline", "wd_unresolved", (state) => {
997
+ const wd = state.env?.wd;
998
+ if (!wd) return false;
999
+ for (const block of state.tokens) {
1000
+ const line = wd.index + (block.map ? block.map[0] : 0);
1001
+ let offset = 0;
1002
+ for (const child of block.children ?? []) {
1003
+ if (child.type === "softbreak" || child.type === "hardbreak") offset++;
1004
+ if (child.type !== "text") continue;
1005
+ for (const hit of child.content.matchAll(BINDING_BRACE)) {
1006
+ warnUnresolved(wd.ctx, line + offset, hit[1].trim());
1007
+ }
1008
+ }
1009
+ }
1010
+ return false;
1011
+ });
1012
+ }
1013
+
1014
+ /**
1015
+ * markdown-it plugin (core rule): `- [ ] item` in a `.wd` renders a read-only
1016
+ * checkbox, so a checklist survives a markdown document becoming a `.wd`. `.md`
1017
+ * stays strict CommonMark: it renders with no `wd` env, and this rule is a no-op
1018
+ * without one. Static output, so a static page stays `runtime: false`. A checkbox
1019
+ * the reader can tick is `:checkbox`.
1020
+ * @param {MarkdownIt} mdInstance
1021
+ * @returns {void}
1022
+ */
1023
+ function taskListPlugin(mdInstance) {
1024
+ mdInstance.core.ruler.after("wd_unresolved", "wd_task_list", (state) => {
1025
+ if (!state.env?.wd) return false;
1026
+ state.tokens.forEach((block, i) => {
1027
+ if (block.type !== "inline" || state.tokens[i - 2]?.type !== "list_item_open") return;
1028
+ const first = block.children?.[0];
1029
+ const box = first?.type === "text" ? /^\[([ xX])\](?=\s|$)\s*/.exec(first.content) : null;
1030
+ if (!first || !box) return;
1031
+ first.content = first.content.slice(box[0].length);
1032
+ const input = new state.Token("html_inline", "", 0);
1033
+ input.content = `<input type="checkbox" disabled${box[1] === " " ? "" : " checked"}> `;
1034
+ input.meta = { wdText: "" };
1035
+ block.children?.unshift(input);
1036
+ });
1037
+ return false;
1038
+ });
1039
+ }
1040
+
751
1041
  /**
752
1042
  * markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
753
1043
  * whose content comes from the env's `resolveBinding` callback.
@@ -489,6 +489,15 @@ export function compileDocument(
489
489
  0,
490
490
  null
491
491
  );
492
+ // Checked after the whole page, not at the :fetch line: the runtime reads state
493
+ // when the request fires, so a :state further down still fills the placeholder.
494
+ for (const { name, url, at } of comp.fetchPlaceholders) {
495
+ if (!comp.state.has(name)) {
496
+ comp.warnings.push(
497
+ `${at}: :fetch "${url}" reads "{ ${name} }", but the page declares no :state ${name}, so the request never fires until something sets it. Declare it with :state ${name} = …, or set it from a colocated .js.`
498
+ );
499
+ }
500
+ }
492
501
  // Shared skins (frontmatter `skins:`) join the page's asset set through the
493
502
  // exact same pipeline as colocated skins (compile through compileSkin at emit,
494
503
  // scoping honored), and the CSS cascade is then ordered so the page always
package/src/dev.js CHANGED
@@ -1,8 +1,87 @@
1
+ import { isIP } from "node:net";
1
2
  import { escapeHtml } from "./compiler.js";
2
3
 
3
4
  export const devClientPath = "/__wd/dev-client.js";
4
5
  export const devEventsPath = "/__wd/dev-events";
5
6
 
7
+ /** @param {string} h */
8
+ const isLoopbackName = (h) =>
9
+ h === "localhost" || h.endsWith(".localhost") || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
10
+
11
+ /** `Host`/`Origin` → bare lowercase hostname (`[::1]:5173` → `::1`), or null. @param {string} value */
12
+ function hostnameOf(value) {
13
+ try {
14
+ return new URL(value.includes("://") ? value : `http://${value}`).hostname
15
+ .toLowerCase()
16
+ .replace(/^\[|\]$/g, "");
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * The local dev/preview servers bind to loopback, but loopback alone does not
24
+ * keep a website you are visiting out. Two attacks reach it from a browser:
25
+ *
26
+ * - DNS rebinding: `evil.example` re-resolves to 127.0.0.1, after which the
27
+ * page can READ every response (drafts, the dep map, the SSE stream, and
28
+ * `api/*` handlers, which run with the full `process.env`). The browser still
29
+ * sends `Host: evil.example`, so only hostnames that cannot be rebound pass:
30
+ * `localhost`/`*.localhost`, IP literals (a rebinding needs a domain), the
31
+ * bind address, and the `DARKMOWN_ALLOWED_HOSTS` list (a tunnel's domain).
32
+ * - Cross-site requests: any page can fire a simple POST at
33
+ * `http://127.0.0.1:5173/api/x` without reading the reply, so the handler's
34
+ * side effects run. `/api/*` therefore also refuses `Sec-Fetch-Site:
35
+ * cross-site` and an `Origin` that is not loopback, the request's own host,
36
+ * or allow-listed.
37
+ *
38
+ * `DARKMOWN_ALLOWED_HOSTS=*` turns the guard off.
39
+ * @param {{ url?: string, headers: Record<string, string | string[] | undefined> }} req
40
+ * @param {{ bindHost?: string, allowedHosts?: string }} [options]
41
+ * @returns {string | null} why the request is refused, or null to serve it
42
+ */
43
+ export function refuseLocalRequest(req, { bindHost = "", allowedHosts = "" } = {}) {
44
+ const allowed = allowedHosts
45
+ .split(",")
46
+ .map((h) => hostnameOf(h.trim()) ?? "")
47
+ .filter(Boolean);
48
+ if (allowedHosts.split(",").some((h) => h.trim() === "*")) return null;
49
+ const bind = hostnameOf(bindHost) ?? "";
50
+ const host = hostnameOf(String(req.headers.host ?? ""));
51
+ if (!host || !(isLoopbackName(host) || isIP(host) || host === bind || allowed.includes(host))) {
52
+ return `Host "${req.headers.host ?? ""}" is not allowed. Set DARKMOWN_ALLOWED_HOSTS to permit it.`;
53
+ }
54
+ if (!isApiPath(req.url || "/")) return null;
55
+ if (req.headers["sec-fetch-site"] === "cross-site") return "Cross-site request to /api/ refused.";
56
+ const origin = req.headers.origin;
57
+ if (origin !== undefined) {
58
+ const from = origin === "null" ? null : hostnameOf(String(origin));
59
+ if (
60
+ !from ||
61
+ !(isLoopbackName(from) || from === host || from === bind || allowed.includes(from))
62
+ ) {
63
+ return `Origin "${origin}" may not call /api/ on this server.`;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Whether a request reaches the `api/` runner: the same URL parse and the same
71
+ * test as `handleApiRequest`, so `/api`, `/api?x` and `/./api/x` are all guarded.
72
+ * A URL it cannot parse gets the stricter answer.
73
+ * @param {string} url
74
+ * @returns {boolean}
75
+ */
76
+ function isApiPath(url) {
77
+ try {
78
+ const { pathname } = new URL(url, "http://localhost");
79
+ return pathname === "/api" || pathname.startsWith("/api/");
80
+ } catch {
81
+ return true;
82
+ }
83
+ }
84
+
6
85
  /**
7
86
  * Serialize the dev server's rebuilds: at most ONE child build runs at a time.
8
87
  * Every build read-modify-writes the dependency map (`dist/.wd-dev-deps.json`),
package/src/feeds.js CHANGED
@@ -27,14 +27,23 @@ import { wdError } from "./compiler/context.js";
27
27
  * @property {string} lastmod ISO date (`yyyy-mm-dd`).
28
28
  */
29
29
 
30
+ // Characters XML 1.0 cannot carry (see escapeXml).
31
+ const XML_INVALID =
32
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: these are exactly the characters being removed.
33
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
34
+
30
35
  /**
31
36
  * XML-escape a text value for use inside an element or attribute. Covers the
32
- * five predefined XML entities — the same set RSS/sitemap consumers expect.
37
+ * five predefined XML entities — the same set RSS/sitemap consumers expect —
38
+ * and drops the characters XML 1.0 cannot carry at all (C0 controls other than
39
+ * tab/LF/CR, U+FFFE/U+FFFF, lone surrogates): one in a single post title makes
40
+ * the whole feed malformed, and readers reject it outright.
33
41
  * @param {string} value
34
42
  * @returns {string}
35
43
  */
36
44
  export function escapeXml(value) {
37
45
  return String(value)
46
+ .replace(XML_INVALID, "")
38
47
  .replaceAll("&", "&amp;")
39
48
  .replaceAll("<", "&lt;")
40
49
  .replaceAll(">", "&gt;")
@@ -174,14 +183,20 @@ export function firstParagraph(body) {
174
183
  /** @type {string[]} */
175
184
  const collected = [];
176
185
  for (let j = i; j < lines.length && lines[j].trim(); j++) collected.push(lines[j].trim());
177
- return collected
178
- .join(" ")
179
- .replace(/`([^`]+)`/g, "$1") // inline code → its text
180
- .replace(/\*\*([^*]+)\*\*/g, "$1") // bold
181
- .replace(/\*([^*]+)\*/g, "$1") // italic
182
- .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
183
- .replace(/\s+/g, " ")
184
- .trim();
186
+ return (
187
+ collected
188
+ .join(" ")
189
+ .replace(/`([^`]+)`/g, "$1") // inline code → its text
190
+ .replace(/\*\*([^*]+)\*\*/g, "$1") // bold
191
+ .replace(/\*([^*]+)\*/g, "$1") // italic
192
+ // [text](url) → text. The classes also exclude the OPENING bracket/paren:
193
+ // with `[^\]]*` a body of 200k `[` rescans to the end from every `[`
194
+ // (quadratic, 17 s per call), and any one collection author can write it.
195
+ // One level of parens is kept for URLs like `/wiki/Foo_(bar)`.
196
+ .replace(/\[([^[\]]*)\]\([^()]*(?:\([^()]*\)[^()]*)*\)/g, "$1")
197
+ .replace(/\s+/g, " ")
198
+ .trim()
199
+ );
185
200
  }
186
201
 
187
202
  /**