From 09d3d4bc674bc1fe624ad2ce93bdc89229f1f363 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:15:10 +0530 Subject: [PATCH 1/7] Escape brackets in raw wiki article labels _md_link already escapes a label before it ever becomes link text, because a node label is extracted source content and can literally contain a bracketed substring, a doc that discusses or demonstrates wikilink syntax being the obvious source. Three other places print a label straight into an article body without going through that escaping at all, a community's own title, its Key Concepts node listing, and a god node's own title, so a label like double bracket wikilink double bracket rendered as a real, and always dead since the wiki export never writes that link form, wikilink instead of the plain text it actually is. Pulls the escaping out of _md_link into a small shared helper and applies it at all three sites. Fixes the first of two bugs in #3547. The second bug the report describes, a wikilink target left percent encoded while the file written for it is not, was already fixed on this branch's base by number 2597, confirmed directly: _safe_filename strips everything that would need encoding from a slug before it is ever used as either the link target or the filename, so raw emission and the on disk name are the same string by construction. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/wiki.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/graphify/wiki.py b/graphify/wiki.py index 9eef581c52..698f5516b8 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -56,6 +56,20 @@ def _safe_filename(name: str, limit: int = 200) -> str: return s[:limit] if s else 'unnamed' +def _escape_md_brackets(text: str) -> str: + """Escape `[`/`]` so raw text embedded in a generated article can never be + misread as markdown/Obsidian link syntax (#3547). + + A node's own label is source content (an extracted heading, identifier, or + doc excerpt) and occasionally contains a literal ``[[...]]`` substring — + e.g. a doc that itself explains or demonstrates wikilink syntax. Printed + unescaped, that string renders as a real (and always dead — the wiki + export never writes bracket-style links, see ``_md_link``) wikilink + instead of the plain text it actually is. + """ + return text.replace("[", r"\[").replace("]", r"\]") + + def _md_link(label: str, resolver: dict[str, str]) -> str: """Render a link to another wiki article as a portable relative markdown link. @@ -83,7 +97,7 @@ def _md_link(label: str, resolver: dict[str, str]) -> str: god nodes get article files — render as plain text instead of a dead link that points nowhere even inside Obsidian. """ - text = label.replace("[", r"\[").replace("]", r"\]") + text = _escape_md_brackets(label) slug = resolver.get(label) if slug is None: return text @@ -137,7 +151,7 @@ def _community_article( sources = sorted({G.nodes[n].get("source_file") or "" for n in nodes} - {""}) lines: list[str] = [] - lines += [f"# {label}", ""] + lines += [f"# {_escape_md_brackets(label)}", ""] meta_parts = [f"{len(nodes)} nodes"] if cohesion is not None: @@ -147,7 +161,7 @@ def _community_article( lines += ["## Key Concepts", ""] for nid in top_nodes: d = G.nodes[nid] - node_label = d.get("label", nid) + node_label = _escape_md_brackets(d.get("label", nid)) src = d.get("source_file", "") degree = G.degree(nid) src_str = f" — `{src}`" if src else "" @@ -185,7 +199,7 @@ def _community_article( def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str], node_community: dict[str, int] | None = None, resolver: dict[str, str] | None = None) -> str: resolver = resolver or {} d = G.nodes[nid] - node_label = d.get("label", nid) + node_label = _escape_md_brackets(d.get("label", nid)) src = d.get("source_file", "") cid = (node_community or {}).get(nid) community_name = labels.get(cid, f"Community {cid}") if cid is not None else None From 72d3c2df2b8b0c08a0007fb74ac84b33cddc5218 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:15:17 +0530 Subject: [PATCH 2/7] Add regression tests for the wiki bracket escaping fix Covers the helper directly, each of the three fixed print sites, and an end to end export using the exact placeholder text the report listed, double bracket link double bracket and double bracket new underscore stem double bracket among them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_wiki_label_bracket_escaping.py | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_wiki_label_bracket_escaping.py diff --git a/tests/test_wiki_label_bracket_escaping.py b/tests/test_wiki_label_bracket_escaping.py new file mode 100644 index 0000000000..650eabdc87 --- /dev/null +++ b/tests/test_wiki_label_bracket_escaping.py @@ -0,0 +1,72 @@ +"""Regression tests for issue #3547: a node label that literally contains +`[[...]]` (extracted from source content — a doc discussing or demonstrating +wikilink syntax, for example) must not be printed raw into a generated +article. `_md_link` already escapes `[`/`]` for anything it links, but three +other sites print a label directly into the article body without going +through it: a community's own title, its "Key Concepts" node listing, and a +god node's own title. Printed unescaped, `[[wikilink]]` renders as a real +(and always dead — the wiki export never writes bracket-style links) wikilink +instead of the plain text it actually is. +""" +from __future__ import annotations + +import networkx as nx + +from graphify.wiki import _community_article, _god_node_article, _escape_md_brackets, to_wiki + + +def test_escape_md_brackets_escapes_both_brackets(): + assert _escape_md_brackets("[[wikilink]]") == r"\[\[wikilink\]\]" + assert _escape_md_brackets("plain text") == "plain text" + + +def test_community_title_escapes_bracket_label(): + G = nx.Graph() + G.add_node("n1", label="sym", file_type="code", source_file="a.py") + article = _community_article(G, 0, ["n1"], "[[Foo Bar Baz]]", {0: "[[Foo Bar Baz]]"}, + None, {"n1": 0}, {}) + assert "# \\[\\[Foo Bar Baz\\]\\]" in article + assert "# [[Foo Bar Baz]]" not in article + + +def test_community_key_concepts_escapes_node_label(): + G = nx.Graph() + G.add_node("n1", label="[[wikilink]]", file_type="concept", source_file="doc.md") + G.add_node("n2", label="ordinary", file_type="code", source_file="a.py") + G.add_edge("n1", "n2", relation="related") + article = _community_article(G, 0, ["n1", "n2"], "Community 0", {0: "Community 0"}, + None, {"n1": 0, "n2": 0}, {}) + assert r"\[\[wikilink\]\]" in article + assert "[[wikilink]]" not in article + + +def test_god_node_title_escapes_bracket_label(): + G = nx.Graph() + G.add_node("n1", label="[[...]]", file_type="concept", source_file="doc.md") + G.add_node("n2", label="caller", file_type="code", source_file="a.py") + G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED") + article = _god_node_article(G, "n1", {0: "Community 0"}, {"n1": 0, "n2": 0}, {}) + assert "# \\[\\[...\\]\\]" in article # only [ and ] are escaped, not the dots + assert "# [[...]]" not in article + + +def test_end_to_end_wiki_export_has_no_literal_wikilinks(tmp_path): + """A label with content that resembles wikilink placeholder text ("[[link]]", + "[[new_stem]]", empty "[[]]", per the issue's own examples) must not survive + verbatim into any generated page.""" + G = nx.Graph() + G.add_node("n1", label="[[link]]", file_type="concept", source_file="a.md", community=0) + G.add_node("n2", label="[[new_stem]]", file_type="concept", source_file="a.md", community=0) + G.add_node("n3", label="[[]]", file_type="concept", source_file="b.md", community=0) + G.add_edge("n1", "n2", relation="related") + G.add_edge("n2", "n3", relation="related") + communities = {0: ["n1", "n2", "n3"]} + + out = tmp_path / "wiki" + to_wiki(G, communities, out, community_labels={0: "Community 0"}) + + for md in out.glob("*.md"): + text = md.read_text(encoding="utf-8") + assert "[[link]]" not in text, f"{md.name} still has a literal placeholder wikilink" + assert "[[new_stem]]" not in text, f"{md.name} still has a literal placeholder wikilink" + assert "[[]]" not in text, f"{md.name} still has a literal empty wikilink" From e030abce90b73eda205ea9985a7e6d442aa85cb7 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:14:46 +0530 Subject: [PATCH 3/7] Fix crash on a non string node id fallback in bracket escaping Addresses review feedback on PR 3550: the graphify review bot's formal verifier found a concrete input on which _community_article started raising AttributeError after this PR's escaping change. _escape_md_brackets called replace directly on its argument. Every caller passes d.get("label", nid), so a node whose dict has no label attribute falls back to its own networkx node id, which is not always a string (int and tuple ids are legal). The previous code interpolated that fallback into an f string, which stringifies anything; wrapping it in _escape_md_brackets turned that implicit coercion into a hard requirement the fallback does not always meet. Coercing with str() first restores the old behavior for a non string fallback while keeping the escaping for genuine string labels. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/wiki.py | 9 +++++++-- tests/test_wiki_label_bracket_escaping.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/graphify/wiki.py b/graphify/wiki.py index 698f5516b8..1039b77f6c 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -56,7 +56,7 @@ def _safe_filename(name: str, limit: int = 200) -> str: return s[:limit] if s else 'unnamed' -def _escape_md_brackets(text: str) -> str: +def _escape_md_brackets(text: object) -> str: """Escape `[`/`]` so raw text embedded in a generated article can never be misread as markdown/Obsidian link syntax (#3547). @@ -66,8 +66,13 @@ def _escape_md_brackets(text: str) -> str: unescaped, that string renders as a real (and always dead — the wiki export never writes bracket-style links, see ``_md_link``) wikilink instead of the plain text it actually is. + + Callers fall back to a node's own id when it has no ``label`` attribute, + and a networkx node id is not always a string (an int or a tuple id is + legal). ``str()`` first so that fallback stringifies exactly like the + plain f-interpolation this call replaced, instead of raising. """ - return text.replace("[", r"\[").replace("]", r"\]") + return str(text).replace("[", r"\[").replace("]", r"\]") def _md_link(label: str, resolver: dict[str, str]) -> str: diff --git a/tests/test_wiki_label_bracket_escaping.py b/tests/test_wiki_label_bracket_escaping.py index 650eabdc87..c0f6c9c2d7 100644 --- a/tests/test_wiki_label_bracket_escaping.py +++ b/tests/test_wiki_label_bracket_escaping.py @@ -20,6 +20,23 @@ def test_escape_md_brackets_escapes_both_brackets(): assert _escape_md_brackets("plain text") == "plain text" +def test_escape_md_brackets_stringifies_a_non_string_node_id_fallback(): + # A networkx node id is not always a string (int and tuple ids are + # legal). The label callers fall back to a node's own id when it has no + # `label` attribute, so this must stringify instead of raising. + assert _escape_md_brackets(1) == "1" + assert _escape_md_brackets(("a", "b")) == "('a', 'b')" + + +def test_community_article_handles_a_node_with_no_label_and_a_non_string_id(): + G = nx.Graph() + G.add_nodes_from([(1, {}), (2, {}), (3, {})]) + G.add_edges_from([(1, 2, {}), (1, 3, {}), (2, 3, {})]) + article = _community_article(G, 0, [1, 2, 3], "hello world", {0: "hello world"}, + None, {1: 0, 2: 0, 3: 0}, {}) + assert "**1**" in article + + def test_community_title_escapes_bracket_label(): G = nx.Graph() G.add_node("n1", label="sym", file_type="code", source_file="a.py") From 85d212230b89ca48c6780bd25a4c37c62d37c59f Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:25:16 +0530 Subject: [PATCH 4/7] Escape backslashes before brackets to close a review finding Addresses a review finding on PR 3550: the graphify review bot flagged that a pre existing backslash right before a bracket in source content can bypass the bracket escaping. _escape_md_brackets escaped only the two bracket characters. Source content occasionally already contains a literal backslash right before a bracket, a doc excerpt showing a regex character class like backslash close bracket plus, for example. Escaping the bracket alone turned that into two backslashes then a bare bracket, and CommonMark reads a doubled backslash as one literal backslash, which consumes the pair and leaves the following bracket unescaped and live as link syntax again. Escaping every backslash in the source text first, before the brackets, keeps the bracket's own escape intact regardless of what already preceded it. Added a regression test with a small simulator of CommonMark's own left to right backslash escape unwinding, so the assertion checks the actual syntactic property (no bracket reachable as bare, unescaped) rather than just comparing strings. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/wiki.py | 18 +++++++++++++-- tests/test_wiki_label_bracket_escaping.py | 28 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/graphify/wiki.py b/graphify/wiki.py index 1039b77f6c..c98e487739 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -57,7 +57,7 @@ def _safe_filename(name: str, limit: int = 200) -> str: def _escape_md_brackets(text: object) -> str: - """Escape `[`/`]` so raw text embedded in a generated article can never be + r"""Escape `[`/`]` so raw text embedded in a generated article can never be misread as markdown/Obsidian link syntax (#3547). A node's own label is source content (an extracted heading, identifier, or @@ -71,8 +71,22 @@ def _escape_md_brackets(text: object) -> str: and a networkx node id is not always a string (an int or a tuple id is legal). ``str()`` first so that fallback stringifies exactly like the plain f-interpolation this call replaced, instead of raising. + + Backslashes in the SOURCE text are escaped first, before brackets. Source + content occasionally already contains a literal backslash right before a + bracket (a doc excerpt showing a regex character class, ``\]+``, for + example). Escaping brackets alone turns that into ``\\]`` — two + backslashes then a bare bracket — and CommonMark reads a doubled + backslash as one literal backslash, which un-escapes the bracket right + back into live link syntax. Escaping the backslash itself first keeps the + bracket's own escape intact regardless of what already preceded it. """ - return str(text).replace("[", r"\[").replace("]", r"\]") + return ( + str(text) + .replace("\\", "\\\\") + .replace("[", r"\[") + .replace("]", r"\]") + ) def _md_link(label: str, resolver: dict[str, str]) -> str: diff --git a/tests/test_wiki_label_bracket_escaping.py b/tests/test_wiki_label_bracket_escaping.py index c0f6c9c2d7..af93861283 100644 --- a/tests/test_wiki_label_bracket_escaping.py +++ b/tests/test_wiki_label_bracket_escaping.py @@ -20,6 +20,34 @@ def test_escape_md_brackets_escapes_both_brackets(): assert _escape_md_brackets("plain text") == "plain text" +def _bracket_is_live_after_escaping(text: str) -> bool: + """True if unwinding CommonMark backslash-escapes left to right (a `\\X` + pair consumes both characters and yields one INERT literal `X`) leaves any + `[`/`]` reachable as a BARE, unpaired character -- i.e. still able to act + as link syntax rather than literal text.""" + i = 0 + while i < len(text): + if text[i] == "\\" and i + 1 < len(text): + i += 2 # the pair is consumed together; its second char is inert + continue + if text[i] in "[]": + return True + i += 1 + return False + + +def test_escape_md_brackets_escapes_a_pre_existing_backslash_before_a_bracket(): + # A source label can already contain a literal backslash right before a + # bracket (a doc excerpt showing a regex character class, `\]+`, for + # example). Escaping the bracket alone would turn it into `\\]` -- CommonMark + # reads the doubled backslash as one literal backslash, which un-escapes + # the bracket right back into live syntax. The backslash must be escaped + # first so the bracket's own escape survives. + escaped = _escape_md_brackets("regex: \\]+") + assert escaped == "regex: \\\\\\]+" + assert not _bracket_is_live_after_escaping(escaped) + + def test_escape_md_brackets_stringifies_a_non_string_node_id_fallback(): # A networkx node id is not always a string (int and tuple ids are # legal). The label callers fall back to a node's own id when it has no From ecc031d893705141d14a3dae1508e6a94adf7e4c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Wed, 16 Sep 2026 16:11:10 +0530 Subject: [PATCH 5/7] Add a regression test for a bare bracket community title A community label that is just a bracket character exercises _community_article's own title heading, not a link target the existing bracketed label test already covers, and must render escaped there too or the lone bracket opens markdown link or image syntax the rest of the line never closes. Matches the exact shape a formal verification pass on this PR reproduced. Co-Authored-By: Claude Sonnet 5 --- tests/test_wiki.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 130da843dc..d5ae0c019b 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -392,6 +392,22 @@ def test_wiki_link_with_bracketed_label_resolves(tmp_path): assert (tmp_path / "Array[T]_Models.md").exists() +def test_community_article_title_escapes_a_bare_bracket_label(tmp_path): + """A community label that is JUST a bracket character (`_community_article`'s + own `# {label}` title heading, not a link target) must render escaped too, + or the lone `[` opens a markdown link/image syntax the rest of the line + never closes.""" + G = nx.Graph() + G.add_node(1, label="a", file_type="code", source_file="a.py", community=0) + G.add_node(2, label="b", file_type="code", source_file="b.py", community=0) + G.add_node(3, label="c", file_type="code", source_file="c.py", community=0) + G.add_edge(1, 2, relation="references", confidence="INFERRED", weight=1.0) + G.add_edge(1, 3, relation="references", confidence="INFERRED", weight=1.0) + G.add_edge(2, 3, relation="references", confidence="INFERRED", weight=1.0) + article = _community_article(G, 0, [1, 2, 3], "[", {0: "["}, 1.0) + assert article.startswith("# \\[\n") + + def test_wiki_links_to_nodes_without_articles_are_plain_text(tmp_path): """A god node links its neighbours, but only communities and god nodes get article files — neighbours without one must render as plain text, not as a From 235604d5c1615510be8a6483e204da37d08fbc78 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Wed, 16 Sep 2026 23:12:05 +0530 Subject: [PATCH 6/7] Only double a backslash that precedes a bracket Doubling every backslash in the source text, not just ones right before a bracket, un-escaped any OTHER pre-existing markdown escape already in that text. A source string like a literal asterisk written as backslash asterisk survived as backslash asterisk before this change; doubled unconditionally it became two backslashes then a bare asterisk, and CommonMark reads two backslashes as one literal backslash followed by a now live, unescaped asterisk. Only doubling a backslash that precedes a bracket keeps the one case this handling exists for intact without touching anything else in the text. Co-Authored-By: Claude Sonnet 5 --- graphify/wiki.py | 23 +++++++++++++---------- tests/test_wiki.py | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/graphify/wiki.py b/graphify/wiki.py index c98e487739..e8e80cc5fb 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -72,18 +72,21 @@ def _escape_md_brackets(text: object) -> str: legal). ``str()`` first so that fallback stringifies exactly like the plain f-interpolation this call replaced, instead of raising. - Backslashes in the SOURCE text are escaped first, before brackets. Source - content occasionally already contains a literal backslash right before a - bracket (a doc excerpt showing a regex character class, ``\]+``, for - example). Escaping brackets alone turns that into ``\\]`` — two - backslashes then a bare bracket — and CommonMark reads a doubled - backslash as one literal backslash, which un-escapes the bracket right - back into live link syntax. Escaping the backslash itself first keeps the - bracket's own escape intact regardless of what already preceded it. + A backslash immediately before a bracket in the SOURCE text is escaped + first, before the bracket. Source content occasionally already contains + a literal backslash right before a bracket (a doc excerpt showing a + regex character class, ``\]+``, for example). Escaping the bracket alone + turns that into ``\\]`` — two backslashes then a bare bracket — and + CommonMark reads a doubled backslash as one literal backslash, which + un-escapes the bracket right back into live link syntax. Doubling only a + backslash that precedes a bracket (not every backslash in the text) + keeps the bracket's own escape intact without touching an unrelated + pre-existing escape elsewhere in the source (``\*`` meaning a literal + asterisk, doubled unconditionally, would itself un-escape into a bare, + newly-live ``*``). """ return ( - str(text) - .replace("\\", "\\\\") + re.sub(r"\\(?=[\[\]])", r"\\\\", str(text)) .replace("[", r"\[") .replace("]", r"\]") ) diff --git a/tests/test_wiki.py b/tests/test_wiki.py index d5ae0c019b..b1976fff51 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -3,7 +3,7 @@ import pytest from pathlib import Path import networkx as nx -from graphify.wiki import to_wiki, _index_md, _community_article, _god_node_article +from graphify.wiki import to_wiki, _index_md, _community_article, _god_node_article, _escape_md_brackets _MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") @@ -408,6 +408,24 @@ def test_community_article_title_escapes_a_bare_bracket_label(tmp_path): assert article.startswith("# \\[\n") +def test_escape_md_brackets_leaves_an_unrelated_escape_alone(): + """A backslash that precedes something other than a bracket (`\\*`, + escaping a literal asterisk so it doesn't open emphasis) must survive + unchanged. Doubling every backslash unconditionally -- rather than only + ones that precede a bracket -- would itself un-escape that unrelated + escape: `\\*` doubled becomes `\\\\*`, and CommonMark reads `\\\\` as one + literal backslash followed by a bare, newly-live `*`.""" + assert _escape_md_brackets(r"\*bold*\ ") == r"\*bold*\ " + + +def test_escape_md_brackets_still_escapes_a_backslash_before_a_bracket(): + """The one case the backslash handling exists for is unaffected: a + backslash already sitting right before a bracket in source (a regex + character class, `\\]+`) still round-trips as a literal backslash + followed by a literal bracket once escaped.""" + assert _escape_md_brackets(r"\]+") == r"\\\]+" + + def test_wiki_links_to_nodes_without_articles_are_plain_text(tmp_path): """A god node links its neighbours, but only communities and god nodes get article files — neighbours without one must render as plain text, not as a From 89477d0a77670c73388e4a2f4b3950e7c68f2f9f Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Wed, 16 Sep 2026 23:48:10 +0530 Subject: [PATCH 7/7] Double the whole backslash run before a bracket, not just the last one Doubling only the single backslash immediately adjacent to a bracket left an earlier backslash in a longer run unpaired. Two source backslashes right before a bracket produced four escaped ones with nothing left to escape the bracket itself, so CommonMark read that as two literal backslashes followed by a bare, newly live bracket, un-escaping it right back into link syntax, exactly the failure this whole helper exists to prevent. A single regex pass now matches the entire run of backslashes ahead of a bracket, doubles all of it, then adds one more backslash for the bracket's own escape, so any run length still leaves the bracket correctly escaped. Co-Authored-By: Claude Sonnet 5 --- graphify/wiki.py | 40 +++++++++++++++++++++++----------------- tests/test_wiki.py | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/graphify/wiki.py b/graphify/wiki.py index e8e80cc5fb..f660d1dbdf 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -72,24 +72,30 @@ def _escape_md_brackets(text: object) -> str: legal). ``str()`` first so that fallback stringifies exactly like the plain f-interpolation this call replaced, instead of raising. - A backslash immediately before a bracket in the SOURCE text is escaped - first, before the bracket. Source content occasionally already contains - a literal backslash right before a bracket (a doc excerpt showing a - regex character class, ``\]+``, for example). Escaping the bracket alone - turns that into ``\\]`` — two backslashes then a bare bracket — and - CommonMark reads a doubled backslash as one literal backslash, which - un-escapes the bracket right back into live link syntax. Doubling only a - backslash that precedes a bracket (not every backslash in the text) - keeps the bracket's own escape intact without touching an unrelated - pre-existing escape elsewhere in the source (``\*`` meaning a literal - asterisk, doubled unconditionally, would itself un-escape into a bare, - newly-live ``*``). + A RUN of one or more backslashes immediately before a bracket in the + SOURCE text is doubled first, before the bracket is escaped. Source + content occasionally already contains one or more literal backslashes + right before a bracket (a doc excerpt showing a regex character class, + ``\]+``, or an escaped backslash inside one, ``[\\]``, for example). + Doubling only the LAST backslash of a run, rather than every backslash + in the run, still leaves the earlier ones unpaired: two source + backslashes before a bracket produced four backslashes then a bare + bracket, and CommonMark reads two backslash pairs as two literal + backslashes with nothing left to escape the bracket, un-escaping it + right back into live link syntax. The whole run must double, then get + one more backslash for the bracket's own escape, in a single pass — + two separate ``.replace()`` calls for the brackets can't do this, since + the second call has no way to know which backslashes a previous + replacement result already produced versus which were in the run. + Matching (and touching) only a run that actually precedes a bracket + keeps an unrelated pre-existing escape elsewhere in the source intact + (``\*`` meaning a literal asterisk, doubled unconditionally, would + itself un-escape into a bare, newly-live ``*``). """ - return ( - re.sub(r"\\(?=[\[\]])", r"\\\\", str(text)) - .replace("[", r"\[") - .replace("]", r"\]") - ) + def _double_and_escape(m: "re.Match[str]") -> str: + return m.group(1) * 2 + "\\" + m.group(2) + + return re.sub(r"(\\*)([\[\]])", _double_and_escape, str(text)) def _md_link(label: str, resolver: dict[str, str]) -> str: diff --git a/tests/test_wiki.py b/tests/test_wiki.py index b1976fff51..05748fd123 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -426,6 +426,21 @@ def test_escape_md_brackets_still_escapes_a_backslash_before_a_bracket(): assert _escape_md_brackets(r"\]+") == r"\\\]+" +def test_escape_md_brackets_handles_a_run_of_backslashes_before_a_bracket(): + """A RUN of two or more backslashes right before a bracket in source (an + escaped backslash inside a regex character class, `[\\]`, for example) + must still leave the bracket escaped. Doubling only the single backslash + immediately adjacent to the bracket (rather than the whole run) left the + earlier backslashes in the run unpaired: two source backslashes produced + four escaped ones with nothing left to escape the bracket itself, so + CommonMark read it as two literal backslashes followed by a BARE, + newly-live bracket -- un-escaping it right back into link syntax.""" + # Two backslashes + a close bracket: CommonMark must read this back as + # two literal backslashes followed by one literal (escaped) bracket, five + # backslashes then the bracket (2 pairs + 1 leftover that pairs with it). + assert _escape_md_brackets("\\" * 2 + "]") == "\\" * 5 + "]" + + def test_wiki_links_to_nodes_without_articles_are_plain_text(tmp_path): """A god node links its neighbours, but only communities and god nodes get article files — neighbours without one must render as plain text, not as a