diff --git a/content/index.tree b/content/index.tree index abc3e3f..38c086b 100644 --- a/content/index.tree +++ b/content/index.tree @@ -166,4 +166,4 @@ - breaking % content.link = "secret" - - this block includes another block's content + + this block includes another block's content diff --git a/crates/treehouse/src/html/mod.rs b/crates/treehouse/src/html/mod.rs index 7470bcc..eec01a6 100644 --- a/crates/treehouse/src/html/mod.rs +++ b/crates/treehouse/src/html/mod.rs @@ -18,3 +18,21 @@ impl<'a> Display for EscapeAttribute<'a> { Ok(()) } } + +pub struct EscapeHtml<'a>(&'a str); + +impl<'a> Display for EscapeHtml<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for c in self.0.chars() { + match c { + '<' => f.write_str("<")?, + '>' => f.write_str(">")?, + '&' => f.write_str("&")?, + '\'' => f.write_str("'")?, + '"' => f.write_str(""")?, + _ => f.write_char(c)?, + } + } + Ok(()) + } +} diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs index 831c0d5..5be2cf0 100644 --- a/crates/treehouse/src/html/tree.rs +++ b/crates/treehouse/src/html/tree.rs @@ -8,76 +8,37 @@ use crate::{ state::{toml_error_to_diagnostic, FileId, TomlError, Treehouse}, }; -use super::{attributes::Attributes, markdown}; +use super::{ + attributes::{Attributes, Content}, + markdown, EscapeHtml, +}; pub fn branch_to_html(s: &mut String, treehouse: &mut Treehouse, file_id: FileId, branch: &Branch) { - let source = treehouse.get_source(file_id); - - let mut successfully_parsed = true; - let mut attributes = if let Some(attributes) = &branch.attributes { - toml_edit::de::from_str(&source[attributes.data.clone()]).unwrap_or_else(|error| { - treehouse - .diagnostics - .push(toml_error_to_diagnostic(TomlError { - message: error.message().to_owned(), - span: error.span(), - file_id, - input_range: attributes.data.clone(), - })); - successfully_parsed = false; - Attributes::default() - }) - } else { - Attributes::default() - }; - let successfully_parsed = successfully_parsed; - - // Only check for attribute validity if the attributes were parsed successfully. - if successfully_parsed { - let attribute_warning_span = branch - .attributes - .as_ref() - .map(|attributes| attributes.percent.clone()) - .unwrap_or(branch.kind_span.clone()); - if attributes.id.is_empty() { - attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno()); - treehouse.diagnostics.push(Diagnostic { - severity: Severity::Warning, - code: Some("attr".into()), - message: "branch does not have an `id` attribute".into(), - labels: vec![Label { - style: LabelStyle::Primary, - file_id, - range: attribute_warning_span, - message: String::new(), - }], - notes: vec![ - format!( - "note: a generated id `{}` will be used, but this id is unstable and will not persist across generations", - attributes.id - ), - format!("help: run `treehouse fix {}` to add missing ids to branches", treehouse.get_filename(file_id)), - ], - }); - } - } + let attributes = parse_attributes(treehouse, file_id, branch); // Reborrow because the closure requires unique access (it adds a new diagnostic.) let source = treehouse.get_source(file_id); - let class = if !branch.children.is_empty() { - "branch" + let has_children = + !branch.children.is_empty() || matches!(attributes.content, Content::Link(_)); + + let class = if has_children { "branch" } else { "leaf" }; + let linked_branch = if let Content::Link(link) = &attributes.content { + format!( + " is=\"th-linked-branch\" data-th-link=\"{}\"", + EscapeHtml(link) + ) } else { - "leaf" + String::new() }; write!( s, - "
  • ", + "
  • ", EscapeAttribute(&attributes.id) ) .unwrap(); { - if !branch.children.is_empty() { + if has_children { s.push_str(match branch.kind { BranchKind::Expanded => "
    ", BranchKind::Collapsed => "
    ", @@ -110,18 +71,37 @@ pub fn branch_to_html(s: &mut String, treehouse: &mut Treehouse, file_id: FileId }); markdown::push_html(s, markdown_parser); - s.push_str(""); - { + if let Content::Link(link) = &attributes.content { write!( s, - "", + "", + EscapeAttribute(link), + EscapeHtml(link), + ) + .unwrap(); + } + + s.push_str(""); + { + if let Content::Link(link) = &attributes.content { + write!( + s, + "", + EscapeAttribute(link), + ) + .unwrap(); + } + + write!( + s, + "", EscapeAttribute(&attributes.id) ) .unwrap(); } s.push_str(""); - if !branch.children.is_empty() { + if has_children { s.push_str(""); branches_to_html(s, treehouse, file_id, &branch.children); s.push_str("
    "); @@ -132,6 +112,82 @@ pub fn branch_to_html(s: &mut String, treehouse: &mut Treehouse, file_id: FileId s.push_str("
  • "); } +fn parse_attributes(treehouse: &mut Treehouse, file_id: usize, branch: &Branch) -> Attributes { + let source = treehouse.get_source(file_id); + + let mut successfully_parsed = true; + let mut attributes = if let Some(attributes) = &branch.attributes { + toml_edit::de::from_str(&source[attributes.data.clone()]).unwrap_or_else(|error| { + treehouse + .diagnostics + .push(toml_error_to_diagnostic(TomlError { + message: error.message().to_owned(), + span: error.span(), + file_id, + input_range: attributes.data.clone(), + })); + successfully_parsed = false; + Attributes::default() + }) + } else { + Attributes::default() + }; + let successfully_parsed = successfully_parsed; + + // Only check for attribute validity if the attributes were parsed successfully. + if successfully_parsed { + let attribute_warning_span = branch + .attributes + .as_ref() + .map(|attributes| attributes.percent.clone()) + .unwrap_or(branch.kind_span.clone()); + + // Check that every block has an ID. + if attributes.id.is_empty() { + attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno()); + treehouse.diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: Some("attr".into()), + message: "branch does not have an `id` attribute".into(), + labels: vec![Label { + style: LabelStyle::Primary, + file_id, + range: attribute_warning_span.clone(), + message: String::new(), + }], + notes: vec![ + format!( + "note: a generated id `{}` will be used, but this id is unstable and will not persist across generations", + attributes.id + ), + format!("help: run `treehouse fix {}` to add missing ids to branches", treehouse.get_filename(file_id)), + ], + }); + } + + // Check that link-type blocks are `+`-type to facilitate lazy loading. + if let Content::Link(_) = &attributes.content { + if branch.kind == BranchKind::Expanded { + treehouse.diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: Some("attr".into()), + message: "`content.link` branch is expanded by default".into(), + labels: vec![Label { + style: LabelStyle::Primary, + file_id, + range: branch.kind_span.clone(), + message: String::new(), + }], + notes: vec![ + "note: `content.link` branches should normally be collapsed to allow for lazy loading".into(), + ], + }); + } + } + } + attributes +} + pub fn branches_to_html( s: &mut String, treehouse: &mut Treehouse, diff --git a/static/css/tree.css b/static/css/tree.css index 14dc5fd..6bde1da 100644 --- a/static/css/tree.css +++ b/static/css/tree.css @@ -97,8 +97,7 @@ opacity: 100%; } -.tree .branch-link { - background-image: url("../svg/link.svg"); +.tree .icon { background-repeat: no-repeat; background-position: 50% 50%; opacity: 35%; @@ -106,3 +105,24 @@ width: 24px; height: 24px; } + +.tree .icon-permalink { + background-image: url("../svg/permalink.svg"); +} + +.tree .icon-go { + background-image: url("../svg/go.svg"); +} + +.tree a.navigate { + background-repeat: no-repeat; + background-position: 0 50%; + opacity: 50%; + color: #000; + padding-left: 20px; +} + +.tree .link-loading { + padding-left: 24px; + opacity: 50%; +} diff --git a/static/js/tree.js b/static/js/tree.js new file mode 100644 index 0000000..56df70a --- /dev/null +++ b/static/js/tree.js @@ -0,0 +1,60 @@ +class LinkedBranch extends HTMLLIElement { + constructor() { + super(); + + this.linkedTree = this.getAttribute("data-th-link"); + + this.details = this.childNodes[0]; + this.innerUL = this.details.childNodes[1]; + + this.state = "notloaded"; + + this.loadingText = document.createElement("p"); + { + this.loadingText.className = "link-loading"; + let linkedTreeName = document.createElement("code"); + linkedTreeName.innerText = this.linkedTree; + this.loadingText.append("Loading ", linkedTreeName, "..."); + } + this.innerUL.appendChild(this.loadingText); + + // This produces a warning during static generation but we still want to handle that + // correctly. Having an expanded-by-default linked block can be useful in development. + if (this.details.open) { + this.loadTree(); + } + + this.details.addEventListener("toggle", event => { + if (this.details.open) { + this.loadTree(); + } + }); + } + + loadTree() { + if (this.state == "notloaded") { + this.state = "loading"; + + fetch(`/${this.linkedTree}.html`) + .then(request => request.text()) + .then(text => { + let parser = new DOMParser(); + let linkedDocument = parser.parseFromString(text, "text/html"); + let main = linkedDocument.getElementsByTagName("main")[0]; + let ul /*: Element */ = main.getElementsByTagName("ul")[0]; + console.log(ul); + + this.loadingText.remove(); + + for (let i = 0; i < ul.childNodes.length; ++i) { + this.innerUL.appendChild(ul.childNodes[i]); + } + }) + .catch(error => { + this.loadingText.innerText = error.toString(); + }); + } + } +} + +customElements.define("th-linked-branch", LinkedBranch, { extends: "li" }); diff --git a/static/svg/link.svg b/static/svg/permalink.svg similarity index 100% rename from static/svg/link.svg rename to static/svg/permalink.svg diff --git a/template/tree.hbs b/template/tree.hbs index f9985ab..9e66849 100644 --- a/template/tree.hbs +++ b/template/tree.hbs @@ -11,6 +11,7 @@ +