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 @@
+