@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/AGENTS.md +1 -0
- package/CHANGELOG.md +43 -0
- package/README.md +10 -0
- package/SECURITY.md +9 -2
- package/docs/cli.md +10 -0
- package/docs/spec-alignment.md +1 -0
- package/package.json +2 -2
- package/site/pages/docs/index.wd +1 -0
- package/site/pages/playground/index.js +59 -58
- package/src/builder.js +6 -1
- package/src/cli.js +23 -1
- package/src/compiler/context.js +3 -0
- package/src/compiler/fetch.js +1 -0
- package/src/compiler/format.js +3 -2
- package/src/compiler/frontmatter.js +2 -1
- package/src/compiler/markdown.js +299 -9
- package/src/compiler/page.js +9 -0
- package/src/dev.js +79 -0
- package/src/feeds.js +24 -9
- package/src/headers.js +8 -2
- package/src/image-variant-resize.js +6 -1
- package/src/router.js +4 -1
- package/src/runtime.js +19 -7
- package/src/runtime.min.js +1 -1
- package/src/runtime.min.js.map +1 -1
- package/src/scaffold.js +13 -5
- package/src/statics.js +17 -1
- package/types/compiler/context.d.ts +11 -0
- package/types/compiler/markdown.d.ts +14 -0
- package/types/dev.d.ts +28 -0
- package/types/feeds.d.ts +19 -26
- package/types/headers.d.ts +6 -1
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
|
-
|
|
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).
|
package/src/compiler/context.js
CHANGED
|
@@ -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(),
|
package/src/compiler/fetch.js
CHANGED
|
@@ -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
|
package/src/compiler/format.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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]);
|
package/src/compiler/markdown.js
CHANGED
|
@@ -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)
|
|
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
|
|
685
|
-
|
|
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 =
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
944
|
+
// Escaping only holds inside a quoted attribute or text. In a script-valued
|
|
945
|
+
// attribute the browser entity-decodes `'` 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
|
-
|
|
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.
|
package/src/compiler/page.js
CHANGED
|
@@ -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("&", "&")
|
|
39
48
|
.replaceAll("<", "<")
|
|
40
49
|
.replaceAll(">", ">")
|
|
@@ -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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
/**
|