Skip to content

Commit e7c97de

Browse files
TMHSDigitalclaude
andcommitted
guide-render: render or refuse the Markdown it used to get silently wrong
- Ordered lists keep their start number, and blank lines between items keep one list, so "1. a / blank / 2. b" no longer restarts at 1. - ---, *** and ___ render as <hr />; ~~~ fences render as code. - A setext underline (=== or --- under text) and a code fence indented under a list item now fail with MarkdownError instead of mis-rendering. - A heading whose slug is a template id (## Content vs <main id="content">) takes the next suffix. Every current guide page builds byte-identical. Tests cover each case. Closes #168 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent ba170a2 commit e7c97de

3 files changed

Lines changed: 71 additions & 12 deletions

File tree

‎site/lib/guide-render.mjs‎

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
* entry bodies (tables, fenced code, blockquotes, numbered lists, ### headings),
44
* so they get their own renderer; renderBlocks stays strict for entries.
55
* Inline markup and links go through renderInline and the caller's resolver.
6-
* Images and raw HTML still fail the build, as on entry pages. Pure.
6+
* Images and raw HTML still fail the build, as on entry pages, and so do the
7+
* constructs it would otherwise get wrong: setext headings and code fences
8+
* indented under a list item. Pure.
79
*/
810
import { esc } from "./shared.mjs";
9-
import { MarkdownError, renderInline } from "./markdown.mjs";
11+
import { MarkdownError, renderInline, RESERVED_IDS } from "./markdown.mjs";
1012

1113
/** GitHub's heading anchor, so links written for GitHub (licenses.md#cc-by) land here too. */
1214
export function headingSlug(text) {
@@ -41,6 +43,12 @@ function cells(line) {
4143
return out;
4244
}
4345

46+
/** A thematic break: three or more of the same -, * or _ (spaces allowed between). */
47+
const isBreak = (line) => /^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line);
48+
/** A setext underline (=== or ---) under a paragraph line. */
49+
const isSetext = (line) => /^\s{0,3}(=+|-+)\s*$/.test(line);
50+
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
51+
4452
const isSeparator = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(line);
4553

4654
/**
@@ -59,7 +67,7 @@ export function renderGuide(md, { file, resolveLink }) {
5967
const fail = (n, what) => {
6068
throw new MarkdownError(`${file}:${n + 1}: ${what} is not supported on guide pages`);
6169
};
62-
const isBlockStart = (l) => /^\s*(```|>|\||#{1,6}\s|[-*]\s|\d+[.)]\s)/.test(l);
70+
const isBlockStart = (l) => /^\s*(```|~~~|>|\||#{1,6}\s|[-*]\s|\d+[.)]\s)/.test(l) || isBreak(l);
6371

6472
while (i < ls.length) {
6573
const line = ls[i];
@@ -70,18 +78,29 @@ export function renderGuide(md, { file, resolveLink }) {
7078
if (/^\s*!\[/.test(line)) fail(i, "an image");
7179
if (/^\s*<(?!https?:\/\/)[A-Za-z!/]/.test(line)) fail(i, "raw HTML");
7280

73-
// Fenced code: literal, escaped.
74-
if (/^\s*```/.test(line)) {
75-
const lang = line.trim().slice(3).trim();
81+
// Fenced code (``` or ~~~): literal, escaped. It closes on a fence of
82+
// the same character at least as long as the opening one.
83+
const fence = line.match(FENCE_RE);
84+
if (fence) {
85+
if (/^\s+/.test(line)) fail(i, "an indented code fence (inside a list item)");
86+
const mark = fence[1];
87+
const lang = line.trim().slice(mark.length).trim();
88+
const closes = new RegExp(`^\\s*\\${mark[0]}{${mark.length},}\\s*$`);
7689
const body = [];
7790
i += 1;
78-
while (i < ls.length && !/^\s*```/.test(ls[i])) body.push(ls[i++]);
91+
while (i < ls.length && !closes.test(ls[i])) body.push(ls[i++]);
7992
if (i >= ls.length) fail(i - 1, "an unclosed code fence");
8093
i += 1;
8194
html.push(`<pre class="guide-code"${lang ? ` data-lang="${esc(lang)}"` : ""}><code>${esc(body.join("\n"))}</code></pre>`);
8295
continue;
8396
}
8497

98+
if (isBreak(line)) {
99+
html.push("<hr />");
100+
i += 1;
101+
continue;
102+
}
103+
85104
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
86105
if (h) {
87106
const level = h[1].length;
@@ -96,9 +115,14 @@ export function renderGuide(md, { file, resolveLink }) {
96115
continue;
97116
}
98117
const base = headingSlug(h[2]);
99-
const n = seen.get(base) || 0;
118+
let n = seen.get(base) || 0;
119+
let id = n ? `${base}-${n}` : base;
120+
// The page template owns some ids (<main id="content">): take the next suffix.
121+
while (RESERVED_IDS.has(id)) {
122+
n += 1;
123+
id = `${base}-${n}`;
124+
}
100125
seen.set(base, n + 1);
101-
const id = n ? `${base}-${n}` : base;
102126
const tag = `h${Math.min(level, 4)}`;
103127
html.push(`<${tag} id="${esc(id)}">${inline(h[2])}</${tag}>`);
104128
i += 1;
@@ -131,6 +155,7 @@ export function renderGuide(md, { file, resolveLink }) {
131155
const li = line.match(listRe);
132156
if (li) {
133157
const ordered = /\d/.test(li[1]);
158+
const start = ordered ? parseInt(li[1], 10) : 1;
134159
const items = [];
135160
while (i < ls.length) {
136161
const m = ls[i].match(listRe);
@@ -140,15 +165,34 @@ export function renderGuide(md, { file, resolveLink }) {
140165
} else if (items.length && ls[i].trim() && /^\s+/.test(ls[i]) && !isBlockStart(ls[i])) {
141166
items[items.length - 1] += ` ${ls[i].trim()}`;
142167
i += 1;
168+
} else if (items.length && FENCE_RE.test(ls[i]) && /^\s+/.test(ls[i])) {
169+
fail(i, "an indented code fence (inside a list item)");
170+
} else if (items.length && !ls[i].trim()) {
171+
// Blank lines between items of the same list keep one list
172+
// (a "loose" list), so 1. a / blank / 2. b stays numbered 1, 2.
173+
let j = i;
174+
while (j < ls.length && !ls[j].trim()) j += 1;
175+
const next = j < ls.length ? ls[j].match(listRe) : null;
176+
if (next && /\d/.test(next[1]) === ordered && !/^\s+/.test(ls[j])) i = j;
177+
else if (j < ls.length && FENCE_RE.test(ls[j]) && /^\s+/.test(ls[j])) fail(j, "an indented code fence (inside a list item)");
178+
else break;
143179
} else break;
144180
}
145181
const tag = ordered ? "ol" : "ul";
146-
html.push(`<${tag}>${items.map((t) => `<li>${inline(t)}</li>`).join("")}</${tag}>`);
182+
const startAttr = ordered && start !== 1 ? ` start="${start}"` : "";
183+
html.push(`<${tag}${startAttr}>${items.map((t) => `<li>${inline(t)}</li>`).join("")}</${tag}>`);
147184
continue;
148185
}
149186

150187
const para = [];
151-
while (i < ls.length && ls[i].trim() && !isBlockStart(ls[i])) para.push(ls[i++].trim());
188+
while (i < ls.length && ls[i].trim() && !isBlockStart(ls[i])) {
189+
// A === or --- under paragraph text is a setext heading on GitHub.
190+
if (para.length && isSetext(ls[i])) fail(i, "a setext heading underline (use ## instead)");
191+
para.push(ls[i++].trim());
192+
}
193+
if (para.length && i < ls.length && isBreak(ls[i]) && /^\s{0,3}-/.test(ls[i])) {
194+
fail(i, "a setext heading underline (use ## instead)");
195+
}
152196
if (!para.length) {
153197
// A line that looks like a block start but matched nothing above.
154198
para.push(ls[i++].trim());

‎site/lib/lib.test.mjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,21 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
12611261
let threw = "";
12621262
try { renderGuide("# T\n\n![x](a.png)\n", { file: "g.md", resolveLink: pass }); } catch (e) { threw = e.message; }
12631263
has("guide: images refused", threw, "g.md:3: an image is not supported");
1264+
const gr = (md) => renderGuide(`# T\n\n${md}`, { file: "g.md", resolveLink: pass }).html;
1265+
has("guide: an ordered list keeps its start number", gr("3. third\n4. fourth\n"), '<ol start="3"><li>third</li><li>fourth</li></ol>');
1266+
has("guide: a loose ordered list stays one list", gr("1. a\n\n2. b\n"), "<ol><li>a</li><li>b</li></ol>");
1267+
has("guide: a loose bullet list stays one list", gr("- a\n\n- b\n\nAfter.\n"), "<ul><li>a</li><li>b</li></ul>\n<p>After.</p>");
1268+
has("guide: a paragraph between lists splits them", gr("1. a\n\nText.\n\n2. b\n"), '<ol start="2"><li>b</li></ol>');
1269+
has("guide: --- is a thematic break", gr("Para.\n\n---\n\nNext.\n"), "<p>Para.</p>\n<hr />\n<p>Next.</p>");
1270+
has("guide: *** is a thematic break", gr("***\n"), "<hr />");
1271+
throws("guide: --- under text (a setext heading) fails", () => gr("Para\n---\n"), "a setext heading underline");
1272+
throws("guide: === under text (a setext heading) fails", () => gr("Para\n===\n"), "a setext heading underline");
1273+
has("guide: ~~~ fences render as code", gr("~~~js\nlet a = **1**;\n~~~\n"), '<pre class="guide-code" data-lang="js"><code>let a = **1**;</code></pre>');
1274+
has("guide: a ``` line inside a ~~~ fence is literal", gr("~~~\n```\n~~~\n"), "<code>```</code>");
1275+
throws("guide: a fence indented under a list item fails", () => gr("1. step\n ```sh\n x\n ```\n"), "an indented code fence");
1276+
throws("guide: a fence after a blank line under a list item fails", () => gr("- step\n\n ```\n x\n ```\n"), "an indented code fence");
1277+
has("guide: ## Content does not take the template's id", gr("## Content\n"), '<h2 id="content-1">Content</h2>');
1278+
has("guide: a second ## Content continues the count", gr("## Content\n\n## Content\n"), '<h2 id="content-2">Content</h2>');
12641279
}
12651280

12661281
/* new-entry --from-issue (scaffold.mjs) ------------------------------------ */

‎site/lib/markdown.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function marks(escaped) {
3333
}
3434

3535
/** Ids the page templates use themselves; a heading must not take one. */
36-
const RESERVED_IDS = new Set([
36+
export const RESERVED_IDS = new Set([
3737
"attribution-string",
3838
"content",
3939
"copy-attribution",

0 commit comments

Comments
 (0)