From 3fa399c3179b41a69c1ae0c5442314fd1e0b58cc Mon Sep 17 00:00:00 2001 From: jutty Date: Sun, 11 Jan 2026 08:00:35 -0300 Subject: [PATCH] Make anchors aware of the nodes they point to --- .justfile | 2 +- src/dev.rs | 2 +- src/router/handlers/graph.rs | 4 +- src/router/handlers/template.rs | 6 +- src/syntax/content.rs | 7 +- src/syntax/content/parser.rs | 39 +++-- src/syntax/content/parser/context/anchor.rs | 155 +++++++++++-------- src/syntax/content/parser/context/block.rs | 12 +- src/syntax/content/parser/context/inline.rs | 4 +- src/syntax/content/parser/context/list.rs | 16 +- src/syntax/content/parser/point.rs | 10 +- src/syntax/content/parser/token.rs | 21 ++- src/syntax/content/parser/token/anchor.rs | 53 ++++++- src/syntax/content/parser/token/bold.rs | 4 + src/syntax/content/parser/token/checkbox.rs | 4 + src/syntax/content/parser/token/code.rs | 4 + src/syntax/content/parser/token/header.rs | 4 + src/syntax/content/parser/token/item.rs | 4 + src/syntax/content/parser/token/linebreak.rs | 4 + src/syntax/content/parser/token/list.rs | 4 + src/syntax/content/parser/token/literal.rs | 4 + src/syntax/content/parser/token/oblique.rs | 4 + src/syntax/content/parser/token/paragraph.rs | 4 + src/syntax/content/parser/token/preformat.rs | 4 + src/syntax/content/parser/token/strike.rs | 4 + src/syntax/content/parser/token/underline.rs | 4 + src/syntax/serial.rs | 67 ++++---- src/types.rs | 75 +++++---- static/graph.toml | 5 +- static/style.css | 30 ++-- templates/tree.html | 40 ++--- 31 files changed, 368 insertions(+), 232 deletions(-) diff --git a/.justfile b/.justfile index f9981d9..6519990 100644 --- a/.justfile +++ b/.justfile @@ -24,7 +24,7 @@ alias w := run-watch [private] quick-assess: - {{ just_cmd }} test lint check + {{ just_cmd }} lint check quick-test-cover [private] quick-assess-run: diff --git a/src/dev.rs b/src/dev.rs index a9f8fbf..ffdf2ac 100644 --- a/src/dev.rs +++ b/src/dev.rs @@ -4,7 +4,7 @@ pub fn elog(function: &str, message: &str) { } // Paths in this slice suppress logging if found in the stack trace -pub const SKIP_PATHS: &[&str] = &["en::types::Config::parse_text"]; +pub const SKIP_PATHS: &[&str] = &["en::types::Graph::parse"]; #[macro_export] macro_rules! log { diff --git a/src/router/handlers/graph.rs b/src/router/handlers/graph.rs index a0b3589..3079a8e 100644 --- a/src/router/handlers/graph.rs +++ b/src/router/handlers/graph.rs @@ -8,7 +8,7 @@ use crate::{syntax::serial::populate_graph, router::handlers, types::Node}; pub async fn node(Path(id): Path) -> Response { let graph = populate_graph(); let result = graph.find_node(&id); - let nodes: Vec = graph.nodes.into_values().collect(); + let nodes: Vec = graph.nodes.clone().into_values().collect(); let not_found = result.node.is_none(); let node = result .node @@ -29,7 +29,7 @@ pub async fn node(Path(id): Path) -> Response { let mut context = tera::Context::default(); context.insert("node", &node); context.insert("nodes", &nodes); - context.insert("text", &content::parse(&node.text, &graph.meta.config)); + context.insert("text", &content::parse(&node.text, &graph)); context.insert("incoming", &graph.incoming.get(&id)); context.insert("config", &graph.meta.config); diff --git a/src/router/handlers/template.rs b/src/router/handlers/template.rs index 270449f..cc4cb7a 100644 --- a/src/router/handlers/template.rs +++ b/src/router/handlers/template.rs @@ -156,10 +156,8 @@ mod tests { let node = crate::types::Node::new(Some(payload.to_string())); let graph = crate::syntax::serial::populate_graph(); context.insert("node", &node); - context.insert( - "text", - &crate::syntax::content::parse(&node.text, &graph.meta.config), - ); + context + .insert("text", &crate::syntax::content::parse(&node.text, &graph)); context.insert("incoming", &graph.incoming.get(&node.id)); context.insert("config", &graph.meta.config); let (body, status) = render("node.html", &context, None); diff --git a/src/syntax/content.rs b/src/syntax/content.rs index 4e7a4a7..b802efa 100644 --- a/src/syntax/content.rs +++ b/src/syntax/content.rs @@ -1,6 +1,6 @@ use parser::{token::Token, lexeme::Lexeme}; -use crate::types::Config; +use crate::types::Graph; pub mod parser; @@ -8,12 +8,13 @@ pub trait Parseable: std::fmt::Display { fn probe(lexeme: &Lexeme) -> bool; fn lex(lexeme: &Lexeme) -> Self; fn render(&self) -> String; + fn flatten(&self) -> String; } type Probe = fn(&Lexeme) -> bool; type Lexer = fn(&Lexeme) -> Token; type LexMap<'lm> = &'lm [(Probe, Lexer)]; -pub fn parse(text: &str, config: &Config) -> String { - parser::read(text, config) +pub fn parse(text: &str, graph: &Graph) -> String { + parser::read(text, graph) } diff --git a/src/syntax/content/parser.rs b/src/syntax/content/parser.rs index 18ee600..f00de4f 100644 --- a/src/syntax/content/parser.rs +++ b/src/syntax/content/parser.rs @@ -1,4 +1,4 @@ -use crate::{prelude::*, types::Config}; +use crate::{prelude::*, types::Graph}; use super::{Parseable as _, Token, LexMap}; use token::{linebreak::LineBreak, literal::Literal}; use lexeme::Lexeme; @@ -20,7 +20,7 @@ const LEXMAP: LexMap = &[ }), ]; -fn lex(text: &str, map: LexMap, config: &Config, blocking: bool) -> Vec { +fn lex(text: &str, map: LexMap, graph: &Graph, blocking: bool) -> Vec { let mut tokens: Vec = Vec::default(); let mut state = state::State::default(); @@ -44,7 +44,7 @@ fn lex(text: &str, map: LexMap, config: &Config, blocking: bool) -> Vec { &mut state, &mut tokens, &mut iterator, - config, + graph, ) { continue; } @@ -59,6 +59,7 @@ fn lex(text: &str, map: LexMap, config: &Config, blocking: bool) -> Vec { &mut state, &mut tokens, &mut iterator, + graph, ) { continue; } @@ -77,20 +78,28 @@ fn lex(text: &str, map: LexMap, config: &Config, blocking: bool) -> Vec { tokens } +pub(super) fn read(input: &str, graph: &Graph) -> String { + parse(&lex(input, LEXMAP, graph, true)) +} + +/// Apply end-to-end point and inline parsing for nested formatting, such as +/// inside the display text of anchors and list items +pub fn nest(input: &str, graph: &Graph) -> String { + parse(&lex(input, LEXMAP, graph, false)) +} + +// Strip special syntax for display in noninteractive or plain-text display +pub fn flatten(input: &str, graph: &Graph) -> String { + let tokens = lex(input, LEXMAP, graph, true); + let flat = tokens.iter().map(Token::flatten).collect::(); + log!("Flattened {tokens:?} to {flat}"); + flat +} + fn parse(tokens: &[Token]) -> String { tokens.iter().map(Token::render).collect::() } -/// Apply end-to-end point and inline parsing for nested contexts, such as -/// inside the displayed text of other tokens like anchors and list items -pub fn nest(input: &str, config: &Config) -> String { - parse(&lex(input, LEXMAP, config, false)) -} - -pub(super) fn read(input: &str, config: &Config) -> String { - parse(&lex(input, LEXMAP, config, true)) -} - #[cfg(test)] mod tests { use crate::{ @@ -101,7 +110,7 @@ mod tests { use super::*; fn read_noconfig(input: &str) -> String { - read(input, &Graph::new(None).meta.config) + read(input, &Graph::default()) } #[test] @@ -112,7 +121,7 @@ mod tests { #[test] fn mixed_sample() { let en = "`this |test|` tries ## to |brea|k|: things"; - let html = r#"

this |test| tries ## to brea: things

"#; + let html = r#"

this |test| tries ## to brea: things

"#; assert_eq!(read_noconfig(en), html); } diff --git a/src/syntax/content/parser/context/anchor.rs b/src/syntax/content/parser/context/anchor.rs index 8843ab4..665627c 100644 --- a/src/syntax/content/parser/context/anchor.rs +++ b/src/syntax/content/parser/context/anchor.rs @@ -1,8 +1,9 @@ use crate::{ prelude::*, syntax::content::parser::{ - state::State, context::Inline, lexeme::Lexeme, token::Token, + context::Inline, lexeme::Lexeme, state::State, token::Token, }, + types::Graph, }; /// Handles open anchor contexts until an anchor token is fully parsed. @@ -16,6 +17,7 @@ pub fn parse( lexeme: &Lexeme, state: &mut State, tokens: &mut Vec, + graph: &Graph, ) -> bool { log!("Solving: {}", state.clone().buffers.anchor); let buffer = &mut state.buffers.anchor; @@ -61,19 +63,16 @@ pub fn parse( candidate.set_destination(Some(&candidate.text().clone())); candidate.text_push("s"); if lexeme.last() { - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; + push(None, tokens, state, graph); } return true; } else if lexeme.match_char('|') && lexeme.is_next_delimiter() { log!("End: Pipe followed by delimiter"); if buffer.destination.is_empty() { - candidate.set_destination(Some(&candidate.text().clone())); + push(Some(&candidate.text().clone()), tokens, state, graph); } else { - candidate.set_destination(Some(&buffer.destination.clone())); + push(Some(&buffer.destination.clone()), tokens, state, graph); } - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; return true; } else if lexeme.match_char('|') && !candidate.balanced() { log!("State: Found a pipe, but no boundary: destination follows"); @@ -90,23 +89,17 @@ pub fn parse( return true; } else if !candidate.external() && lexeme.is_delimiter() { log!("End: Internal anchor trailed by delimiter"); - candidate.set_destination(Some(&buffer.destination.clone())); - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; + push(Some(&buffer.destination.clone()), tokens, state, graph); return false; } else if lexeme.is_next_whitespace() { log!("End: next is whitespace"); buffer.destination.push_str(&lexeme.text()); - candidate.set_destination(Some(&buffer.destination.clone())); - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; + push(Some(&buffer.destination.clone()), tokens, state, graph); return true; } else if lexeme.last() { log!("End: end of input"); buffer.destination.push_str(&lexeme.text()); - candidate.set_destination(Some(&buffer.destination.clone())); - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; + push(Some(&buffer.destination.clone()), tokens, state, graph); return true; // This else branch is the 'no end found yet' state and will keep @@ -119,9 +112,7 @@ pub fn parse( ); buffer.destination.push_str(&lexeme.text()); if lexeme.last() { - candidate.set_destination(Some(&buffer.destination.clone())); - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; + push(Some(&buffer.destination.clone()), tokens, state, graph); } return true; } @@ -136,47 +127,79 @@ pub fn parse( "Anchor context parsing done but no destination found: {:#?}", state.buffers.anchor ); - tokens.push(Token::Anchor(candidate.clone())); - state.context.inline = Inline::None; + push(None, tokens, state, graph); false } +fn push( + d: Option<&str>, + tokens: &mut Vec, + state: &mut State, + graph: &Graph, +) { + let candidate = &mut state.buffers.anchor.candidate; + if d.is_some() { + candidate.set_destination(d); + } + + if let Some(node_id) = candidate.node_id() + && let Some(node) = graph.find_node(&node_id).node + { + log!("Found matching node {node:?} for anchor candidate {candidate}"); + candidate.set_node(&node); + } else { + log!("Could not find a matching node for anchor candidate {candidate}"); + } + + tokens.push(Token::Anchor(Box::new(candidate.clone()))); + state.context.inline = Inline::None; +} + #[cfg(test)] mod tests { use crate::{syntax::content::parser, types::Graph}; fn read(input: &str) -> String { - parser::read(input, &Graph::new(None).meta.config) + parser::read(input, &Graph::default()) } #[test] fn flanking() { - assert_eq!(read("|Node|"), r#"

Node

"#); + assert_eq!( + read("|Node|"), + r#"

Node

"# + ); } #[test] fn flanking_with_trailing_comma() { - assert_eq!(read("|Node|,"), r#"

Node,

"#); + assert_eq!( + read("|Node|,"), + r#"

Node,

"# + ); } #[test] fn flanking_with_trailing_comma_and_space() { assert_eq!( read("|Node|, at"), - r#"

Node, at

"# + r#"

Node, at

"# ); } #[test] fn flanking_at_eoi() { - assert_eq!(read("|Node|"), r#"

Node

"#); + assert_eq!( + read("|Node|"), + r#"

Node

"# + ); } #[test] fn needless_three_pipe_anchor() { assert_eq!( read("|Node|Destination|"), - r#"

Node

"# + r#"

Node

"# ); } @@ -184,7 +207,7 @@ mod tests { fn nonleading_second_pipe() { assert_eq!( read("Go to Node|Destination|, here"), - r#"

Go to Node, here

"#, + r#"

Go to Node, here

"#, ); } @@ -192,7 +215,7 @@ mod tests { fn anchor_to_node_s() { assert_eq!( read("The |letter s|s|'s node: |s|!"), - r#"

The letter s's node: s!

"# + r#"

The letter s's node: s!

"# ); } @@ -200,7 +223,7 @@ mod tests { fn nonleading_plural_anchor() { assert_eq!( read("The flower|s bloomed"), - r#"

The flowers bloomed

"# + r#"

The flowers bloomed

"# ); } @@ -208,7 +231,7 @@ mod tests { fn leading_plural_anchor() { assert_eq!( read("Interfaces are |element|s of |system|s."), - r#"

Interfaces are elements of systems.

"# + r#"

Interfaces are elements of systems.

"# ); } @@ -216,7 +239,7 @@ mod tests { fn leading_multiword_anchor() { assert_eq!( read("interactions are |basic elements| of systems"), - r#"

interactions are basic elements of systems

"# + r#"

interactions are basic elements of systems

"# ); } @@ -224,7 +247,7 @@ mod tests { fn explicit_end_of_destination() { assert_eq!( read("interactions are |basic elements|BasicElements| of systems"), - r#"

interactions are basic elements of systems

"# + r#"

interactions are basic elements of systems

"# ); } @@ -232,20 +255,23 @@ mod tests { fn explicit_end_of_external_destination() { assert_eq!( read("this |anchor example|https://example.com| is external"), - r#"

this anchor example is external

"# + r#"

this anchor example is external

"# ); } #[test] fn anchor_destination_at_eoi() { - assert_eq!(read("a |b c|d"), r#"

a b c

"#); + assert_eq!( + read("a |b c|d"), + r#"

a b c

"# + ); } #[test] fn external_anchor_destination_at_eoi() { assert_eq!( read("a b|https://example.com"), - r#"

a b

"# + r#"

a b

"# ); } @@ -253,7 +279,7 @@ mod tests { fn nonleading_plural_anchor_at_eoi() { assert_eq!( read("element|s"), - r#"

elements

"# + r#"

elements

"# ); } @@ -261,7 +287,7 @@ mod tests { fn leading_plural_anchor_at_eoi() { assert_eq!( read("|element|s"), - r#"

elements

"# + r#"

elements

"# ); } @@ -271,7 +297,7 @@ mod tests { read( "a |false dichotomy|https://en.wikipedia.org/wiki/False_dilemma|." ), - r#"

a false dichotomy.

"# + r#"

a false dichotomy.

"# ); } @@ -284,7 +310,7 @@ mod tests { "at rustup.rs", )), concat!( - r#"

Rust toolchain"#, + r#"

Rust toolchain"#, "\n", "at rustup.rs

", ) @@ -295,7 +321,7 @@ mod tests { fn http_external_anchor_leading_no_third_then_space() { assert_eq!( read("|Rust toolchain|https://rustup.rs/ at rustup.rs"), - r#"

Rust toolchain at rustup.rs

"# + r#"

Rust toolchain at rustup.rs

"# ); } @@ -303,7 +329,7 @@ mod tests { fn http_external_anchor_leading_no_third_then_eoi() { assert_eq!( read("|Rust toolchain|https://rustup.rs/"), - r#"

Rust toolchain

"# + r#"

Rust toolchain

"# ); } @@ -313,7 +339,7 @@ mod tests { read("\n|SomeAnchor|\n"), concat!( "\n", - r#"

SomeAnchor

"# + r#"

SomeAnchor

"# ), ); } @@ -323,9 +349,9 @@ mod tests { assert_eq!( read("|SomeAnchor|\n|SomeOtherAnchor|\n"), concat!( - r#"

SomeAnchor"#, + r#"

SomeAnchor"#, "\n", - r#"SomeOtherAnchor

"# + r#"SomeOtherAnchor

"# ) ); } @@ -335,10 +361,10 @@ mod tests { assert_eq!( read("|SomeAnchor|\n\n|SomeOtherAnchor|\n"), concat!( - r#"

SomeAnchor

"#, + r#"

SomeAnchor

"#, "\n", "\n", - r#"

SomeOtherAnchor

"# + r#"

SomeOtherAnchor

"# ), ); } @@ -347,7 +373,7 @@ mod tests { fn trailing_anchor() { assert_eq!( read("see acks|acks"), - r#"

see acks

"# + r#"

see acks

"# ); } @@ -355,7 +381,10 @@ mod tests { fn trailing_anchor_with_newline() { assert_eq!( read("\nsee acks|acks\n"), - concat!("\n", r#"

see acks

"#) + concat!( + "\n", + r#"

see acks

"# + ) ); } @@ -383,7 +412,7 @@ mod tests { fn anchor_with_trailing_single_quote() { assert_eq!( read("the |lion|'s mouth"), - r#"

the lion's mouth

"#, + r#"

the lion's mouth

"#, ); } @@ -391,7 +420,7 @@ mod tests { fn anchor_with_trailing_double_quote() { assert_eq!( read(r#"the "|real|" motive"#), - r#"

the "real" motive

"#, + r#"

the "real" motive

"#, ); } @@ -399,7 +428,7 @@ mod tests { fn anchor_with_trailing_parenthesis() { assert_eq!( read("this (though |true|) was questioned"), - r#"

this (though true) was questioned

"#, + r#"

this (though true) was questioned

"#, ); } @@ -407,7 +436,7 @@ mod tests { fn anchor_with_leading_single_quote() { assert_eq!( read("the 'real|Reality' motive"), - r#"

the 'real' motive

"#, + r#"

the 'real' motive

"#, ); } @@ -415,7 +444,7 @@ mod tests { fn anchor_with_leading_double_quote() { assert_eq!( read(r#"the "real|Reality" motive"#), - r#"

the "real" motive

"#, + r#"

the "real" motive

"#, ); } @@ -423,7 +452,7 @@ mod tests { fn anchor_with_leading_parenthesis() { assert_eq!( read("her (last|Surname) name"), - r#"

her (last) name

"#, + r#"

her (last) name

"#, ); } @@ -431,7 +460,7 @@ mod tests { fn anchor_with_internal_apostrophe() { assert_eq!( read("the |lion's mouth|album was released"), - r#"

the lion's mouth was released

"# + r#"

the lion's mouth was released

"# ); } @@ -439,7 +468,7 @@ mod tests { fn nonleading_anchor_with_internal_apostrophe() { assert_eq!( read("they decided to stay at Jane's|YellowHouse that night"), - r#"

they decided to stay at Jane's that night

"# + r#"

they decided to stay at Jane's that night

"# ); } @@ -447,7 +476,7 @@ mod tests { fn nonleading_anchor_with_internal_apostrophe_at_eoi() { assert_eq!( read("they decided to stay at Jane's|YellowHouse"), - r#"

they decided to stay at Jane's

"# + r#"

they decided to stay at Jane's

"# ); } @@ -455,7 +484,7 @@ mod tests { fn nonleading_anchor_with_internal_apostrophe_at_soi() { assert_eq!( read("Jane's|YellowHouse that night"), - r#"

Jane's that night

"# + r#"

Jane's that night

"# ); } @@ -463,7 +492,7 @@ mod tests { fn anchor_with_internal_double_quotes() { assert_eq!( read(r#"the |"real"|Truth motive"#), - r#"

the "real" motive

"#, + r#"

the "real" motive

"#, ); } @@ -471,7 +500,7 @@ mod tests { fn anchor_with_internal_double_quotes_wrapping_spaced_words() { assert_eq!( read(r#"the |"bare reality"|Ideology they believed"#), - r#"

the "bare reality" they believed

"#, + r#"

the "bare reality" they believed

"#, ); } @@ -479,7 +508,7 @@ mod tests { fn anchor_with_internal_parenthesis() { assert_eq!( read("her |last (name)|Surname was Amad"), - r#"

her last (name) was Amad

"#, + r#"

her last (name) was Amad

"#, ); } @@ -487,7 +516,7 @@ mod tests { fn anchor_with_internal_parenthesis_wrapping_spaced_words() { assert_eq!( read("this |truth (though questionable) was fine|Absurd to them "), - r#"

this truth (though questionable) was fine to them

"# + r#"

this truth (though questionable) was fine to them

"# ); } } diff --git a/src/syntax/content/parser/context/block.rs b/src/syntax/content/parser/context/block.rs index e2d278e..4dfede6 100644 --- a/src/syntax/content/parser/context/block.rs +++ b/src/syntax/content/parser/context/block.rs @@ -14,7 +14,7 @@ use crate::{ }, }, }, - types::Config, + types::Graph, }; pub fn parse( @@ -22,7 +22,7 @@ pub fn parse( state: &mut State, tokens: &mut Vec, iterator: &mut Peekable>, - config: &Config, + graph: &Graph, ) -> bool { match state.context.block { Block::None => { @@ -34,7 +34,7 @@ pub fn parse( } else if Header::probe(lexeme) { let mut header = Header::lex(lexeme); header.dom_id = Some(Header::make_id( - config, + &graph.meta.config, iterator.peek().map_or(&Lexeme::default(), |l| l), &mut state.dom_ids, )); @@ -47,7 +47,7 @@ pub fn parse( state.context.block = Block::List; state.buffers.list.candidate.ordered = lexeme.match_char('+'); return super::list::parse( - lexeme, state, tokens, iterator, config, + lexeme, state, tokens, iterator, graph, ); } else if Paragraph::probe(lexeme) { log!("Block Context: None -> Paragraph on {lexeme}"); @@ -80,7 +80,7 @@ pub fn parse( } }, Block::List => { - return super::list::parse(lexeme, state, tokens, iterator, config); + return super::list::parse(lexeme, state, tokens, iterator, graph); }, } false @@ -103,7 +103,7 @@ mod tests { }; fn read(input: &str) -> String { - parser::read(input, &Graph::new(None).meta.config) + parser::read(input, &Graph::default()) } #[test] diff --git a/src/syntax/content/parser/context/inline.rs b/src/syntax/content/parser/context/inline.rs index 7b05af7..eb339ef 100644 --- a/src/syntax/content/parser/context/inline.rs +++ b/src/syntax/content/parser/context/inline.rs @@ -11,6 +11,7 @@ use crate::{ token::{Token, anchor::Anchor, code::Code, literal::Literal}, }, }, + types::Graph, }; pub fn parse( @@ -18,6 +19,7 @@ pub fn parse( state: &mut State, tokens: &mut Vec, iterator: &mut Peekable>, + graph: &Graph, ) -> bool { match state.context.inline { Inline::None => { @@ -54,7 +56,7 @@ pub fn parse( } }, Inline::Anchor => { - if context::anchor::parse(lexeme, state, tokens) { + if context::anchor::parse(lexeme, state, tokens, graph) { return true; } }, diff --git a/src/syntax/content/parser/context/list.rs b/src/syntax/content/parser/context/list.rs index 13e4141..bb36915 100644 --- a/src/syntax/content/parser/context/list.rs +++ b/src/syntax/content/parser/context/list.rs @@ -9,7 +9,7 @@ use crate::{ state::{ListBuffer, State}, token::{Token, item::Item}, }, - types::Config, + types::Graph, }; /// Handles open list contexts until a list is fully parsed. @@ -25,7 +25,7 @@ pub fn parse( state: &mut State, tokens: &mut Vec, iterator: &mut Peekable>, - config: &Config, + graph: &Graph, ) -> bool { let buffer = &mut state.buffers.list; let candidate = &mut buffer.candidate; @@ -52,7 +52,7 @@ pub fn parse( } if item_candidate.depth.is_some() { // if the current item candidate has a known depth, push it - item_candidate.text = nest(&item_candidate.text, config); + item_candidate.text = nest(&item_candidate.text, graph); candidate.items.push(item_candidate.clone()); } // push list candidate, reset state and exit context @@ -64,7 +64,7 @@ pub fn parse( } else if lexeme.match_char('\n') { // found end of item, push it and reset state log!("Accepting item candidate {item_candidate}"); - item_candidate.text = nest(&item_candidate.text, config); + item_candidate.text = nest(&item_candidate.text, graph); candidate.items.push(item_candidate.clone()); *item_candidate = Item::default(); buffer.depth = 0; @@ -89,11 +89,11 @@ mod tests { syntax::content::parser::{ self, context::list::parse, lexeme::Lexeme, state::State, }, - types::{Config, Graph}, + types::Graph, }; fn read(input: &str) -> String { - parser::read(input, &Graph::new(None).meta.config) + parser::read(input, &Graph::default()) } #[test] @@ -273,13 +273,13 @@ mod tests { fn bad_context() { let mut state = State::default(); let lexemes = Lexeme::collect(&["a", "b", "c"].map(str::to_string)); - let config = Config::default(); + let graph = Graph::default(); parse( &Lexeme::default(), &mut state, &mut vec![], &mut lexemes.iter().peekable(), - &config, + &graph, ); } } diff --git a/src/syntax/content/parser/point.rs b/src/syntax/content/parser/point.rs index aaf5a11..1dab1dd 100644 --- a/src/syntax/content/parser/point.rs +++ b/src/syntax/content/parser/point.rs @@ -65,14 +65,14 @@ mod tests { use crate::{syntax::content::parser, types::Graph}; fn read(input: &str) -> String { - parser::read(input, &Graph::new(None).meta.config) + parser::read(input, &Graph::default()) } #[test] fn oblique_anchor() { assert_eq!( read("w _|S|_ w"), - r#"

w S w

"# + r#"

w S w

"# ); } @@ -80,7 +80,7 @@ mod tests { fn oblique_anchor_with_trailing_comma() { assert_eq!( read("w _|S|_, w"), - r#"

w S, w

"# + r#"

w S, w

"# ); } @@ -88,9 +88,9 @@ mod tests { fn oblique() { assert_eq!( read( - "_|this anchor is oblique|o as are these literals_ but not these _just these_, not this _and these with an |anchor| again_" + "_|this anchor is oblique|o as are these literals_ but not these _just these_, not this _and these with an |anc80r| again_" ), - r#"

this anchor is oblique as are these literals but not these just these, not this and these with an anchor again

"# + r#"

this anchor is oblique as are these literals but not these just these, not this and these with an anc80r again

"# ); } diff --git a/src/syntax/content/parser/token.rs b/src/syntax/content/parser/token.rs index c02f937..a6e9124 100644 --- a/src/syntax/content/parser/token.rs +++ b/src/syntax/content/parser/token.rs @@ -18,7 +18,7 @@ pub mod underline; #[derive(Debug, Eq, PartialEq, Clone)] pub enum Token { - Anchor(anchor::Anchor), + Anchor(Box), Bold(bold::Bold), CheckBox(checkbox::CheckBox), Code(code::Code), @@ -53,6 +53,25 @@ impl Token { Token::Underline(ref d) => d.render(), } } + + pub fn flatten(&self) -> String { + match *self { + Token::Anchor(ref d) => d.flatten(), + Token::Bold(ref d) => d.flatten(), + Token::CheckBox(ref d) => d.flatten(), + Token::Code(ref d) => d.flatten(), + Token::Strike(ref d) => d.flatten(), + Token::Header(ref d) => d.flatten(), + Token::Item(ref d) => d.flatten(), + Token::LineBreak(ref d) => d.flatten(), + Token::List(ref d) => d.flatten(), + Token::Literal(ref d) => d.flatten(), + Token::Oblique(ref d) => d.flatten(), + Token::Paragraph(ref d) => d.flatten(), + Token::PreFormat(ref d) => d.flatten(), + Token::Underline(ref d) => d.flatten(), + } + } } impl std::fmt::Display for Token { diff --git a/src/syntax/content/parser/token/anchor.rs b/src/syntax/content/parser/token/anchor.rs index 25481dd..4569aee 100644 --- a/src/syntax/content/parser/token/anchor.rs +++ b/src/syntax/content/parser/token/anchor.rs @@ -7,6 +7,7 @@ use crate::{ pub struct Anchor { text: String, destination: Option, + node_id: Option, node: Option, leading: bool, balanced: bool, @@ -18,6 +19,7 @@ impl Anchor { text: &str, destination: &str, node: Option, + node_id: Option, leading: bool, external: bool, balanced: bool, @@ -26,6 +28,7 @@ impl Anchor { text: text.to_owned(), destination: Some(String::from(destination)), node, + node_id, leading, external, balanced, @@ -76,6 +79,14 @@ impl Anchor { self.leading = leading; } + pub fn set_node(&mut self, node: &Node) { + self.node = Some(node.to_owned()); + } + + pub fn node_id(&self) -> Option { + self.node_id.clone() + } + fn route(&mut self) { self.destination = if let Some(destination) = self.destination.clone() { if destination.contains(":") || destination.contains("/") { @@ -83,8 +94,10 @@ impl Anchor { } else if destination.is_empty() && self.text.is_empty() { None } else if destination.is_empty() { + self.node_id = Some(self.text.clone()); Some(format!("/node/{}", self.text)) } else { + self.node_id = self.destination.clone(); Some(format!("/node/{destination}")) } } else { @@ -111,7 +124,30 @@ impl Parseable for Anchor { ) }; - format!(r#"{}"#, destination, &self.text) + let summary = if let Some(node) = self.node.clone() { + node.summary + } else { + 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 { + String::from(r#"class="external""#) + } else { + String::default() + }; + + format!( + r#"{}"#, + destination, self.text, + ) + } + + fn flatten(&self) -> String { + self.text.clone() } } @@ -167,7 +203,7 @@ mod tests { anchor.set_destination(Some("AnchorDest")); assert_eq!( anchor.render(), - r#"AnchorText"# + r#"AnchorText"# ); } @@ -190,20 +226,20 @@ mod tests { fn token_display() { let mut anchor = Anchor::default(); assert_eq!( - format!("{}", Token::Anchor(anchor.clone())), + format!("{}", Token::Anchor(Box::new(anchor.clone()))), "Tk:Anchor -> ", ); anchor.text = String::from("FsJAt RTggA"); assert_eq!( - format!("{}", Token::Anchor(anchor.clone())), + format!("{}", Token::Anchor(Box::new(anchor.clone()))), "Tk:Anchor 'FsJAt RTggA' -> ", ); anchor.text = String::from("wPVo1 0OmYm"); anchor.destination = Some(String::from("M1UEp 1gbfr")); assert_eq!( - format!("{}", Token::Anchor(anchor.clone())), + format!("{}", Token::Anchor(Box::new(anchor.clone()))), r#"Tk:Anchor 'wPVo1 0OmYm' -> "M1UEp 1gbfr""#, ); @@ -212,7 +248,7 @@ mod tests { anchor.external = true; assert_eq!( - format!("{}", Token::Anchor(anchor.clone())), + format!("{}", Token::Anchor(Box::new(anchor.clone()))), "Tk:Anchor 'wPVo1 0OmYm' -> \"M1UEp 1gbfr\" \ +Leading +Balanced +External", ); @@ -230,7 +266,10 @@ mod tests { let mut anchor = Anchor::default(); anchor.set_text("BSThI"); anchor.set_destination(Some("")); - assert_eq!(anchor.render(), r#"BSThI"#); + assert_eq!( + anchor.render(), + r#"BSThI"# + ); } #[test] diff --git a/src/syntax/content/parser/token/bold.rs b/src/syntax/content/parser/token/bold.rs index 15f2ec2..1003733 100644 --- a/src/syntax/content/parser/token/bold.rs +++ b/src/syntax/content/parser/token/bold.rs @@ -29,6 +29,10 @@ impl Parseable for Bold { String::from("") } } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Bold { diff --git a/src/syntax/content/parser/token/checkbox.rs b/src/syntax/content/parser/token/checkbox.rs index e875e49..829996b 100644 --- a/src/syntax/content/parser/token/checkbox.rs +++ b/src/syntax/content/parser/token/checkbox.rs @@ -33,6 +33,10 @@ impl Parseable for CheckBox { let toggle = if self.checked { " checked " } else { "" }; format!(r#""#) } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for CheckBox { diff --git a/src/syntax/content/parser/token/code.rs b/src/syntax/content/parser/token/code.rs index 5ffd868..6dcd24b 100644 --- a/src/syntax/content/parser/token/code.rs +++ b/src/syntax/content/parser/token/code.rs @@ -29,6 +29,10 @@ impl Parseable for Code { String::from("") } } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Code { diff --git a/src/syntax/content/parser/token/header.rs b/src/syntax/content/parser/token/header.rs index 8b35161..5c141ae 100644 --- a/src/syntax/content/parser/token/header.rs +++ b/src/syntax/content/parser/token/header.rs @@ -112,6 +112,10 @@ impl Parseable for Header { panic!("Attempt to render a header tag while open state is unknown") } } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Header { diff --git a/src/syntax/content/parser/token/item.rs b/src/syntax/content/parser/token/item.rs index 3741520..585c981 100644 --- a/src/syntax/content/parser/token/item.rs +++ b/src/syntax/content/parser/token/item.rs @@ -18,6 +18,10 @@ impl Parseable for Item { fn render(&self) -> String { panic!("Items should only be rendered by a list's render method") } + + fn flatten(&self) -> String { + String::default() + } } impl Item { diff --git a/src/syntax/content/parser/token/linebreak.rs b/src/syntax/content/parser/token/linebreak.rs index 8c05284..2ec7123 100644 --- a/src/syntax/content/parser/token/linebreak.rs +++ b/src/syntax/content/parser/token/linebreak.rs @@ -17,6 +17,10 @@ impl Parseable for LineBreak { fn render(&self) -> String { "\n".to_owned() } + + fn flatten(&self) -> String { + String::from('\n') + } } impl std::fmt::Display for LineBreak { diff --git a/src/syntax/content/parser/token/list.rs b/src/syntax/content/parser/token/list.rs index a71f7bc..ece3fd2 100644 --- a/src/syntax/content/parser/token/list.rs +++ b/src/syntax/content/parser/token/list.rs @@ -51,6 +51,10 @@ impl Parseable for List { format!("\n<{tag}>\n{output}\n\n") } + + fn flatten(&self) -> String { + format!("[List: {} items]", self.items.len()) + } } impl List { diff --git a/src/syntax/content/parser/token/literal.rs b/src/syntax/content/parser/token/literal.rs index 23d65b1..12ef542 100644 --- a/src/syntax/content/parser/token/literal.rs +++ b/src/syntax/content/parser/token/literal.rs @@ -19,6 +19,10 @@ impl Parseable for Literal { fn render(&self) -> String { self.text.clone() } + + fn flatten(&self) -> String { + self.text.clone() + } } impl std::fmt::Display for Literal { diff --git a/src/syntax/content/parser/token/oblique.rs b/src/syntax/content/parser/token/oblique.rs index b776bf6..49b7cad 100644 --- a/src/syntax/content/parser/token/oblique.rs +++ b/src/syntax/content/parser/token/oblique.rs @@ -29,6 +29,10 @@ impl Parseable for Oblique { String::from("") } } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Oblique { diff --git a/src/syntax/content/parser/token/paragraph.rs b/src/syntax/content/parser/token/paragraph.rs index 500fb43..6b60c16 100644 --- a/src/syntax/content/parser/token/paragraph.rs +++ b/src/syntax/content/parser/token/paragraph.rs @@ -38,6 +38,10 @@ impl Parseable for Paragraph { ) } } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Paragraph { diff --git a/src/syntax/content/parser/token/preformat.rs b/src/syntax/content/parser/token/preformat.rs index e2ee75d..422f26c 100644 --- a/src/syntax/content/parser/token/preformat.rs +++ b/src/syntax/content/parser/token/preformat.rs @@ -46,6 +46,10 @@ impl Parseable for PreFormat { ) } } + + fn flatten(&self) -> String { + String::default() + } } #[cfg(test)] diff --git a/src/syntax/content/parser/token/strike.rs b/src/syntax/content/parser/token/strike.rs index a13c868..ced9e7d 100644 --- a/src/syntax/content/parser/token/strike.rs +++ b/src/syntax/content/parser/token/strike.rs @@ -26,6 +26,10 @@ impl Parseable for Strike { let tag = if self.open { "" } else { "" }; String::from(tag) } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Strike { diff --git a/src/syntax/content/parser/token/underline.rs b/src/syntax/content/parser/token/underline.rs index a9a1d14..690f94d 100644 --- a/src/syntax/content/parser/token/underline.rs +++ b/src/syntax/content/parser/token/underline.rs @@ -29,6 +29,10 @@ impl Parseable for Underline { String::from("") } } + + fn flatten(&self) -> String { + String::default() + } } impl std::fmt::Display for Underline { diff --git a/src/syntax/serial.rs b/src/syntax/serial.rs index 9f2b298..0157333 100644 --- a/src/syntax/serial.rs +++ b/src/syntax/serial.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use crate::{ - syntax::command::Arguments, - types::{Edge, Graph, Meta, Node}, + syntax::{command::Arguments, content::parser::flatten}, + types::{Edge, Graph, Node}, }; pub fn populate_graph() -> Graph { @@ -16,25 +16,25 @@ pub fn populate_graph() -> Graph { modulate_graph(&graph) } -fn modulate_graph(graph: &Graph) -> Graph { - let nodes = modulate_nodes(&graph.nodes); +fn modulate_graph(in_graph: &Graph) -> Graph { + let nodes = modulate_nodes(in_graph); - Graph { + let mut graph = Graph { incoming: make_incoming(&nodes), lowercase_keymap: map_lowercase_keys(&nodes), nodes, - meta: Meta { - config: graph.meta.config.clone().parse_text(), - ..graph.meta.clone() - }, - ..graph.to_owned() - } + ..in_graph.to_owned() + }; + + graph.parse(); + graph } -fn modulate_nodes(old_nodes: &HashMap) -> HashMap { +fn modulate_nodes(graph: &Graph) -> HashMap { + let old_nodes = graph.nodes.clone(); let mut nodes: HashMap = HashMap::default(); - for (key, node) in old_nodes { + for (key, node) in old_nodes.clone() { let connections = node.connections.clone().unwrap_or_default(); let mut new_edges = connections.clone(); @@ -43,7 +43,7 @@ fn modulate_nodes(old_nodes: &HashMap) -> HashMap { // Populate empty "from" IDs in edges with node's ID if edge.from.is_empty() { - new_edge.from.clone_from(key); + new_edge.from.clone_from(&key); } // Flag detached edges @@ -62,7 +62,7 @@ fn modulate_nodes(old_nodes: &HashMap) -> HashMap { from: key.clone(), to: link.clone(), anchor: String::default(), - detached: !old_nodes.contains_key(link), + detached: !old_nodes.clone().contains_key(link), }); } @@ -73,9 +73,25 @@ fn modulate_nodes(old_nodes: &HashMap) -> HashMap { node.title.clone() }; + let mut summary = if let Some(summary) = node.text.lines().next() { + if let Some(sentence) = node.text.split_once('.') { + format!("{}.", sentence.0) + } else { + String::from(summary) + } + } else { + node.text.clone() + }; + + if summary.len() > 300 { + summary.truncate(300); + summary.push('…'); + } + let new_node = Node { id: key.clone(), title: new_title, + summary: flatten(&summary, graph), connections: Some(new_edges), ..node.clone() }; @@ -177,27 +193,6 @@ mod tests { let message = graph.meta.messages.first().unwrap(); assert!(message.contains("expected value at line 1 column 1")); } - - #[test] - fn detached_node() { - let mut node = Node::new(None); - node.connections = Some(vec![Edge { - anchor: String::from("SomeAnchor"), - from: String::default(), - to: String::default(), - detached: false, - }]); - - let mut map: HashMap = HashMap::default(); - map.insert(String::from("SomeNode"), node); - - let modulated_map = modulate_nodes(&map); - let modulated_node = modulated_map.get("SomeNode").unwrap().clone(); - let modulated_connections = modulated_node.connections.unwrap(); - let modulated_connection = modulated_connections.first().unwrap(); - assert!(modulated_connection.anchor == "SomeAnchor"); - assert!(modulated_connection.detached); - } } #[cfg(test)] diff --git a/src/types.rs b/src/types.rs index f99f268..31aa78d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -23,6 +23,8 @@ pub struct Node { #[serde(default)] pub text: String, #[serde(default)] + pub summary: String, + #[serde(default)] pub title: String, #[serde(default)] pub links: Vec, @@ -107,7 +109,7 @@ pub struct Config { #[serde(default = "mktrue")] pub tree: bool, #[serde(default = "mkfalse")] - pub tree_node_text: bool, + pub tree_node_summary: bool, } // See: https://github.com/serde-rs/serde/issues/368 @@ -121,6 +123,7 @@ fn mk8() -> u16 { 8 } +#[derive(Clone)] pub struct QueryResult { pub node: Option, pub redirect: bool, @@ -144,7 +147,7 @@ impl Graph { pub fn find_node(&self, query: &str) -> QueryResult { let collapsed_query = query.trim().replace(" ", ""); - if let Some(exact_match) = self.nodes.get(query) { + let candidate = if let Some(exact_match) = self.nodes.get(query) { QueryResult { node: Some(exact_match.clone()), redirect: false, @@ -161,12 +164,30 @@ impl Graph { node: None, redirect: false, } + }; + + if let Some(ref candidate_node) = candidate.node + && !candidate_node.redirect.is_empty() + { + QueryResult { + node: self.find_node(&candidate_node.redirect).node, + redirect: true, + } + } else { + candidate } } pub fn get_root(&self) -> Option { self.nodes.get(&self.root_node).cloned() } + + pub fn parse(&mut self) { + self.meta.config.footer_text = + content::parse(&self.meta.config.footer_text, self); + self.meta.config.about_text = + content::parse(&self.meta.config.about_text, self); + } } impl Node { @@ -182,17 +203,7 @@ impl Node { links: vec![], redirect: String::default(), hidden: false, - } - } -} - -impl Config { - #[must_use] - pub fn parse_text(self) -> Config { - Config { - footer_text: content::parse(&self.footer_text, &self), - about_text: content::parse(&self.about_text, &self), - ..self + summary: String::default(), } } } @@ -222,7 +233,7 @@ impl Default for Config { site_description: String::default(), site_title: String::default(), tree: true, - tree_node_text: false, + tree_node_summary: false, } } } @@ -252,33 +263,35 @@ mod tests { #[test] fn empty_footer_text() { - let default_graph = populate_graph(); + let mut graph = populate_graph(); - let config = Config { + graph.meta.config = Config { footer_text: String::default(), - ..default_graph.meta.config + ..graph.meta.config }; - let parsed_config = config.parse_text(); + graph.parse(); - println!("{:?}", parsed_config.footer_text); - assert!(parsed_config.footer_text.is_empty()); + println!("{:?}", graph.meta.config.footer_text); + assert!(graph.meta.config.footer_text.is_empty()); } #[test] fn config_footer_text() { let payload = "0kqBrdS8NPrU4xVxh2xW0hUzAw926JCQ"; - let default_graph = populate_graph(); + let mut graph = populate_graph(); - let config = Config { + graph.meta.config = Config { footer_text: format!("`{payload}`"), - ..default_graph.meta.config + ..graph.meta.config }; - let parsed_config = config.parse_text(); + graph.parse(); assert!( - parsed_config + graph + .meta + .config .footer_text .matches(format!("{payload}").as_str()) .count() @@ -289,17 +302,19 @@ mod tests { #[test] fn config_about_text() { let payload = "ZqPFl84JlzSS0QUo61RwTUPONIE78Lmw"; - let default_graph = populate_graph(); + let mut graph = populate_graph(); - let config = Config { + graph.meta.config = Config { about_text: format!("`{payload}`"), - ..default_graph.meta.config + ..graph.meta.config }; - let parsed_config = config.parse_text(); + graph.parse(); assert!( - parsed_config + graph + .meta + .config .about_text .matches(format!("{payload}").as_str()) .count() diff --git a/static/graph.toml b/static/graph.toml index e433e38..7830df3 100644 --- a/static/graph.toml +++ b/static/graph.toml @@ -520,7 +520,7 @@ en is only possible thanks to a number of projects and people: [nodes.Roadmap] text = """ - [x] Redirects -- [ ] Strip/render some syntax in Tree text preview +- [x] Strip/render some syntax in Tree text preview - [x] Drop-down navigation - [ ] Meta-awareness - [ ] Detached edges @@ -540,7 +540,7 @@ text = """ - [ ] By most linked to - [ ] By most linked - [x] Anchors and connections - - [ ] Render detached anchors differently + - [x] Render detached anchors differently - [ ] Suffix-aware anchors - [x] Plural anchors (`|node|s` -> `node`) - [x] Ignore trailing punctuation @@ -560,6 +560,7 @@ text = """ - [x] External anchors - [x] Richer text formatting - [x] Nested formatting + - [ ] Blockquotes - [x] Headers - [x] Preformatted blocks - [x] _Oblique_, diff --git a/static/style.css b/static/style.css index 0842b68..c62f865 100644 --- a/static/style.css +++ b/static/style.css @@ -37,7 +37,12 @@ a { } a:visited { - text-decoration-color: #aaa; + text-decoration-color: #999; +} + +a.detached { + color: #595959; + text-decoration-color: #555555; } div.header-row { @@ -75,24 +80,6 @@ h1.node-title { margin: 10px 0; } -ul.tree-node-text { - display: inline; - padding-left: 0; -} - -li.tree-node-text { - display: inline; -} - -details.tree-node-text { - display: inline; - cursor: pointer; -} - -summary.tree-node-text { - display: inline; -} - footer div { margin: 20px 0; text-align: center; @@ -180,6 +167,11 @@ em#index-node-count { text-decoration-color: #159b9b; } + a.detached { + color: #acacac; + text-decoration-color: #777; + } + span.id-label { background-color: #444; border-color: #666; diff --git a/templates/tree.html b/templates/tree.html index ec6d4df..9e346ce 100644 --- a/templates/tree.html +++ b/templates/tree.html @@ -15,28 +15,20 @@
  • {{root_node.title}} - {% if root_node.connections or config.tree_node_text %} + {% if root_node.connections or config.tree_node_summary %}
      - {% if config.tree_node_text %} -
    • Text: -
        -
      • -
        - - {{root_node.text | truncate(length=120)}} - - {{root_node.text}} -
        + {% if config.tree_node_summary %} +
      • + {{ root_node.summary }}
      • -
      {% endif %} {% if root_node.connections %} - {% if config.tree_node_text %}
    • Connections + {% if config.tree_node_summary %}
    • Connections
        {% endif %} {% for connection in root_node.connections %}
      • {{connection.to}}
      • {% endfor %} - {% if config.tree_node_text %}
      + {% if config.tree_node_summary %}
  • {% endif %} {% endif %}
@@ -51,30 +43,22 @@ {% for node in nodes | filter(attribute="hidden", value=false)%}
  • {{node.title}} - {% if node.connections or config.tree_node_text %} + {% if node.connections or config.tree_node_summary %}
      - {% if config.tree_node_text %} -
    • Text: -
        -
      • -
        - - {{node.text | truncate(length=30)}} - - {{node.text}} -
        + {% if config.tree_node_summary %} +
      • + {{node.summary}}
      • -
      {% endif %} {% if node.connections %} - {% if config.tree_node_text %}
    • Connections + {% if config.tree_node_summary %}
    • Connections
        {% endif %} {% for connection in node.connections %} {% if not connection.detached %}
      • {{connection.to}}
      • {% endif %} {% endfor %} - {% if config.tree_node_text %}
      + {% if config.tree_node_summary %}
  • {% endif %} {% endif %}