diff --git a/crates/zensical/src/python/issues.rs b/crates/zensical/src/python/issues.rs index b7216f0..c3e5cf8 100644 --- a/crates/zensical/src/python/issues.rs +++ b/crates/zensical/src/python/issues.rs @@ -298,11 +298,15 @@ impl Issues { let base = Path::new(&base_str); for (span, href) in mappings { if let Some((path, anchor)) = href.split_once('#') { - let (anchor, len) = anchor - .split_once(":~:") - .map_or((anchor, 0), |(left, right)| { - (left, right.len() + 3) - }); + let offset = path.len(); + let path = decode_markdown_href(path); + let anchor = decode_markdown_href(anchor); + let (anchor, len) = + if let Some((left, right)) = anchor.split_once(":~:") { + (left.to_string(), right.len() + 3) + } else { + (anchor, 0) + }; // Skip empty anchors since they are technically valid if anchor.is_empty() { @@ -313,13 +317,13 @@ impl Issues { // used as a directory before checking if it looks like a // Markdown file at all, since the former is invalid which // we need to report as an invalid link - if !path.is_empty() && !is_markdown_path(path) { - if is_invalid_markdown_path(path) { + if !path.is_empty() && !is_markdown_path(&path) { + if is_invalid_markdown_path(&path) { issues.push(Issue::InvalidLink { path: base.into(), span, href: to_slash( - &resolve_relative(base, &decode_href(path)) + &resolve_relative(base, &path) .to_string_lossy(), ), }); @@ -329,22 +333,20 @@ impl Issues { // Resolve the link against the base path let link = to_slash( - &resolve_relative(base, &decode_href(path)) - .to_string_lossy(), + &resolve_relative(base, &path).to_string_lossy(), ); // Check if the link exists, and if it does, whether the // anchor exists on the target page if let Some(anchors) = anchor_map.get(&link) { - if !anchors.contains(anchor) { + if !anchors.contains(&anchor) { issues.push(Issue::InvalidLinkAnchor { path: base.into(), span: Span::from( - (span.start + path.len() + 1) - ..span.end - len, + (span.start + offset + 1)..span.end - len, ), href: href.clone(), - anchor: anchor.to_string(), + anchor: anchor.clone(), }); } } else { @@ -355,17 +357,15 @@ impl Issues { }); } } else { + let href = decode_markdown_href(&href); if !is_markdown_path(&href) { if is_invalid_markdown_path(&href) { issues.push(Issue::InvalidLink { path: base.into(), span, href: to_slash( - &resolve_relative( - base, - &decode_href(&href), - ) - .to_string_lossy(), + &resolve_relative(base, &href) + .to_string_lossy(), ), }); } @@ -373,8 +373,7 @@ impl Issues { } let link = to_slash( - &resolve_relative(base, &decode_href(&href)) - .to_string_lossy(), + &resolve_relative(base, &href).to_string_lossy(), ); if !anchor_map.contains_key(&link) { @@ -619,6 +618,51 @@ fn decode_href(href: &str) -> String { percent_decode_str(href).decode_utf8_lossy().into_owned() } +/// Decodes a Markdown link destination. +fn decode_markdown_href(href: &str) -> String { + unescape_markdown(&decode_href(href)) +} + +/// Unescapes Markdown punctuation escapes. +fn unescape_markdown(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(char) = chars.next() { + if char == '\\' + && chars + .peek() + .is_some_and(|char| is_markdown_escapable(*char)) + { + result.push(chars.next().expect("checked above")); + } else { + result.push(char); + } + } + result +} + +/// Returns whether a character can be escaped in Markdown. +fn is_markdown_escapable(char: char) -> bool { + matches!( + char, + '\\' | '`' + | '*' + | '_' + | '{' + | '}' + | '[' + | ']' + | '>' + | '(' + | ')' + | '#' + | '+' + | '-' + | '.' + | '!' + ) +} + /// Converts a path string to use forward slashes for consistent cross-platform /// map key comparisons, since markdown hrefs always use forward slashes. fn to_slash(path: &str) -> String { @@ -631,7 +675,9 @@ fn to_slash(path: &str) -> String { #[cfg(test)] mod tests { - use super::{is_invalid_markdown_path, is_markdown_path}; + use super::{ + decode_markdown_href, is_invalid_markdown_path, is_markdown_path, + }; #[test] fn markdown_path_must_end_in_md_file() { @@ -656,4 +702,12 @@ mod tests { assert!(!is_invalid_markdown_path("target/")); assert!(!is_invalid_markdown_path("target.mdx/")); } + + #[test] + fn markdown_href_decodes_escapes() { + assert_eq!(decode_markdown_href("#a\\_b"), "#a_b"); + assert_eq!(decode_markdown_href(r"a\%b"), r"a\%b"); + assert_eq!(decode_markdown_href(r"a\:b"), r"a\:b"); + assert_eq!(decode_markdown_href(r"a\qb"), r"a\qb"); + } } diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index c0345ff..2875bd1 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -1446,6 +1446,15 @@ class TestFootnoteReferences: assert text(md, link_refs[0].text) == expected_id assert text(md, link_refs[0].id) == expected_id + def test_no_footnote_ref_escaped_id(self) -> None: + md = b"[^a\\_b]\n\n[^a_b]: note" + refs = collect(md) + assert len(refs) == 1 + + note_defs = footnote_defs_only(refs) + assert len(note_defs) == 1 + assert text(md, note_defs[0].id) == b"a_b" + # --------------------------------------------------------------------------- diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index 8db0550..3738077 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -889,6 +889,11 @@ def _scan_footnote_ref_or_def( ): return _scan_footnote_def(cursor, id, end) + # Python Markdown doesn't recognize footnote references with escapes. + if _has_escaped_char(text): + cursor.advance(end - start) + return iter(()) + # Advance cursor and return link cursor.advance(end - start) return FootnoteReference( @@ -1509,6 +1514,16 @@ def _is_callout_marker(cursor: Cursor, text: Span, end: int) -> bool: return found and pos == start +def _has_escaped_char(value: bytes) -> bool: + """Return whether a value contains an escaped Markdown character.""" + i = 0 + while i + 1 < len(value): + if value[i] == _BACKSLASH and value[i + 1] in _ESCAPABLE: + return True + i += 1 + return False + + # ---------------------------------------------------------------------------