From 7f6a3d874e8661b8bd74cec8080df2fb1f15983f Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Sun, 18 Oct 2026 13:39:03 -0700 Subject: [PATCH 1/2] test: failing repros for six suspected label-grammar bugs (Burrow report) Each test asserts the correct behaviour and fails on 0.47.0 where the bug is real. No source changes. Co-Authored-By: Claude Opus 5.5 --- test/label-grammar-repros.test.ts | 130 ++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/label-grammar-repros.test.ts diff --git a/test/label-grammar-repros.test.ts b/test/label-grammar-repros.test.ts new file mode 100644 index 0000000..61da819 --- /dev/null +++ b/test/label-grammar-repros.test.ts @@ -0,0 +1,130 @@ +// Reproductions for six suspected label-grammar bugs reported by Burrow (a +// port of this renderer). Each test asserts the CORRECT behaviour, so a test +// that fails on main confirms the bug. Nothing here is a fix. +import { describe, expect, it } from "vitest"; +import { DIRECTIVE_LINE, label, linkInline, linkSource } from "../src/grammar.ts"; +import { unrequestedOutputDirectives } from "../src/tk.ts"; +import { withStubQuote } from "../src/ui/stub-quote.ts"; +import { apiJson, createAndPublish, getPublic, login } from "./helpers.ts"; + +const ID = "7c9wk2n4h6q1x8v0z3m5rjy2ke"; +const OURS = "https://example.com/blyg/"; + +async function publish(cookie: string, md: string, kind: "fragment" | "thread" = "thread") { + const id = (await apiJson(cookie, "POST", "/api/items", { content_md: md, kind })).json.id as string; + const res = await apiJson(cookie, "POST", `/api/items/${id}/publish`, {}); + const doc = res.status === 200 ? await (await getPublic(`/blyg/items/${id}.json`)).json() : null; + return { id, res, doc }; +} + +const anchorText = (html: string, target: string) => + new RegExp(`([\\s\\S]*?)`).exec(html)?.[1]; + +describe("1. a label inside block TK output is escaped once, as in the body", () => { + it("block-scope anchor text equals body anchor text for the same label", async () => { + const cookie = await login(); + await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); + const target = await createAndPublish(cookie, "# Target\n\nBody."); + const lbl = "a & x *em*"; + const body = await publish(cookie, `Body [[${target} | ${lbl}]] here.`); + // impyrt carries provenance, so the scope is lifted out and rendered as a + // generated block (a hand-written `[=]` output with no provenance is not + // lifted at publish and renders in the body, which hides the bug there). + const blockMd = `Intro.\n\n[TK]impyrt=One [[${target} | ${lbl}]].\n\nTwo.[/TK]\n\nOutro.`; + const block = await publish(cookie, blockMd); + const inline = await publish(cookie, `Intro [TK]impyrt=one [[${target} | ${lbl}]] two[/TK] outro.`); + // The preview lifts every block scope, `[=]` included. + const preview = await apiJson(cookie, "POST", "/api/preview", { + content_md: `[TK]write[=]One [[${target} | ${lbl}]].\n\nTwo.[/TK]`, + kind: "thread", + }); + expect(body.res.status, JSON.stringify(body.res.json)).toBe(200); + expect(block.res.status, JSON.stringify(block.res.json)).toBe(200); + expect(inline.res.status, JSON.stringify(inline.res.json)).toBe(200); + const want = anchorText(body.doc.content_html, target); + expect(want).toBe("a <b> & x *em*"); + expect(anchorText(inline.doc.content_html, target), "inline scope").toBe(want); + expect(anchorText(preview.json.html as string, target), "block scope, preview").toBe(want); + expect(anchorText(block.doc.content_html, target), "block scope, publish").toBe(want); + }); +}); + +describe("2. a backtick inside a label does not make a later link inert", () => { + it("the second link on the line still renders as a link", async () => { + const cookie = await login(); + await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); + const a = await createAndPublish(cookie, "# Alpha\n\nBody."); + const b = await createAndPublish(cookie, "# Beta\n\nBody."); + const { res, doc } = await publish(cookie, `See [[${a} | it\`s]] and [[${b}]] then \`code\`.`, "fragment"); + expect(res.status, JSON.stringify(res.json)).toBe(200); + expect(doc.content_html).toContain(`it\`s`); + expect(doc.content_html).toContain(`Beta`); + expect(doc.content_html).not.toContain(`[[${b}]]`); + }); +}); + +describe("3. linkSource -> parse round-trips the label", () => { + it.each(["a]]]b", "a]]]]b", "x]]]"])("label %s survives as a label with no ]] cut", (text) => { + const src = linkSource(ID, text); + const parsed = [...src.matchAll(linkInline())].map((m) => ({ id: m[1], label: label(m[2]), whole: m[0] })); + expect(parsed).toHaveLength(1); + expect(parsed[0].whole, "the match covers the whole written link").toBe(src); + expect(parsed[0].label).not.toContain("]]"); + }); +}); + +describe("4. an autolinked URL holding a labelled link escapes & in its href", () => { + it("the href attribute carries &, never a raw &", async () => { + const cookie = await login(); + await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); + const target = await createAndPublish(cookie, "# Target\n\nBody."); + for (const md of [``, `https://x.test/[[${target}|a©]]`]) { + const { res, doc } = await publish(cookie, md, "fragment"); + expect(res.status, JSON.stringify(res.json)).toBe(200); + const href = / ${doc.content_html}`).toContain("x.test"); + // A raw `©` in an attribute is decoded by the browser to "©". + expect(href, md).not.toMatch(/&(?!amp;|#|lt;|gt;|quot;)/); + } + }); +}); + +describe("5. choosing a passage keeps the directive's label", () => { + it("withStubQuote preserves `![[id | label]]`", () => { + const md = `![[${ID} | their post]]\n\nMy reply.`; + const out = withStubQuote(md, ID, "a passage"); + expect(out.split("\n")[0]).toBe(`![[${ID} | their post]]`); + expect(out).toContain("> a passage"); + // and back to whole + expect(withStubQuote(out, ID, null).split("\n")[0]).toBe(`![[${ID} | their post]]`); + }); +}); + +describe("6. directiveOwnLine ([ \\t]) vs DIRECTIVE_LINE (\\s)", () => { + it.each([ + ["NBSP before", " "], + ["form feed before", "\f"], + ["vertical tab before", "\v"], + ])("%s: a line the publish walk transcludes is also flagged as an echo", (_name, ws) => { + const line = `${ws}![[${ID}]]`; + expect(DIRECTIVE_LINE.test(line), "publish treats it as a directive").toBe(true); + const md = `[TK]write[=]Intro.\n\n${line}\n\nMore.[/TK]`; + expect(unrequestedOutputDirectives(md)).toEqual([ID]); + }); + + it("CR (CRLF line ends) does not diverge", () => { + const md = `[TK]write[=]Intro.\r\n\r\n![[${ID}]]\r\n\r\nMore.[/TK]`; + expect(DIRECTIVE_LINE.test(`![[${ID}]]\r`)).toBe(true); + expect(unrequestedOutputDirectives(md)).toEqual([ID]); + }); + + it("end to end: NBSP-led own-line directive in output is quoted AND warned about", async () => { + const cookie = await login(); + const target = await createAndPublish(cookie, "Quoted words."); + const { res, doc } = await publish(cookie, `Intro.\n\n[TK]write[=]Mine.\n\n ![[${target}]]\n\nMore.[/TK]\n\nOutro.`); + expect(res.status, JSON.stringify(res.json)).toBe(200); + const quoted = doc.content_html.includes(`data-blyg-id="${target}"`); + const warned = typeof res.json.warning === "string" && res.json.warning.includes(target); + expect({ quoted, warned }).toEqual({ quoted, warned: quoted }); + }); +}); From d482b026b33c4117902fdb5f50adf76be3082a27 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Sun, 18 Oct 2026 13:52:24 -0700 Subject: [PATCH 2/2] Fix six label-grammar bugs in 0.47.0 (#70); keep the repros as regressions - Block TK output: links resolve over each block's markdown and the block is rendered again, never over its HTML, where a label had already been through markdown (`*em*` -> ``) and escaping. The HTML-source path (htmlCodeRanges, the `inert` parameter, previewInternalLinks' html flag) is gone. - A backtick in a live link's label no longer opens a code span that makes a later link inert: labels are blanked before code is found again, left to right, so a span opened before a link still wins (#54). - labelText spaces every `]` followed by `]` or by the closing `]]`, so `a]]]b`, `a]]]]b` and `x]]]` round-trip through linkSource. - An autolinked URL holding a link gets its literal HTML-escaped in the href (`&` -> `&`). - withStubQuote keeps the directive line as written, label included. - directiveOwnLine takes `[^\S\n]` at both ends, DIRECTIVE_LINE's `\s` less the newline, so NBSP/FF/VT-led own-line directives in TK output are warned about (#60). test/label-grammar-repros.test.ts becomes label-grammar-edge-cases.test.ts, with one added case for code-span precedence over a label. Co-Authored-By: Claude Opus 5.5 --- src/authoring.ts | 16 +++++- src/code-ranges.ts | 5 -- src/grammar.ts | 14 ++++-- src/model.ts | 14 +++--- src/read-api.ts | 4 +- src/tk.ts | 17 ++++++- src/transclusion.ts | 50 +++++++++++++------ src/ui/stub-quote.ts | 11 ++-- ...st.ts => label-grammar-edge-cases.test.ts} | 29 +++++++---- 9 files changed, 111 insertions(+), 49 deletions(-) rename test/{label-grammar-repros.test.ts => label-grammar-edge-cases.test.ts} (82%) diff --git a/src/authoring.ts b/src/authoring.ts index 6fea850..3ebf6f6 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -71,9 +71,21 @@ export function scopeSummaries(scopes: TkScope[]): { index: number; instruction: * and lets the caller render the remaining markdown (plain, or via * previewTransclusions for threads). */ -export function annotateTkPreview(contentMd: string): { scopes: TkScope[]; text: string; blocks: Map; finish: (renderedHtml: string) => string } { +export function annotateTkPreview(contentMd: string): { + scopes: TkScope[]; + text: string; + blockReplacements: Map; + blockSources: Map; + finish: (renderedHtml: string) => string; +} { const { scopes } = parseScopes(contentMd); const { text, spans } = previewStrip(contentMd, scopes); const annotated = annotateGenerated(text, spans, spans.map(() => true)); - return { scopes, text: annotated.text, blocks: annotated.blockReplacements, finish: (renderedHtml) => applyGeneratedWrappers(renderedHtml, annotated) }; + return { + scopes, + text: annotated.text, + blockReplacements: annotated.blockReplacements, + blockSources: annotated.blockSources, + finish: (renderedHtml) => applyGeneratedWrappers(renderedHtml, annotated), + }; } diff --git a/src/code-ranges.ts b/src/code-ranges.ts index f64c096..7279aa6 100644 --- a/src/code-ranges.ts +++ b/src/code-ranges.ts @@ -45,11 +45,6 @@ export function codeRanges(text: string): Range[] { return ranges; } -/** Code regions of already-rendered HTML: every `…` (inline, or inside `
`). */
-export function htmlCodeRanges(html: string): Range[] {
-  return [...html.matchAll(/][\s\S]*?<\/code>/gi)].map((m): Range => [m.index!, m.index! + m[0].length]);
-}
-
 export function inRanges(ranges: Range[], pos: number): boolean {
   return ranges.some(([s, e]) => pos >= s && pos < e);
 }
diff --git a/src/grammar.ts b/src/grammar.ts
index 6a3bd68..a3a9f42 100644
--- a/src/grammar.ts
+++ b/src/grammar.ts
@@ -42,8 +42,12 @@ export const linkInline = () => new RegExp(LINK_INLINE.source, "g");
 /** A directive anywhere in a line (TK source refs): id in group 1, raw label in group 2. */
 export const directiveInline = () => new RegExp(`!\\[\\[${BODY}`, "g");
 
-/** A directive alone on its line, multiline (TK output): id in group 1, raw label in group 2. */
-export const directiveOwnLine = () => new RegExp(`^[ \\t]*!\\[\\[${BODY}[ \\t]*$`, "gm");
+/**
+ * A directive alone on its line, multiline (TK output): id in group 1, raw label in group 2.
+ * `[^\S\n]` is `DIRECTIVE_LINE`'s `\s` less the newline that ends a line, so
+ * every line publish transcludes is one this finds (decision #60).
+ */
+export const directiveOwnLine = () => new RegExp(`^[^\\S\\n]*!\\[\\[${BODY}[^\\S\\n]*$`, "gm");
 
 /** The label a match carries, trimmed; undefined when absent or empty. */
 export function label(raw: string | undefined): string | undefined {
@@ -53,10 +57,12 @@ export function label(raw: string | undefined): string | undefined {
 
 /**
  * Text made safe to write as a label: one line, no `]]` (the grammar has no
- * escape, so §10.1 says a label that would contain one is rewritten).
+ * escape, so §10.1 says a label that would contain one is rewritten). Every
+ * `]` followed by another, or by the `]]` that will close the link, takes a
+ * space after it; one pass over `]]` would leave `a]]]b` reading `a] ]]`.
  */
 export function labelText(text: string): string {
-  return text.replace(/\s+/g, " ").replace(/\]\]/g, "] ]").trim();
+  return text.replace(/\s+/g, " ").trim().replace(/\](?=\]|$)/g, "] ");
 }
 
 /**
diff --git a/src/model.ts b/src/model.ts
index 2101503..e11642c 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -5,7 +5,6 @@ import { parseCustomTheme } from "./themes.ts";
 // Data access + publish-flow semantics — v0.1-plan §3.1.
 
 import { renderMarkdown } from "./markdown.ts";
-import { htmlCodeRanges } from "./code-ranges.ts";
 import { annotateGenerated, applyGeneratedWrappers, parseScopes, stripToOutput, TkPublishError, unresolvedScopes } from "./tk.ts";
 import { recordPublishInteractions } from "./interactions.ts";
 import { applyVersionAgreement, composeStubCite, composeTransclusionCite, parseStoredStub } from "./stub.ts";
@@ -392,16 +391,15 @@ export async function publish(db: D1Database, item: ItemRow, note: string | null
   //
   // Run over the body *and* over each independently-rendered generated block,
   // because annotateGenerated has already lifted those blocks out of the body
-  // text: a link inside one would otherwise stay literal. Their raw text is
-  // gone by now, but the rendered HTML still contains the un-substituted
-  // `[[id]]` verbatim — no character of the grammar is HTML-special — so the
-  // same resolver works on either input. Token sequence is global, so the maps
-  // merge.
+  // text: a link inside one would otherwise stay literal. Each block is
+  // resolved over its own markdown and rendered again, never over its HTML,
+  // where a label has already been through markdown and escaping once.
+  // Token sequence is global, so the maps merge.
   const linkErrors: TransclusionRefError[] = [];
   const links = await resolveInternalLinks(db, annotated.text, normalizedOrigin(origin));
   linkErrors.push(...links.errors);
-  const { docs: blockLinks, errors: blockErrors } = await resolveBlockLinks(annotated.blockReplacements, (html) =>
-    resolveInternalLinks(db, html, normalizedOrigin(origin), undefined, htmlCodeRanges),
+  const { docs: blockLinks, errors: blockErrors } = await resolveBlockLinks(annotated, (md) =>
+    resolveInternalLinks(db, md, normalizedOrigin(origin)),
   );
   linkErrors.push(...blockErrors);
   if (linkErrors.length) throw new TransclusionResolveError(linkErrors);
diff --git a/src/read-api.ts b/src/read-api.ts
index bc02bad..82e81ad 100644
--- a/src/read-api.ts
+++ b/src/read-api.ts
@@ -179,7 +179,7 @@ readApi.openapi(routes.preview, async (c) => {
   const origin = siteOrigin(await getSettings(c.env.DB), c.req.url, mount);
   const links = await previewInternalLinks(c.env.DB, tk.text, origin);
   // Links inside generated blocks resolve here too, as at publish (studio#14).
-  const blocks = await resolveBlockLinks(tk.blocks, (html) => previewInternalLinks(c.env.DB, html, origin, true));
+  const blocks = await resolveBlockLinks(tk, (md) => previewInternalLinks(c.env.DB, md, origin));
   const html = [links, ...blocks.docs].reduce((acc, doc) => applyInternalLinks(acc, doc), tk.finish(renderMarkdown(links.text)));
   return c.json({ html, scopes: await scopeSummariesWithSources(c.env.DB, tk.scopes), link_errors: [...links.errors, ...blocks.errors] });
 });
@@ -285,7 +285,7 @@ async function threadPreview(c: Context<{ Bindings: Env }>) {
   const mount = normalizeMount(c.env.MOUNT);
   const origin = siteOrigin(await getSettings(c.env.DB), c.req.url, mount);
   const links = await previewInternalLinks(c.env.DB, tk.text, origin);
-  const blocks = await resolveBlockLinks(tk.blocks, (html) => previewInternalLinks(c.env.DB, html, origin, true));
+  const blocks = await resolveBlockLinks(tk, (md) => previewInternalLinks(c.env.DB, md, origin));
   const resolved = await previewTransclusions(c.env.DB, links.text, body.item_id);
   return c.json({
     html: [links, ...blocks.docs].reduce((acc, doc) => applyInternalLinks(acc, doc), tk.finish(resolved.html)),
diff --git a/src/tk.ts b/src/tk.ts
index 2969258..d4ab178 100644
--- a/src/tk.ts
+++ b/src/tk.ts
@@ -292,6 +292,12 @@ export interface AnnotatedDocument {
   text: string;
   /** Unique per-span token -> its independently-rendered `
` replacement. */ blockReplacements: Map; + /** + * The same tokens -> each block's markdown, so `[[id]]` inside a block can be + * resolved over the text the author wrote rather than over its HTML, where + * markdown has already been applied to a label (`*em*` is `` by then). + */ + blockSources: Map; hasInline: boolean; } @@ -305,6 +311,7 @@ export function annotateGenerated(strippedMd: string, spans: GeneratedSpan[], ha let out = ""; let last = 0; const blockReplacements = new Map(); + const blockSources = new Map(); let hasInline = false; spans.forEach((span, i) => { out += strippedMd.slice(last, span.start); @@ -313,7 +320,8 @@ export function annotateGenerated(strippedMd: string, spans: GeneratedSpan[], ha out += text; } else if (span.block) { const token = `${BLOCK_SENTINEL}${i}${BLOCK_SENTINEL}`; - blockReplacements.set(token, `
${renderMarkdown(text)}
`); + blockReplacements.set(token, renderGeneratedBlock(text)); + blockSources.set(token, text); out += token; } else { hasInline = true; @@ -322,7 +330,12 @@ export function annotateGenerated(strippedMd: string, spans: GeneratedSpan[], ha last = span.end; }); out += strippedMd.slice(last); - return { text: out, blockReplacements, hasInline }; + return { text: out, blockReplacements, blockSources, hasInline }; +} + +/** One generated block span rendered on its own, in its disclosure wrapper. */ +export function renderGeneratedBlock(md: string): string { + return `
${renderMarkdown(md)}
`; } /** Splice annotateGenerated()'s placeholders/sentinels into already-rendered HTML. */ diff --git a/src/transclusion.ts b/src/transclusion.ts index 8decace..fafb714 100644 --- a/src/transclusion.ts +++ b/src/transclusion.ts @@ -2,7 +2,7 @@ // locked protocol surface: do not modify without Fable + Venkat. import { attachedQuote } from "./directives.ts"; -import { codeRanges, htmlCodeRanges, inRanges, lineOffsets, type Range } from "./code-ranges.ts"; +import { codeRanges, inRanges, lineOffsets } from "./code-ranges.ts"; import { sanitizeHtml } from "./importer/sanitize.ts"; import { blygItemUrl } from "./importer/util.ts"; import { renderMarkdown, selectionText } from "./markdown.ts"; @@ -10,6 +10,7 @@ import type { ImportedItemRow, ItemRow, TextQuoteSelector, Transclusion, Version import { absolutizeHtml, escapeHtml } from "./util.ts"; import { DIRECTIVE_LINE, label, LINK_INLINE, RESERVED_LINE } from "./grammar.ts"; import { linkLabel } from "./link-label.ts"; +import { renderGeneratedBlock } from "./tk.ts"; /** * `[[id]]` — a plain internal link (§10.1, decision #32). The same bracket @@ -430,13 +431,17 @@ function anchorText(target: ResolvedTarget): string { * * `ourOrigin` must be absolute: `content_html` travels to subscribers, where a * relative href would resolve against *their* origin and point at nothing. + * + * `contentMd` is always markdown, never rendered HTML: a label is the author's + * plain text, and HTML has already had markdown applied to it (`*em*` is + * `` by then) and its `<`/`&` escaped. Generated blocks are resolved over + * their own markdown for that reason — see `resolveBlockLinks`. */ export async function resolveInternalLinks( db: D1Database, contentMd: string, ourOrigin: string, onError: (err: TransclusionRefError) => string | null = () => null, - inert: (text: string) => Range[] = codeRanges, ): Promise { const errors: TransclusionRefError[] = []; const replacements = new Map(); @@ -444,10 +449,21 @@ export async function resolveInternalLinks( let out = ""; let last = 0; - const code = inert(contentMd); + // A label is text, not markdown, so a backtick in one must not open a code + // span that swallows a later link. Each live link's label is blanked (same + // length, so offsets hold) and code found again. Left to right, so a span + // opened *before* a link still makes it inert, as markdown's own precedence + // has it (§10.1, #54). + let scanned = contentMd; + let code = codeRanges(scanned); for (const m of contentMd.matchAll(LINK_INLINE)) { const at = m.index ?? 0; if (inRanges(code, at)) continue; // inert inside code (§10.1, #54) + if (m[2]?.includes("`")) { + const end = at + m[0].length - 2; // the label runs up to the closing `]]` + scanned = scanned.slice(0, end - m[2].length) + " ".repeat(m[2].length) + scanned.slice(end); + code = codeRanges(scanned); + } out += contentMd.slice(last, at); last = at + m[0].length; const id = m[1]; @@ -498,10 +514,13 @@ const ENCODED_TOKEN = new RegExp(`${ENCODED_SENTINEL}(\\d+)${ENCODED_SENTINEL}`, */ export function applyInternalLinks(html: string, doc: InternalLinkDocument): string { const label = (seq: string) => doc.labels.get(`${LINK_SENTINEL}${seq}${LINK_SENTINEL}`); - // 1. Tokens percent-encoded into attribute values: the author's literal, encoded the way the URL around it was. + // 1. Tokens percent-encoded into attribute values: the author's literal, + // encoded the way the URL around it was, and escaped like any attribute + // value — `encodeURI` keeps `&`, which a browser would read as an entity. + const inHref = (literal: string) => escapeHtml(encodeURI(literal)); let out = html.replace(ENCODED_TOKEN, (whole, seq: string) => { const l = label(seq); - return l ? encodeURI(l.literal) : whole; + return l ? inHref(l.literal) : whole; }); // 2. Walk tags and text, tracking whether we are inside an anchor or code. const parts = out.split(/(<[^>]*>)/); @@ -523,7 +542,7 @@ export function applyInternalLinks(html: string, doc: InternalLinkDocument): str if (isTag) return escapeHtml(l.literal); if (inCode) return escapeHtml(l.literal); // An autolink's text is its URL: keep it reading as the URL it links to. - if (inAnchor) return anchorTag.includes(encodeURI(l.literal)) ? escapeHtml(l.literal) : l.text; + if (inAnchor) return anchorTag.includes(inHref(l.literal)) ? escapeHtml(l.literal) : l.text; return doc.replacements.get(token) ?? whole; }); if (isTag && /^]/i.test(part)) { inAnchor++; anchorTag = parts[i]; } @@ -536,18 +555,22 @@ export function applyInternalLinks(html: string, doc: InternalLinkDocument): str * block span is lifted out of the body and rendered on its own, so the body's * pass never sees it). Publish and preview both call this, so the preview shows * the links readers will get, and an unresolvable one is an error in both - * places (studio#14). The input is rendered HTML, so code is ``. + * places (studio#14). + * + * Resolved over each block's markdown, as the body is, and the block rendered + * again from the substituted text: resolving over its HTML would take a label + * that markdown had already rendered and escaped, and escape it a second time. */ export async function resolveBlockLinks( - blocks: Map, - resolve: (html: string) => Promise, + blocks: { blockReplacements: Map; blockSources: Map }, + resolve: (md: string) => Promise, ): Promise<{ docs: InternalLinkDocument[]; errors: TransclusionRefError[] }> { const docs: InternalLinkDocument[] = []; const errors: TransclusionRefError[] = []; - for (const [token, blockHtml] of blocks) { - const doc = await resolve(blockHtml); + for (const [token, blockMd] of blocks.blockSources) { + const doc = await resolve(blockMd); if (doc.replacements.size || doc.errors.length) { - blocks.set(token, doc.text); + blocks.blockReplacements.set(token, renderGeneratedBlock(doc.text)); errors.push(...doc.errors); docs.push(doc); } @@ -560,12 +583,11 @@ export async function resolveBlockLinks( * the composer shows what publish will reject before the author hits publish — * the same contract as `previewTransclusions`. */ -export async function previewInternalLinks(db: D1Database, contentMd: string, ourOrigin: string, html = false): Promise { +export async function previewInternalLinks(db: D1Database, contentMd: string, ourOrigin: string): Promise { return resolveInternalLinks( db, contentMd, ourOrigin, (err) => `⚠ ${escapeHtml(err.reason)}`, - html ? htmlCodeRanges : codeRanges, ); } diff --git a/src/ui/stub-quote.ts b/src/ui/stub-quote.ts index bd90912..5dae252 100644 --- a/src/ui/stub-quote.ts +++ b/src/ui/stub-quote.ts @@ -15,7 +15,11 @@ import { attachedQuote } from '../directives.ts'; import { normalizeSelection, quoteLines } from '../markdown.ts'; -/** Where the stub's quote of `id` is: its directive line and the line after its attached quote. */ +/** + * Where the stub's quote of `id` is: its directive line (index, and the line + * as written, so a rewrite keeps its label — decision #70) and the line after + * its attached quote. + */ function findQuote(md: string, id: string) { const lines = md.split('\n'); const inert = codeRanges(md); @@ -24,7 +28,7 @@ function findQuote(md: string, id: string) { if (inRanges(inert, starts[i])) continue; if (DIRECTIVE_LINE.exec(lines[i])?.[1] !== id) continue; const { quoteMd, next } = attachedQuote(lines, i); - return { lines, at: i, next, partial: quoteMd !== null }; + return { lines, at: i, directive: lines[i], next, partial: quoteMd !== null }; } return null; } @@ -78,8 +82,9 @@ export function addStubQuote(md: string, id: string, passage: string, at: number */ export function withStubQuote(md: string, id: string, passage: string | null): string { const selection = passage === null ? '' : normalizeSelection(passage); - const block = [`![[${id}]]`, ...(selection ? [quoteLines(selection)] : [])].join('\n'); const found = findQuote(md, id); + const directive = found?.directive ?? `![[${id}]]`; + const block = [directive, ...(selection ? [quoteLines(selection)] : [])].join('\n'); if (!found) return `${block}\n\n${md.replace(/^\n+/, '')}`; const { lines, at, next } = found; return [...lines.slice(0, at), block, ...lines.slice(next)].join('\n'); diff --git a/test/label-grammar-repros.test.ts b/test/label-grammar-edge-cases.test.ts similarity index 82% rename from test/label-grammar-repros.test.ts rename to test/label-grammar-edge-cases.test.ts index 61da819..b8ab211 100644 --- a/test/label-grammar-repros.test.ts +++ b/test/label-grammar-edge-cases.test.ts @@ -1,6 +1,7 @@ -// Reproductions for six suspected label-grammar bugs reported by Burrow (a -// port of this renderer). Each test asserts the CORRECT behaviour, so a test -// that fails on main confirms the bug. Nothing here is a fix. +// Edge cases of the label grammar (0.4 §10.1, decision #70), kept as regression +// coverage for six bugs in 0.47.0 found by Burrow, a port of this renderer. +// Each describe names the bug it pins; each test asserts the correct +// behaviour, so a regression reads as the bug it brings back. import { describe, expect, it } from "vitest"; import { DIRECTIVE_LINE, label, linkInline, linkSource } from "../src/grammar.ts"; import { unrequestedOutputDirectives } from "../src/tk.ts"; @@ -20,7 +21,7 @@ async function publish(cookie: string, md: string, kind: "fragment" | "thread" = const anchorText = (html: string, target: string) => new RegExp(`
([\\s\\S]*?)`).exec(html)?.[1]; -describe("1. a label inside block TK output is escaped once, as in the body", () => { +describe("a label inside block TK output is escaped once, as in the body", () => { it("block-scope anchor text equals body anchor text for the same label", async () => { const cookie = await login(); await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); @@ -49,7 +50,7 @@ describe("1. a label inside block TK output is escaped once, as in the body", () }); }); -describe("2. a backtick inside a label does not make a later link inert", () => { +describe("a backtick inside a label does not make a later link inert", () => { it("the second link on the line still renders as a link", async () => { const cookie = await login(); await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); @@ -61,9 +62,19 @@ describe("2. a backtick inside a label does not make a later link inert", () => expect(doc.content_html).toContain(`Beta`); expect(doc.content_html).not.toContain(`[[${b}]]`); }); + + it("a code span opened before a link still wins where it closes inside the label", async () => { + const cookie = await login(); + await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); + const a = await createAndPublish(cookie, "# Alpha\n\nBody."); + const { res, doc } = await publish(cookie, `See \`x [[${a} | y\`]] after.`, "fragment"); + expect(res.status, JSON.stringify(res.json)).toBe(200); + expect(doc.content_html).toContain(`x [[${a} | y`); + expect(doc.content_html).not.toContain(" parse round-trips the label", () => { +describe("linkSource -> parse round-trips a label holding runs of ]", () => { it.each(["a]]]b", "a]]]]b", "x]]]"])("label %s survives as a label with no ]] cut", (text) => { const src = linkSource(ID, text); const parsed = [...src.matchAll(linkInline())].map((m) => ({ id: m[1], label: label(m[2]), whole: m[0] })); @@ -73,7 +84,7 @@ describe("3. linkSource -> parse round-trips the label", () => { }); }); -describe("4. an autolinked URL holding a labelled link escapes & in its href", () => { +describe("an autolinked URL holding a labelled link escapes & in its href", () => { it("the href attribute carries &, never a raw &", async () => { const cookie = await login(); await apiJson(cookie, "PATCH", "/api/settings", { site_url: OURS }); @@ -89,7 +100,7 @@ describe("4. an autolinked URL holding a labelled link escapes & in its href", ( }); }); -describe("5. choosing a passage keeps the directive's label", () => { +describe("choosing a passage in a stub keeps the directive's label", () => { it("withStubQuote preserves `![[id | label]]`", () => { const md = `![[${ID} | their post]]\n\nMy reply.`; const out = withStubQuote(md, ID, "a passage"); @@ -100,7 +111,7 @@ describe("5. choosing a passage keeps the directive's label", () => { }); }); -describe("6. directiveOwnLine ([ \\t]) vs DIRECTIVE_LINE (\\s)", () => { +describe("an own-line directive in TK output takes the same whitespace as DIRECTIVE_LINE", () => { it.each([ ["NBSP before", " "], ["form feed before", "\f"],