adding document mode

I've been thinking a lot about the treehouse and I feel like it's time to say goodbye to the tree format.
This commit is contained in:
りき萌 2025-07-10 16:50:41 +02:00
parent 550c062327
commit 36705e7c1e
31 changed files with 940 additions and 409 deletions

View file

@ -152,6 +152,8 @@ impl Config {
}
pub fn page_url(&self, page: &str) -> String {
// We don't want .dj appearing in URLs, though it exists as a disambiguator in [page:] links.
let page = page.strip_suffix(".dj").unwrap_or(page);
format!("{}/{}", self.site, page)
}

View file

@ -1,5 +1,6 @@
mod atom;
mod dir_helper;
mod doc;
mod include_static_helper;
mod simple_template;
mod tree;
@ -7,6 +8,7 @@ mod tree;
use std::{ops::ControlFlow, sync::Arc};
use atom::FeedDir;
use chrono::{DateTime, Utc};
use dir_helper::DirHelper;
use handlebars::{handlebars_helper, Handlebars};
use include_static_helper::IncludeStaticHelper;
@ -18,13 +20,14 @@ use crate::{
dirs::Dirs,
fun::seasons::Season,
generate::{
doc::DocDir,
simple_template::SimpleTemplateDir,
tree::{DirIndex, TreehouseDir},
},
sources::Sources,
vfs::{
self, layered_dir, AnchoredAtExt, Cd, Content, ContentCache, Dir, DynDir, Entries,
HtmlCanonicalize, MemDir, Overlay, ToDynDir, VPath, VPathBuf,
self, layered_dir, AnchoredAtExt, Cd, Content, ContentCache, Dir, DynDir, HtmlCanonicalize,
MemDir, ToDynDir, VPath, VPathBuf,
},
};
@ -54,8 +57,10 @@ fn create_handlebars(site: &str, static_: DynDir) -> Handlebars<'static> {
let mut handlebars = Handlebars::new();
handlebars_helper!(cat: |a: String, b: String| a + &b);
handlebars_helper!(iso_date: |d: DateTime<Utc>| d.format("%F").to_string());
handlebars.register_helper("cat", Box::new(cat));
handlebars.register_helper("iso_date", Box::new(iso_date));
handlebars.register_helper("asset", Box::new(DirHelper::new(site, static_.clone())));
handlebars.register_helper(
"include_static",
@ -103,7 +108,13 @@ pub fn target(dirs: Arc<Dirs>, sources: Arc<Sources>) -> DynDir {
let dir_index = DirIndex::new(sources.treehouse.files_by_tree_path.keys().map(|x| &**x));
let treehouse_dir = layered_dir(&[
TreehouseDir::new(dirs, sources.clone(), handlebars.clone(), dir_index).to_dyn(),
TreehouseDir::new(dirs.clone(), sources.clone(), handlebars.clone(), dir_index).to_dyn(),
DocDir {
sources: sources.clone(),
dirs,
handlebars: handlebars.clone(),
}
.to_dyn(),
SimpleTemplateDir::new(sources.clone(), handlebars.clone()).to_dyn(),
]);

View file

@ -11,7 +11,7 @@ use crate::{
html::djot::{self, resolve_link},
sources::Sources,
state::FileId,
tree::SemaBranchId,
tree::{feed, SemaBranchId},
vfs::{self, Content, Dir, Entries, VPath, VPathBuf},
};
@ -155,7 +155,7 @@ fn extract_entries(sources: &Sources, dirs: &Dirs, file_id: FileId) -> Vec<Entry
let branch = sources.treehouse.tree.branch(branch_id);
let text = &sources.treehouse.source(file_id).input()[branch.content.clone()];
let parsed = parse_entry(sources, dirs, file_id, jotdown::Parser::new(text));
let parsed = feed::parse_entry(sources, dirs, file_id, jotdown::Parser::new(text));
let mut summary = String::new();
branches_to_html_simple(&mut summary, sources, dirs, file_id, &branch.children);
@ -177,94 +177,6 @@ fn extract_entries(sources: &Sources, dirs: &Dirs, file_id: FileId) -> Vec<Entry
.collect()
}
#[derive(Debug, Clone)]
struct ParsedEntry {
title: Option<String>,
link: Option<String>,
}
fn parse_entry(
sources: &Sources,
dirs: &Dirs,
file_id: FileId,
parser: jotdown::Parser,
) -> ParsedEntry {
let mut parser = parser.into_offset_iter();
while let Some((event, span)) = parser.next() {
if let jotdown::Event::Start(jotdown::Container::Heading { .. }, _attrs) = &event {
let mut events = vec![(event, span)];
for (event, span) in parser.by_ref() {
// To my knowledge headings cannot nest, so it's okay not keeping a stack here.
let is_heading = matches!(
event,
jotdown::Event::End(jotdown::Container::Heading { .. })
);
events.push((event, span));
if is_heading {
break;
}
}
let title_events: Vec<_> = events
.iter()
.filter(|(event, _)| {
!matches!(
event,
// A little repetitive, but I don't mind.
// The point of this is not to include extra <h3> and <a> in the link text,
// but preserve other formatting such as bold, italic, code, etc.
jotdown::Event::Start(
jotdown::Container::Link(_, _) | jotdown::Container::Heading { .. },
_
) | jotdown::Event::End(
jotdown::Container::Link(_, _) | jotdown::Container::Heading { .. }
)
)
})
.cloned()
.collect();
let mut title = String::new();
let _render_diagnostics = djot::Renderer {
config: &sources.config,
dirs,
treehouse: &sources.treehouse,
file_id,
// How. Just, stop.
page_id: "liquidex-you-reeeeeal-dummy".into(),
}
.render(&title_events, &mut title);
let link = events.iter().find_map(|(event, _)| {
if let jotdown::Event::Start(jotdown::Container::Link(link, link_type), _) = event {
Some(link_url(sources, dirs, link, *link_type))
} else {
None
}
});
return ParsedEntry {
title: (!title.is_empty()).then_some(title),
link,
};
}
}
ParsedEntry {
title: None,
link: None,
}
}
fn link_url(sources: &Sources, dirs: &Dirs, url: &str, link_type: jotdown::LinkType) -> String {
if let jotdown::LinkType::Span(jotdown::SpanLinkType::Unresolved) = link_type {
if let Some(url) = resolve_link(&sources.config, &sources.treehouse, dirs, url) {
return url;
}
}
url.to_owned()
}
/// Extremely simple HTML renderer without the treehouse's fancy branch folding and linking features.
fn branches_to_html_simple(
s: &mut String,

242
src/generate/doc.rs Normal file
View file

@ -0,0 +1,242 @@
use std::{
fmt::{self},
sync::Arc,
};
use anyhow::Context;
use chrono::{DateTime, Utc};
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use tracing::{error, instrument};
use crate::{
dirs::Dirs,
generate::BaseTemplateData,
html::djot,
sources::Sources,
state::{report_diagnostics, toml_error_to_diagnostic, FileId, TomlError},
tree::{attributes::Picture, feed},
vfs::{Content, Dir, Query, VPath},
};
#[derive(Default, Deserialize)]
struct Attributes {
/// Template to use for generating the page.
/// Defaults to `_tree.hbs`.
#[serde(default)]
template: Option<String>,
/// Title of the page.
/// The only necessary field.
/// Unlike tree pages, doc pages always have titles.
title: String,
/// ID of picture attached to the page, to be used as a thumbnail.
#[serde(default)]
thumbnail: Option<Picture>,
/// Additional scripts to load into to the page.
/// These are relative to the /static/js directory.
#[serde(default)]
scripts: Vec<String>,
/// Additional styles to load into to the page.
/// These are relative to the /static/css directory.
#[serde(default)]
styles: Vec<String>,
/// If not `None`, the page will get an additional 'feed' field in template data, containing
/// updates from the news feed of the specified name.
#[serde(default)]
include_feed: Option<IncludeFeed>,
}
#[derive(Deserialize)]
struct IncludeFeed {
/// The name of the feed (within the treehouse database.)
name: String,
/// The title of the feed shown on the page.
title: String,
}
#[derive(Serialize)]
struct Page {
title: String,
thumbnail: Option<Thumbnail>,
scripts: Vec<String>,
styles: Vec<String>,
tree_path: String,
doc: String,
feed: Option<Feed>,
}
#[derive(Serialize)]
struct Thumbnail {
url: String,
alt: Option<String>,
}
#[derive(Serialize)]
struct Feed {
title: String,
entries: Vec<Entry>,
}
#[derive(Serialize)]
struct Entry {
title: String,
url: String,
updated: DateTime<Utc>,
categories: Vec<String>,
}
#[derive(Serialize)]
struct PageTemplateData<'a> {
#[serde(flatten)]
base: &'a BaseTemplateData<'a>,
page: Page,
}
pub struct DocDir {
pub sources: Arc<Sources>,
pub dirs: Arc<Dirs>,
pub handlebars: Arc<Handlebars<'static>>,
}
impl DocDir {
#[instrument("DocDir::content", skip(self))]
pub fn content(&self, path: &VPath) -> Option<Content> {
if let Some(file_id) = self
.sources
.treehouse
.files_by_doc_path
.get(&path.with_extension("dj"))
{
let source = self.sources.treehouse.source(*file_id).input();
return Some(Content::new(
"text/html",
self.generate(*file_id, path, source).into_bytes(),
));
}
None
}
fn generate(&self, file_id: FileId, path: &VPath, source: &str) -> String {
let (front_matter, text) = source.split_once("+++").unwrap_or(("", source));
let attributes: Attributes =
toml_edit::de::from_str(front_matter).unwrap_or_else(|error| {
_ = report_diagnostics(
&self.sources.treehouse,
&[toml_error_to_diagnostic(TomlError {
message: error.message().to_owned(),
span: error.span(),
file_id,
input_range: 0..front_matter.len(),
})],
);
Attributes::default()
});
let events: Vec<_> = jotdown::Parser::new(text).into_offset_iter().collect();
let mut rendered_markup = String::new();
let render_diagnostics = djot::Renderer {
config: &self.sources.config,
dirs: &self.dirs,
treehouse: &self.sources.treehouse,
file_id,
page_id: path.to_string(),
}
.render(&events, &mut rendered_markup);
let template_name = attributes.template.as_deref().unwrap_or("_doc.hbs");
let render_result = self
.handlebars
.render(
template_name,
&PageTemplateData {
base: &BaseTemplateData::new(&self.sources),
page: Page {
title: attributes.title,
thumbnail: attributes.thumbnail.map(|pic| Thumbnail {
url: self.sources.config.pic_url(&*self.dirs.pic, &pic.id),
alt: pic.alt,
}),
scripts: attributes.scripts,
styles: attributes.styles,
tree_path: path.to_string(),
doc: rendered_markup,
feed: attributes.include_feed.and_then(|feed| {
Some(Feed {
title: feed.title,
entries: self
.generate_feed(&feed.name)
.inspect_err(|e| {
error!("generating feed for {path} failed: {e}")
})
.ok()?,
})
}),
},
},
)
.context("template rendering failed");
match render_result {
Ok(rendered) => rendered,
Err(error) => format!("{error:#?}"),
}
}
fn generate_feed(&self, name: &str) -> anyhow::Result<Vec<Entry>> {
let file_id = *self
.sources
.treehouse
.feeds_by_name
.get(name)
.context("no feed with the given name")?;
let roots = &self.sources.treehouse.roots[&file_id];
Ok(roots
.branches
.iter()
.flat_map(|&branch_id| {
let branch = self.sources.treehouse.tree.branch(branch_id);
let text = &self.sources.treehouse.source(file_id).input()[branch.content.clone()];
let parsed = feed::parse_entry(
&self.sources,
&self.dirs,
file_id,
jotdown::Parser::new(text),
);
let updated = branch
.attributes
.timestamp()
.unwrap_or(DateTime::UNIX_EPOCH); // if you see the Unix epoch... oops
parsed.link.map(|url| Entry {
updated,
url,
title: parsed.title.unwrap_or_else(|| "untitled".into()),
categories: branch.attributes.tags.clone(),
})
})
.collect())
}
}
impl Dir for DocDir {
fn query(&self, path: &VPath, query: &mut Query) {
query.try_provide(|| self.content(path));
}
}
impl fmt::Debug for DocDir {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("DocDir")
}
}

View file

@ -106,21 +106,6 @@ impl<'a> Writer<'a> {
range: Range<usize>,
out: &mut String,
) -> std::fmt::Result {
if let Event::Start(Container::Footnote { label: _ }, ..) = e {
self.diagnostics.push(Diagnostic {
severity: Severity::Error,
code: Some("djot".into()),
message: "Djot footnotes are not supported".into(),
labels: vec![Label {
style: LabelStyle::Primary,
file_id: self.renderer.file_id,
range: range.clone(),
message: "".into(),
}],
notes: vec![],
})
}
if matches!(&e, Event::Start(Container::LinkDefinition { .. }, ..)) {
self.ignore_next_event = true;
return Ok(());
@ -163,7 +148,7 @@ impl<'a> Writer<'a> {
} => {
out.push_str("<ol");
if *start > 1 {
write!(out, r#" start="{}""#, start)?;
write!(out, r#" start="{start}""#)?;
}
if let Some(ty) = match numbering {
Decimal => None,
@ -172,7 +157,7 @@ impl<'a> Writer<'a> {
RomanLower => Some('i'),
RomanUpper => Some('I'),
} {
write!(out, r#" type="{}""#, ty)?;
write!(out, r#" type="{ty}""#)?;
}
}
}
@ -182,7 +167,7 @@ impl<'a> Writer<'a> {
}
Container::DescriptionList => out.push_str("<dl"),
Container::DescriptionDetails => out.push_str("<dd"),
Container::Footnote { .. } => unreachable!(),
Container::Footnote { label } => out.push_str(label),
Container::Table => out.push_str("<table"),
Container::TableRow { .. } => out.push_str("<tr"),
Container::Section { .. } => {}
@ -193,7 +178,7 @@ impl<'a> Writer<'a> {
}
out.push_str("<p");
}
Container::Heading { level, .. } => write!(out, "<h{}", level)?,
Container::Heading { level, .. } => write!(out, "<h{level}")?,
Container::TableCell { head: false, .. } => out.push_str("<td"),
Container::TableCell { head: true, .. } => out.push_str("<th"),
Container::Caption => out.push_str("<caption"),
@ -275,7 +260,7 @@ impl<'a> Writer<'a> {
.into_iter()
.filter(|(a, _)| !(*a == "class" || a.starts_with(':')))
{
write!(out, r#" {}=""#, key)?;
write!(out, r#" {key}=""#)?;
value.parts().for_each(|part| write_attr(part, out));
out.push('"');
}
@ -338,7 +323,7 @@ impl<'a> Writer<'a> {
Alignment::Center => "center",
Alignment::Right => "right",
};
write!(out, r#" style="text-align: {};">"#, a)?;
write!(out, r#" style="text-align: {a};">"#)?;
}
Container::CodeBlock { language } => {
if language.is_empty() {
@ -444,7 +429,7 @@ impl<'a> Writer<'a> {
}
Container::DescriptionList => out.push_str("</dl>"),
Container::DescriptionDetails => out.push_str("</dd>"),
Container::Footnote { .. } => unreachable!(),
Container::Footnote { label } => out.push_str(label),
Container::Table => out.push_str("</table>"),
Container::TableRow { .. } => out.push_str("</tr>"),
Container::Section { .. } => {}
@ -455,7 +440,7 @@ impl<'a> Writer<'a> {
}
out.push_str("</p>");
}
Container::Heading { level, .. } => write!(out, "</h{}>", level)?,
Container::Heading { level, .. } => write!(out, "</h{level}>")?,
Container::TableCell { head: false, .. } => out.push_str("</td>"),
Container::TableCell { head: true, .. } => out.push_str("</th>"),
Container::Caption => out.push_str("</caption>"),
@ -537,19 +522,8 @@ impl<'a> Writer<'a> {
Raw::Html => out.push_str(s),
Raw::Other => {}
},
Event::FootnoteReference(_label) => {
self.diagnostics.push(Diagnostic {
severity: Severity::Error,
code: Some("djot".into()),
message: "Djot footnotes are unsupported".into(),
labels: vec![Label {
style: LabelStyle::Primary,
file_id: self.renderer.file_id,
range,
message: "".into(),
}],
notes: vec![],
});
Event::FootnoteReference(label) => {
out.push_str(label);
}
Event::Symbol(sym) => {
if let Some(vpath) = self.renderer.config.emoji.get(sym.as_ref()) {
@ -624,7 +598,7 @@ impl<'a> Writer<'a> {
}
out.push_str("<hr");
for (a, v) in attrs {
write!(out, r#" {}=""#, a)?;
write!(out, r#" {a}=""#)?;
v.parts().for_each(|part| write_attr(part, out));
out.push('"');
}
@ -655,7 +629,7 @@ fn write_escape(mut s: &str, escape_quotes: bool, out: &mut String) {
'"' if escape_quotes => Some("&quot;"),
_ => None,
}
.map_or(false, |s| {
.is_some_and(|s| {
ent = s;
true
})

View file

@ -2,7 +2,7 @@ use std::{collections::HashMap, ops::ControlFlow};
use anyhow::{anyhow, Context};
use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
use tracing::{info_span, instrument};
use tracing::{error, info_span, instrument};
use crate::{
config::Config,
@ -66,14 +66,19 @@ fn load_trees(config: &Config, dirs: &Dirs) -> anyhow::Result<Treehouse> {
let mut parsed_trees = HashMap::new();
let mut paths = vec![];
let mut doc_paths = vec![];
vfs::walk_dir_rec(&*dirs.content, VPath::ROOT, &mut |path| {
if path.extension() == Some("tree") {
paths.push(path.to_owned());
match path.extension() {
Some("tree") => paths.push(path.to_owned()),
Some("dj") => doc_paths.push(path.to_owned()),
_ => (),
}
ControlFlow::Continue(())
});
// Trees
// NOTE: Sources are filled in later; they can be left out until a call to report_diagnostics.
let file_ids: Vec<_> = paths
.iter()
@ -132,5 +137,18 @@ fn load_trees(config: &Config, dirs: &Dirs) -> anyhow::Result<Treehouse> {
report_diagnostics(&treehouse, &diagnostics)?;
// Docs
for path in doc_paths {
if let Some(input) =
vfs::query::<Content>(&dirs.content, &path).and_then(|c| c.string().ok())
{
let file_id = treehouse.add_file(path.clone(), Source::Other(input));
treehouse.files_by_doc_path.insert(path, file_id);
} else {
error!("doc {path} does not exist in content directory even though it was enumerated via walk_dir_rec");
}
}
Ok(treehouse)
}

View file

@ -66,7 +66,8 @@ pub struct FileId(usize);
/// Treehouse compilation context.
pub struct Treehouse {
pub files: Vec<File>,
pub files_by_tree_path: HashMap<VPathBuf, FileId>,
pub files_by_tree_path: HashMap<VPathBuf, FileId>, // trees only
pub files_by_doc_path: HashMap<VPathBuf, FileId>, // docs only
pub feeds_by_name: HashMap<String, FileId>,
pub tree: SemaTree,
@ -83,6 +84,7 @@ impl Treehouse {
Self {
files: vec![],
files_by_tree_path: HashMap::new(),
files_by_doc_path: HashMap::new(),
feeds_by_name: HashMap::new(),
tree: SemaTree::default(),

View file

@ -1,5 +1,6 @@
pub mod ast;
pub mod attributes;
pub mod feed;
pub mod mini_template;
pub mod pull;

View file

@ -23,10 +23,6 @@ pub struct RootAttributes {
#[serde(default = "default_icon")]
pub icon: String,
/// Summary of the generated .html page.
#[serde(default)]
pub description: Option<String>,
/// ID of picture attached to the page, to be used as a thumbnail.
#[serde(default)]
pub thumbnail: Option<Picture>,
@ -50,7 +46,7 @@ pub struct RootAttributes {
#[serde(default)]
pub timestamps: Option<Timestamps>,
/// When specified, this page will have a corresponding Atom feed under `rss/{feed}.xml`.
/// When specified, this page will have a corresponding Atom feed under `feed/{feed}.atom`.
///
/// In feeds, top-level branches are expected to have a single heading containing the post title.
/// Their children are turned into the post description

94
src/tree/feed.rs Normal file
View file

@ -0,0 +1,94 @@
use crate::{
dirs::Dirs,
html::djot::{self, resolve_link},
sources::Sources,
state::FileId,
};
#[derive(Debug, Clone)]
pub struct ParsedEntry {
pub title: Option<String>,
pub link: Option<String>,
}
pub fn parse_entry(
sources: &Sources,
dirs: &Dirs,
file_id: FileId,
parser: jotdown::Parser,
) -> ParsedEntry {
let mut parser = parser.into_offset_iter();
while let Some((event, span)) = parser.next() {
if let jotdown::Event::Start(jotdown::Container::Heading { .. }, _attrs) = &event {
let mut events = vec![(event, span)];
for (event, span) in parser.by_ref() {
// To my knowledge headings cannot nest, so it's okay not keeping a stack here.
let is_heading = matches!(
event,
jotdown::Event::End(jotdown::Container::Heading { .. })
);
events.push((event, span));
if is_heading {
break;
}
}
let title_events: Vec<_> = events
.iter()
.filter(|(event, _)| {
!matches!(
event,
// A little repetitive, but I don't mind.
// The point of this is not to include extra <h3> and <a> in the link text,
// but preserve other formatting such as bold, italic, code, etc.
jotdown::Event::Start(
jotdown::Container::Link(_, _) | jotdown::Container::Heading { .. },
_
) | jotdown::Event::End(
jotdown::Container::Link(_, _) | jotdown::Container::Heading { .. }
)
)
})
.cloned()
.collect();
let mut title = String::new();
let _render_diagnostics = djot::Renderer {
config: &sources.config,
dirs,
treehouse: &sources.treehouse,
file_id,
// How. Just, stop.
page_id: "liquidex-you-reeeeeal-dummy".into(),
}
.render(&title_events, &mut title);
let link = events.iter().find_map(|(event, _)| {
if let jotdown::Event::Start(jotdown::Container::Link(link, link_type), _) = event {
Some(link_url(sources, dirs, link, *link_type))
} else {
None
}
});
return ParsedEntry {
title: (!title.is_empty()).then_some(title),
link,
};
}
}
ParsedEntry {
title: None,
link: None,
}
}
fn link_url(sources: &Sources, dirs: &Dirs, url: &str, link_type: jotdown::LinkType) -> String {
if let jotdown::LinkType::Span(jotdown::SpanLinkType::Unresolved) = link_type {
if let Some(url) = resolve_link(&sources.config, &sources.treehouse, dirs, url) {
return url;
}
}
url.to_owned()
}