mirror of
https://github.com/zensical/zensical.git
synced 2026-08-28 10:06:38 +00:00
Merge pull request #706 from zensical/fix/validation-regressions
fix: empty footnote ref classified as link ref (#702)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,6 +1389,11 @@ class TestFootnoteReferences:
|
||||
@pytest.mark.parametrize(
|
||||
("md", "expected_id"),
|
||||
[
|
||||
pytest.param(
|
||||
b"[^]",
|
||||
b"",
|
||||
id="footnote-ref-empty-id",
|
||||
),
|
||||
pytest.param(
|
||||
b"[^1]",
|
||||
b"1",
|
||||
@@ -1419,11 +1424,6 @@ class TestFootnoteReferences:
|
||||
@pytest.mark.parametrize(
|
||||
("md", "expected_id"),
|
||||
[
|
||||
pytest.param(
|
||||
b"[^]",
|
||||
b"^",
|
||||
id="link-ref",
|
||||
),
|
||||
pytest.param(
|
||||
b"[^ ]",
|
||||
b"^ ",
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -871,10 +871,7 @@ def _scan_footnote_ref_or_def(
|
||||
if end >= cursor.end or cursor.data[end] != _RBRACKET:
|
||||
return None
|
||||
|
||||
# Skip if we didn't find an id
|
||||
id = cursor.span(start + 2, end)
|
||||
if id.start == id.end:
|
||||
return None
|
||||
|
||||
# Validate id - non-empty, no internal spaces
|
||||
text = cursor.data[id.start : id.end]
|
||||
@@ -892,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(
|
||||
@@ -1512,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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user