From 88fdd3084e3601080e8bc8aa538d867ce653af82 Mon Sep 17 00:00:00 2001 From: jutty Date: Wed, 3 Jun 2026 21:23:40 -0300 Subject: [PATCH] Flag absolute anchors --- Cargo.lock | 8 ++-- src/graph.rs | 2 +- src/syntax/content/parser/context/anchor.rs | 49 +++++++++++++++++---- src/syntax/content/parser/token/anchor.rs | 49 ++++++++++++++++----- static/public/assets/style.css | 13 ++++++ templates/data.html | 7 +-- 6 files changed, 101 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d09c10..daa0607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,9 +139,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -531,9 +531,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "matchit" diff --git a/src/graph.rs b/src/graph.rs index 0fd940c..9cc62a5 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -396,7 +396,7 @@ impl Graph { ); } else { if let Some(destination) = anchor.destination() - && !anchor.external() + && !anchor.absolute() { let trimmed_destination = destination .trim_start_matches("/node/") diff --git a/src/syntax/content/parser/context/anchor.rs b/src/syntax/content/parser/context/anchor.rs index 0fbebb6..2aa172c 100644 --- a/src/syntax/content/parser/context/anchor.rs +++ b/src/syntax/content/parser/context/anchor.rs @@ -34,6 +34,9 @@ pub fn parse( log!(VERBOSE, "End: Next lexeme is a pipe"); buffer.text.push_str(&lexeme.text()); candidate.set_text(&buffer.text.clone()); + if buffer.text.starts_with('/') { + candidate.set_absolute(true); + } } else { log!( VERBOSE, @@ -84,6 +87,13 @@ pub fn parse( "State: Found a pipe, but no boundary: destination follows" ); candidate.set_balanced(true); + if lexeme.match_next_first_char('/') { + log!( + VERBOSE, + "State: Destination starts with a dash, marking as absolute" + ); + candidate.set_absolute(true); + } return true; } else if lexeme.match_char(':') { log!(VERBOSE, "State: Found a colon, marking anchor as external"); @@ -205,8 +215,10 @@ mod tests { fn needless_three_pipe_anchor() { assert_eq!( read("|Node|Destination|"), - concat!(r#"

Node

"#) + concat!( + r#"

Node

"# + ) ); } @@ -225,9 +237,11 @@ mod tests { fn anchor_to_node_s() { assert_eq!( read("The |letter s|s|'s node: |s|!"), - concat!(r#"

The The letter s's node: "#, - r#"s!

"#) + r#"s!

"# + ) ); } @@ -246,9 +260,11 @@ mod tests { fn leading_plural_anchor() { assert_eq!( read("Interfaces are |element|s of |system|s."), - concat!(r#"

Interfaces are Interfaces are elements of systems.

"#) + r#"title="" href="/node/system">systems.

"# + ) ); } @@ -268,9 +284,11 @@ mod tests { fn explicit_end_of_destination() { assert_eq!( read("interactions are |basic elements|BasicElements| of systems"), - concat!(r#"

interactions are interactions are basic elements of "#, - r#"systems

"#) + r#"systems

"# + ) ); } @@ -327,6 +345,21 @@ mod tests { ); } + #[test] + fn absolute_anchor() { + let parse_result = + parser::rich_read("see the |raw endpoints|/data|.", &Graph::load()); + println!("Parsed tokens: {:#?}", parse_result.tokens); + assert_eq!( + parse_result.text.unwrap(), + concat!( + r#"

see the "#, + r#"raw endpoints.

"#, + ), + ); + } + #[test] fn http_external_anchor() { assert_eq!( diff --git a/src/syntax/content/parser/token/anchor.rs b/src/syntax/content/parser/token/anchor.rs index 1efecf9..556ac42 100644 --- a/src/syntax/content/parser/token/anchor.rs +++ b/src/syntax/content/parser/token/anchor.rs @@ -11,6 +11,7 @@ pub struct Anchor { node: Option, leading: bool, balanced: bool, + absolute: bool, external: bool, } @@ -34,10 +35,17 @@ impl Anchor { self.balanced = balanced; } + pub const fn absolute(&self) -> bool { self.absolute } + + pub const fn set_absolute(&mut self, absolute: bool) { + self.absolute = absolute; + } + pub const fn external(&self) -> bool { self.external } pub const fn set_external(&mut self, external: bool) { self.external = external; + self.absolute = true; } pub const fn set_leading(&mut self, leading: bool) { @@ -58,21 +66,28 @@ impl Anchor { fn route(&mut self) { self.destination = if let Some(destination) = self.destination.clone() { - if destination.contains(':') || destination.contains('/') { + if destination.contains(':') || destination.starts_with('/') { Some(destination) } else if destination.is_empty() && self.text.is_empty() { None } else if destination.is_empty() { - self.node_id = Some(self.text.clone()); + self.node_id = Some(Self::strip_fragment(&self.text)); Some(format!("/node/{}", self.text)) } else { - self.node_id = self.destination.clone(); + self.node_id = self + .destination + .clone() + .map(|d| Anchor::strip_fragment(&d)); Some(format!("/node/{destination}")) } } else { None } } + + fn strip_fragment(target: &str) -> String { + target.split('#').next().unwrap_or(target).to_string() + } } impl Parseable for Anchor { @@ -100,19 +115,31 @@ impl Parseable for Anchor { String::default() }; - let classes = if self.node.is_some() { - String::from(r#"class="attached""#) - } else if !self.external { - String::from(r#"class="detached""#) - } else if self.external { + let classes = if self.external { String::from(r#"class="external""#) + } else if self.absolute { + String::from(r#"class="absolute""#) + } else if self.node.is_some() { + String::from(r#"class="attached""#) } else { - String::default() + String::from(r#"class="detached""#) + }; + + let text = if destination.contains('#') + && !self.absolute + && destination == &format!("/node/{}", self.text) + { + self.text + .split('#') + .next() + .unwrap_or(&self.text) + .to_string() + } else { + self.text.clone() }; format!( - r#"{}"#, - destination, self.text, + r#"{text}"# ) } diff --git a/static/public/assets/style.css b/static/public/assets/style.css index d3ef9b5..8afbacd 100644 --- a/static/public/assets/style.css +++ b/static/public/assets/style.css @@ -99,6 +99,19 @@ a.external:hover { transition: 1500ms; } +a.absolute { + color: light-dark(#196719, #2fe471); + text-decoration-color: light-dark(#196719, #2fe471); + text-decoration-style: solid; + text-decoration-thickness: 1.5px; + transition: 1500ms; +} + +a.absolute:hover { + filter: brightness(1.25); + transition: 1500ms; +} + a:visited, a.detached:visited, a.external:visited diff --git a/templates/data.html b/templates/data.html index f7a1bca..3a18a7f 100644 --- a/templates/data.html +++ b/templates/data.html @@ -9,15 +9,16 @@
- Detached edges + Detached connections - {{ detached_count }} + {{ detached_count }}
-

Detached edges

+

Detached connections

+

These are the destinations to which connections exist, but the target node doesn't.

Expand to see all detached edges.