Skip to content

Commit a010b08

Browse files
committed
fix: use single-pass fenced-code + HTML comment stripping
Replace separate .replace() calls for fenced code blocks and HTML comments with a combined alternation pattern (FENCED_OR_COMMENT_PATTERN). The previous two-pass approach had an ordering problem: stripping HTML comments first could eat a real link when a <!-- opener appeared inside a fenced code block, and stripping fenced code first could eat a link when a backtick fence appeared inside an HTML comment. The single-pass alternation lets the regex engine pick whichever construct starts first at each position, matching CommonMark precedence. Also adds a regression test for the <!-- inside fenced code case and relaxes the mixed-content link order assertion (extraction order is an implementation detail, not a contract). Addresses ClawSweeper review feedback on strip-order concern.
1 parent 3aa01b3 commit a010b08

2 files changed

Lines changed: 26 additions & 29 deletions

File tree

extensions/memory-wiki/src/markdown.test.ts

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,7 @@ describe("extractWikiLinks", () => {
6767
});
6868

6969
it("ignores links inside longer fences", () => {
70-
const md = [
71-
"````text",
72-
"[label](target)",
73-
"````",
74-
].join("\n");
70+
const md = ["````text", "[label](target)", "````"].join("\n");
7571
expect(extractWikiLinks(md)).toEqual([]);
7672
});
7773

@@ -104,7 +100,10 @@ describe("extractWikiLinks", () => {
104100
"",
105101
"![img](image.png)",
106102
].join("\n");
107-
expect(extractWikiLinks(md)).toEqual(["sources/real-page", "sources/other-page"]);
103+
expect(extractWikiLinks(md)).toEqual(
104+
expect.arrayContaining(["sources/real-page", "sources/other-page"]),
105+
);
106+
expect(extractWikiLinks(md)).toHaveLength(2);
108107
});
109108

110109
it("skips external URLs", () => {
@@ -118,33 +117,24 @@ describe("extractWikiLinks", () => {
118117
});
119118

120119
it("ignores links in fenced blocks with longer closing fence", () => {
121-
const md = [
122-
"```py",
123-
"[fake](bad)",
124-
"````",
125-
"[real](sources/good)",
126-
].join("\n");
120+
const md = ["```py", "[fake](bad)", "````", "[real](sources/good)"].join("\n");
127121
expect(extractWikiLinks(md)).toEqual(["sources/good"]);
128122
});
129123

130124
it("ignores links in indented fenced blocks", () => {
131-
const md = [
132-
" ```py",
133-
" [fake](bad)",
134-
" ```",
135-
"[real](sources/good)",
136-
].join("\n");
125+
const md = [" ```py", " [fake](bad)", " ```", "[real](sources/good)"].join("\n");
137126
expect(extractWikiLinks(md)).toEqual(["sources/good"]);
138127
});
139128

140129
it("does not swallow real links when fence opener is inside HTML comment", () => {
141-
const md = [
142-
"<!--",
143-
"```",
144-
"-->",
145-
"[real](sources/good)",
146-
"```",
147-
].join("\n");
130+
const md = ["<!--", "```", "-->", "[real](sources/good)", "```"].join("\n");
131+
expect(extractWikiLinks(md)).toEqual(["sources/good"]);
132+
});
133+
134+
it("does not swallow real links when HTML comment opener is inside fenced code", () => {
135+
const md = ["```", "<!--", "```", "[real](sources/good)", "<!-- a proper comment -->"].join(
136+
"\n",
137+
);
148138
expect(extractWikiLinks(md)).toEqual(["sources/good"]);
149139
});
150140
});

extensions/memory-wiki/src/markdown.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,16 @@ export type WikiPageSummary = {
6262
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
6363
const OBSIDIAN_LINK_PATTERN = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
6464
const MARKDOWN_LINK_PATTERN = /\[[^\]]+\]\(([^)]+)\)/g;
65-
const FENCED_CODE_PATTERN = /^( {0,3})`{3,}[^\n]*\n[\s\S]*?\n {0,3}`{3,}\s*$/gm;
66-
const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
65+
66+
/**
67+
* Combined pattern that strips fenced code blocks and HTML comments in a
68+
* single pass so that neither construct can "shadow" the other. Because the
69+
* regex engine evaluates alternatives left-to-right at each position, the
70+
* first matching construct in the text wins – exactly the precedence
71+
* CommonMark specifies.
72+
*/
73+
const FENCED_OR_COMMENT_PATTERN =
74+
/(?:^( {0,3})`{3,}[^\n]*\n[\s\S]*?\n {0,3}`{3,}\s*$)|(?:<!--[\s\S]*?-->)/gm;
6775
const IMAGE_LINK_PATTERN = /!\[[^\]]*\]\([^)]+\)/g;
6876
const RELATED_BLOCK_PATTERN = new RegExp(
6977
`${WIKI_RELATED_START_MARKER}[\\s\\S]*?${WIKI_RELATED_END_MARKER}`,
@@ -219,8 +227,7 @@ export function normalizeWikiClaims(value: unknown): WikiClaim[] {
219227
export function extractWikiLinks(markdown: string): string[] {
220228
const searchable = markdown
221229
.replace(RELATED_BLOCK_PATTERN, "")
222-
.replace(HTML_COMMENT_PATTERN, "")
223-
.replace(FENCED_CODE_PATTERN, "")
230+
.replace(FENCED_OR_COMMENT_PATTERN, "")
224231
.replace(IMAGE_LINK_PATTERN, "");
225232
const links: string[] = [];
226233
for (const match of searchable.matchAll(OBSIDIAN_LINK_PATTERN)) {

0 commit comments

Comments
 (0)