Skip to content

Commit a3b2ba5

Browse files
committed
Escape the backslash in markdown link labels
Add the backslash to escapeMd's class, so a title ending in one no longer escapes the `]` that closes its label, and rename it from mdText to pair with escape(). Cover it with synthetic titles parsed back through markdown-it, rather than a corpus title an upstream rename could take away. Keep stripHtmlComments to a single pass: repeating it would eat the prose a cut splices together, and rawHtmlFindings already reports the leftover. Signed-off-by: Lu Zhang <lu@wdl.dev>
1 parent 691c580 commit a3b2ba5

4 files changed

Lines changed: 29 additions & 9 deletions

File tree

scripts/build-content.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ export function summaryOf(md, limit = 155) {
135135
// code span holding <!-- is documentation that must survive.
136136
const CODE_OR_COMMENT = /(`+)(?:[^`\n]|\n(?!\s*\n))+\1|<!--[\s\S]*?-->/g;
137137

138-
// Invisible on GitHub, but the renderer escapes them into visible text.
138+
// Invisible on GitHub, but the renderer escapes them into visible text. One
139+
// left-to-right pass, deliberately: a cut can splice its neighbours into
140+
// something comment-shaped (`<!-<!-- -->- x -->`), and a second pass would eat
141+
// the prose between them. rawHtmlFindings below reports the leftover.
139142
export const stripHtmlComments = (md) =>
140143
mapProse(md, (run) => run.replace(CODE_OR_COMMENT, (hit) => (hit.startsWith("<!--") ? "" : hit)));
141144

src/index.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,9 @@ const estimateTokens = (s) => {
387387
return Math.ceil((s.length - cjk) / 4 + cjk);
388388
};
389389

390-
// A title may legitimately contain brackets (see cli/env-overrides), which
391-
// would otherwise terminate the markdown link early.
392-
const mdText = (s) => s.replace(/([[\]])/g, "\\$1");
390+
// Titles become link labels, where an unpaired bracket ends the label early and
391+
// a trailing backslash escapes the `]` that closes it.
392+
export const escapeMd = (s) => s.replace(/([\\[\]])/g, "\\$1");
393393

394394
const HOME_MD = [
395395
"# wdl.md — WDL documentation",
@@ -403,8 +403,8 @@ const HOME_MD = [
403403
`## ${section}`,
404404
"",
405405
...pages.flatMap((p) => [
406-
`- [${mdText(p.en.title)}](${SITE_ORIGIN}/${p.slug}.md)`,
407-
...(p.zh ? [`- [${mdText(p.zh.title)}](${SITE_ORIGIN}/zh/${p.slug}.md) (中文)`] : []),
406+
`- [${escapeMd(p.en.title)}](${SITE_ORIGIN}/${p.slug}.md)`,
407+
...(p.zh ? [`- [${escapeMd(p.zh.title)}](${SITE_ORIGIN}/zh/${p.slug}.md) (中文)`] : []),
408408
]),
409409
"",
410410
]),

test/build-content.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ test("stripHtmlComments drops comments from prose, never from code", () => {
5252
assert.ok(stripHtmlComments("use `<!-- x -->` here").includes("`<!-- x -->`"));
5353
});
5454

55+
test("a cut that splices a new comment must not eat the prose", () => {
56+
assert.ok(stripHtmlComments("<!-<!-- -->- x -->").includes("x"));
57+
});
58+
5559
test("a comment after an unmatched backtick is still stripped", () => {
5660
// The code-span guard must not span a blank line, or the comment hides inside
5761
// a fake span and renders as visible escaped text.

test/worker.test.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
// markdown.test.js, and the agent contract in agents-md.test.js.
55
import { test } from "node:test";
66
import assert from "node:assert/strict";
7-
import worker from "../src/index.js";
7+
import MarkdownIt from "markdown-it";
8+
import worker, { escapeMd } from "../src/index.js";
89
import { PAGES } from "../src/content.gen.js";
910

1011
const get = (path, headers = {}) =>
@@ -97,13 +98,25 @@ test("sitemap.xml lists home plus every en and zh page", async () => {
9798
// an untranslated page carries none.
9899
const bilingual = PAGES.find((p) => p.zh).slug;
99100
const solo = PAGES.find((p) => !p.zh).slug;
100-
const entry = (loc) =>
101-
body.match(new RegExp(`<url>\\s*<loc>${loc.replace(/[/.]/g, "\\$&")}</loc>[^]*?</url>`))[0];
101+
// Split rather than build a regex out of a slug, which would need escaping.
102+
const entry = (loc) => body.split("</url>").find((b) => b.includes(`<loc>${loc}</loc>`));
102103
assert.ok(entry(`https://wdl.md/${bilingual}`).includes(`hreflang="zh" href="https://wdl.md/zh/${bilingual}"`));
103104
assert.ok(entry(`https://wdl.md/zh/${bilingual}`).includes(`hreflang="x-default" href="https://wdl.md/${bilingual}"`));
104105
assert.ok(!entry(`https://wdl.md/${solo}`).includes("xhtml:link"));
105106
});
106107

108+
test("any upstream title survives as a link label in the markdown index", () => {
109+
// Synthetic on purpose: an upstream rename must not decide what this covers.
110+
// The trailing backslash is the shape that breaks; CommonMark pairs brackets.
111+
const md = new MarkdownIt();
112+
for (const title of ["plain", "balanced [b] here", "unpaired [ here", "trailing \\"]) {
113+
assert.equal(
114+
md.renderInline(`[${escapeMd(title)}](${SITE}x.md)`),
115+
`<a href="${SITE}x.md">${title}</a>`,
116+
);
117+
}
118+
});
119+
107120
test("llms.txt indexes every page in both languages", async () => {
108121
const res = await get("/llms.txt");
109122
assert.equal(res.headers.get("content-type"), "text/plain; charset=utf-8");

0 commit comments

Comments
 (0)