add a tagging system to the website

This commit is contained in:
りき萌 2025-08-24 13:18:51 +02:00
parent 701da6bc4b
commit e1b6578b2a
97 changed files with 1025 additions and 979 deletions

138
src/doc.rs Normal file
View file

@ -0,0 +1,138 @@
use chrono::{DateTime, Utc};
use codespan_reporting::diagnostic::{Diagnostic, Label};
use serde::Deserialize;
use crate::{
state::{FileId, TomlError, Treehouse, toml_error_to_diagnostic},
tree::attributes::{Picture, timestamp_from_id},
};
#[derive(Debug, Clone)]
pub struct Doc {
pub attributes: Attributes,
pub text: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Attributes {
/// Template to use for generating the page.
/// Defaults to `_tree.hbs`.
#[serde(default)]
pub template: Option<String>,
/// The unique ID of the doc.
/// Required to appear in feeds.
///
/// - New format: `doc?{date}-{name}`, where `{date}` is a `YYYY-MM-DD` date, and `{name}` is
/// the filename of the document (or otherwise a unique name which doesn't conflict with docs
/// made that day.)
/// - Old format: `b?{ulid}`, where `{ulid}` is a ULID.
/// This follows the format of branches.
#[serde(default)]
pub id: String,
/// Title of the page.
/// The only necessary field.
/// Unlike tree pages, doc pages always have titles.
pub title: String,
/// Tags assigned to the document.
/// Generally, you want to assign public documents to #all for them to show up on the front page.
#[serde(default)]
pub tags: Vec<String>,
/// Timestamp when the document was last updated.
/// Required for inclusion in feeds.
/// For pages with old style IDs, this is inferred from the ID.
#[serde(default)]
pub updated: Option<DateTime<Utc>>,
/// ID of picture attached to the page, to be used as a thumbnail.
#[serde(default)]
pub thumbnail: Option<Picture>,
/// Additional scripts to load into to the page.
/// These are relative to the /static/js directory.
#[serde(default)]
pub scripts: Vec<String>,
/// Additional styles to load into to the page.
/// These are relative to the /static/css directory.
#[serde(default)]
pub styles: Vec<String>,
/// If not `None`, the page will get an additional 'feed' field in template data, containing
/// a feed of pages with the specified tag.
#[serde(default)]
pub include_feed: Option<IncludeFeed>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IncludeFeed {
/// The tag to look for.
pub tag: String,
/// The title of the feed shown on the page.
pub title: String,
}
impl Doc {
pub fn parse(treehouse: &mut Treehouse, file_id: FileId) -> (Doc, Vec<Diagnostic<FileId>>) {
let mut diagnostics = vec![];
let source = treehouse.source(file_id).input();
let (front_matter, text) = source.split_once("+++").unwrap_or(("", source));
let attributes_span = 0..front_matter.len();
let mut attributes: Attributes =
toml_edit::de::from_str(front_matter).unwrap_or_else(|error| {
diagnostics.push(toml_error_to_diagnostic(TomlError {
message: error.message().to_owned(),
span: error.span(),
file_id,
input_range: attributes_span.clone(),
}));
Attributes::default()
});
// Infer attributes
if let Some(branch_id) = attributes.id.strip_prefix("b?")
&& let Some(timestamp) = timestamp_from_id(branch_id)
{
attributes.updated = Some(timestamp);
}
// Emit warnings
if !attributes.tags.is_empty() {
if attributes.id.is_empty() {
diagnostics.push(
Diagnostic::warning()
.with_code("attr")
.with_message("doc is tagged but missing id attribute")
.with_labels(vec![Label::primary(file_id, attributes_span.clone())])
.with_notes(vec!["id is required for showing up in feeds".into()]),
);
} else if attributes.updated.is_none() {
diagnostics.push(
Diagnostic::warning()
.with_code("attr")
.with_message("doc is tagged but missing updated attribute")
.with_labels(vec![Label::primary(file_id, attributes_span.clone())])
.with_notes(vec![
"updated attribute is required for showing up in feeds".into(),
]),
);
}
}
(
Doc {
attributes,
text: text.to_owned(),
},
diagnostics,
)
}
}

59
src/feed.rs Normal file
View file

@ -0,0 +1,59 @@
use chrono::{DateTime, Utc};
use crate::sources::Sources;
#[derive(Debug, Clone)]
pub struct FeedEntry {
pub id: String,
pub updated: Option<DateTime<Utc>>,
pub url: String,
pub title: String,
pub tags: Vec<String>,
}
pub fn generate(sources: &Sources, tag_name: &str) -> Option<Vec<FeedEntry>> {
let mut entries = vec![];
let tag = sources.treehouse.tags.get(tag_name)?;
for file_id in &tag.files {
if let Some(roots) = sources.treehouse.roots.get(file_id)
&& let Some(id) = roots.attributes.id.clone()
{
entries.push(FeedEntry {
id,
updated: roots.attributes.timestamps.map(|ts| ts.updated),
url: format!(
"{}/{}.tree",
sources.config.site,
sources.treehouse.tree_path(*file_id).unwrap()
),
title: roots.attributes.title.clone(),
tags: roots.attributes.tags.clone(),
});
} else if let Some(doc) = sources.treehouse.docs.get(file_id)
&& !doc.attributes.id.is_empty()
{
entries.push(FeedEntry {
id: doc.attributes.id.clone(),
updated: doc.attributes.updated,
url: format!(
"{}/{}",
sources.config.site,
sources.treehouse.path(*file_id).with_extension("")
),
title: doc.attributes.title.clone(),
tags: doc.attributes.tags.clone(),
});
} else {
unreachable!(
"{file_id:?} registered in tag #{tag_name} is not actually in the treehouse"
);
// Well... either that, or unknown variant.
}
}
entries.sort_by_key(|entry| entry.updated);
entries.reverse();
Some(entries)
}

View file

@ -10,7 +10,7 @@ use std::{ops::ControlFlow, sync::Arc};
use atom::FeedDir;
use chrono::{DateTime, Utc};
use dir_helper::DirHelper;
use handlebars::{handlebars_helper, Handlebars};
use handlebars::{Handlebars, handlebars_helper};
use include_static_helper::IncludeStaticHelper;
use serde::Serialize;
use tracing::{error, info_span, instrument};
@ -26,8 +26,8 @@ use crate::{
},
sources::Sources,
vfs::{
self, layered_dir, AnchoredAtExt, Cd, Content, ContentCache, Dir, DynDir, HtmlCanonicalize,
MemDir, ToDynDir, VPath, VPathBuf,
self, AnchoredAtExt, Cd, Content, ContentCache, Dir, DynDir, HtmlCanonicalize, MemDir,
ToDynDir, VPath, VPathBuf, layered_dir,
},
};
@ -37,7 +37,6 @@ struct BaseTemplateData<'a> {
import_map: String,
season: Option<Season>,
dev: bool,
feeds: Vec<String>,
}
impl<'a> BaseTemplateData<'a> {
@ -48,7 +47,6 @@ impl<'a> BaseTemplateData<'a> {
.expect("import map should be serializable to JSON"),
season: Season::current(),
dev: cfg!(debug_assertions),
feeds: sources.treehouse.feeds_by_name.keys().cloned().collect(),
}
}
}
@ -73,12 +71,12 @@ fn create_handlebars(site: &str, static_: DynDir) -> Handlebars<'static> {
#[instrument(skip(handlebars))]
fn load_templates(handlebars: &mut Handlebars, dir: &dyn Dir) {
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
if path.extension() == Some("hbs") {
if let Some(content) = vfs::query::<Content>(dir, path).and_then(|c| c.string().ok()) {
let _span = info_span!("register_template", ?path).entered();
if let Err(err) = handlebars.register_template_string(path.as_str(), content) {
error!("in template: {err}");
}
if path.extension() == Some("hbs")
&& let Some(content) = vfs::query::<Content>(dir, path).and_then(|c| c.string().ok())
{
let _span = info_span!("register_template", ?path).entered();
if let Err(err) = handlebars.register_template_string(path.as_str(), content) {
error!("in template: {err}");
}
}
ControlFlow::Continue(())

View file

@ -1,6 +1,6 @@
use std::{fmt, sync::Arc};
use anyhow::Context;
use anyhow::{Context, anyhow};
use chrono::{DateTime, Utc};
use handlebars::Handlebars;
use serde::Serialize;
@ -8,10 +8,8 @@ use tracing::{info_span, instrument};
use crate::{
dirs::Dirs,
html::djot::{self, resolve_link},
feed,
sources::Sources,
state::FileId,
tree::{feed, SemaBranchId},
vfs::{self, Content, Dir, Entries, VPath, VPathBuf},
};
@ -38,12 +36,15 @@ impl FeedDir {
fn entries(&self, path: &VPath) -> Vec<VPathBuf> {
if path == VPath::ROOT {
self.sources
let mut entries: Vec<_> = self
.sources
.treehouse
.feeds_by_name
.tags
.keys()
.map(|name| VPathBuf::new(format!("{name}.atom")))
.collect()
.collect();
entries.push(VPathBuf::new("new.atom")); // redirect: new -> all
entries
} else {
vec![]
}
@ -51,21 +52,20 @@ impl FeedDir {
fn content(&self, path: &VPath) -> Option<Content> {
if path.extension() == Some("atom") {
let feed_name = path.with_extension("").to_string();
self.sources
.treehouse
.feeds_by_name
.get(&feed_name)
.map(|file_id| {
Content::new(
"application/atom+xml",
generate_or_error(&self.sources, &self.dirs, &self.handlebars, *file_id)
.into(),
)
})
} else {
None
let mut feed_name = path.with_extension("").to_string();
if feed_name == "new" {
feed_name = "all".into(); // redirect: new -> all
}
if self.sources.treehouse.tags.contains_key(&feed_name) {
return Some(Content::new(
"application/atom+xml",
generate_or_error(&self.sources, &self.dirs, &self.handlebars, &feed_name)
.into(),
));
}
}
None
}
}
@ -85,18 +85,16 @@ impl fmt::Debug for FeedDir {
#[derive(Serialize)]
struct Feed {
name: String,
updated: DateTime<Utc>,
entries: Vec<Entry>,
}
#[derive(Serialize)]
struct Entry {
id: String,
updated: DateTime<Utc>,
updated: Option<DateTime<Utc>>,
url: String,
title: String,
categories: Vec<String>,
summary: String,
tags: Vec<String>,
}
#[derive(Serialize)]
@ -104,6 +102,19 @@ struct AtomTemplateData<'a> {
#[serde(flatten)]
base: &'a BaseTemplateData<'a>,
feed: Feed,
updated: DateTime<Utc>,
}
pub fn generate_or_error(
sources: &Sources,
dirs: &Dirs,
handlebars: &Handlebars,
tag_name: &str,
) -> String {
match generate(sources, dirs, handlebars, tag_name) {
Ok(html) => html,
Err(error) => format!("error: {error:?}"),
}
}
#[instrument(name = "atom::generate", skip(sources, handlebars))]
@ -111,20 +122,28 @@ pub fn generate(
sources: &Sources,
dirs: &Dirs,
handlebars: &Handlebars,
file_id: FileId,
tag_name: &str,
) -> anyhow::Result<String> {
let roots = &sources.treehouse.roots[&file_id];
let feed_name = roots.attributes.feed.clone().expect("page must be a feed");
let feed = feed::generate(sources, tag_name).ok_or_else(|| anyhow!("feed does not exist"))?; // should not happen in reality; 404 should be returned
let template_data = AtomTemplateData {
base: &BaseTemplateData::new(sources),
feed: Feed {
name: feed_name,
// The content cache layer should take care of sampling the current time only once,
// and then preserving it until the treehouse is deployed again.
updated: Utc::now(),
entries: extract_entries(sources, dirs, file_id),
name: tag_name.to_owned(),
entries: feed
.into_iter()
.map(|entry| Entry {
id: entry.id,
updated: entry.updated,
url: entry.url,
title: entry.title,
tags: entry.tags,
})
.collect(),
},
// The content cache layer should take care of sampling the current time only once,
// and then preserving it until the treehouse is deployed again.
updated: Utc::now(),
};
let _span = info_span!("handlebars::render").entered();
@ -132,84 +151,3 @@ pub fn generate(
.render("_feed_atom.hbs", &template_data)
.context("template rendering failed")
}
pub fn generate_or_error(
sources: &Sources,
dirs: &Dirs,
handlebars: &Handlebars,
file_id: FileId,
) -> String {
match generate(sources, dirs, handlebars, file_id) {
Ok(html) => html,
Err(error) => format!("error: {error:?}"),
}
}
fn extract_entries(sources: &Sources, dirs: &Dirs, file_id: FileId) -> Vec<Entry> {
let roots = &sources.treehouse.roots[&file_id];
roots
.branches
.iter()
.flat_map(|&branch_id| {
let branch = sources.treehouse.tree.branch(branch_id);
let text = &sources.treehouse.source(file_id).input()[branch.content.clone()];
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);
let updated = branch
.attributes
.timestamp()
.unwrap_or(DateTime::UNIX_EPOCH); // if you see the Unix epoch... oops
parsed.link.map(|url| Entry {
id: branch.attributes.id.clone(),
updated,
url,
title: parsed.title.unwrap_or_else(|| "untitled".into()),
categories: branch.attributes.tags.clone(),
summary,
})
})
.collect()
}
/// Extremely simple HTML renderer without the treehouse's fancy branch folding and linking features.
fn branches_to_html_simple(
s: &mut String,
sources: &Sources,
dirs: &Dirs,
file_id: FileId,
branches: &[SemaBranchId],
) {
s.push_str("<ul>");
for &branch_id in branches {
let branch = sources.treehouse.tree.branch(branch_id);
s.push_str("<li>");
let text = &sources.treehouse.source(file_id).input()[branch.content.clone()];
let events: Vec<_> = jotdown::Parser::new(text).into_offset_iter().collect();
// Ignore render diagnostics. Those should be reported by the main HTML generator.
let _render_diagnostics = djot::Renderer {
config: &sources.config,
dirs,
treehouse: &sources.treehouse,
file_id,
// Yeah, maybe don't include literate code in summaries...
page_id: "liquidex-is-a-dummy".into(),
}
.render(&events, s);
if !branch.children.is_empty() {
branches_to_html_simple(s, sources, dirs, file_id, &branch.children);
}
s.push_str("</li>");
}
s.push_str("</ul>");
}

View file

@ -3,63 +3,23 @@ use std::{
sync::Arc,
};
use anyhow::Context;
use anyhow::{Context, anyhow};
use chrono::{DateTime, Utc};
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use tracing::{error, instrument};
use crate::{
dirs::Dirs,
doc::Doc,
feed,
generate::BaseTemplateData,
html::djot,
sources::Sources,
state::{report_diagnostics, toml_error_to_diagnostic, FileId, TomlError},
tree::{attributes::Picture, feed},
state::FileId,
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,
@ -87,8 +47,8 @@ struct Feed {
struct Entry {
title: String,
url: String,
updated: DateTime<Utc>,
categories: Vec<String>,
updated: Option<DateTime<Utc>>,
tags: Vec<String>,
}
#[derive(Serialize)]
@ -117,34 +77,19 @@ impl DocDir {
.treehouse
.files_by_doc_path
.get(&path.with_extension("dj"))
&& let Some(doc) = self.sources.treehouse.docs.get(file_id)
{
let source = self.sources.treehouse.source(*file_id).input();
return Some(Content::new(
"text/html",
self.generate(*file_id, path, source).into_bytes(),
self.generate(*file_id, path, doc).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();
fn generate(&self, file_id: FileId, path: &VPath, doc: &Doc) -> String {
let events: Vec<_> = jotdown::Parser::new(&doc.text).into_offset_iter().collect();
let mut rendered_markup = String::new();
let render_diagnostics = djot::Renderer {
config: &self.sources.config,
@ -155,7 +100,7 @@ impl DocDir {
}
.render(&events, &mut rendered_markup);
let template_name = attributes.template.as_deref().unwrap_or("_doc.hbs");
let template_name = doc.attributes.template.as_deref().unwrap_or("_doc.hbs");
let render_result = self
.handlebars
@ -164,20 +109,20 @@ impl DocDir {
&PageTemplateData {
base: &BaseTemplateData::new(&self.sources),
page: Page {
title: attributes.title,
thumbnail: attributes.thumbnail.map(|pic| Thumbnail {
title: doc.attributes.title.clone(),
thumbnail: doc.attributes.thumbnail.as_ref().map(|pic| Thumbnail {
url: self.sources.config.pic_url(&*self.dirs.pic, &pic.id),
alt: pic.alt,
alt: pic.alt.clone(),
}),
scripts: attributes.scripts,
styles: attributes.styles,
scripts: doc.attributes.scripts.clone(),
styles: doc.attributes.styles.clone(),
tree_path: path.to_string(),
doc: rendered_markup,
feed: attributes.include_feed.and_then(|feed| {
feed: doc.attributes.include_feed.as_ref().and_then(|feed| {
Some(Feed {
title: feed.title,
title: feed.title.clone(),
entries: self
.generate_feed(&feed.name)
.generate_feed(&feed.tag)
.inspect_err(|e| {
error!("generating feed for {path} failed: {e}")
})
@ -194,40 +139,23 @@ impl DocDir {
}
}
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(),
})
fn generate_feed(&self, tag_name: &str) -> anyhow::Result<Vec<Entry>> {
let feed = feed::generate(&self.sources, tag_name)
.ok_or_else(|| anyhow!("tag #{tag_name} doesn't exist"))?;
Ok(feed
.into_iter()
.map(|entry| Entry {
title: entry.title,
url: entry.url,
updated: entry.updated,
tags: {
let mut tags = entry.tags;
// Don't show the "self" tag in the list.
if let Some(i) = tags.iter().position(|x| x == tag_name) {
tags.remove(i);
}
tags
},
})
.collect())
}

View file

@ -1,106 +0,0 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use tracing::debug;
#[derive(Debug, Default, Clone)]
pub struct History {
// Sorted from newest to oldest.
pub commits: IndexMap<git2::Oid, Commit>,
pub by_page: HashMap<String, PageHistory>,
}
#[derive(Debug, Clone)]
pub struct Commit {
pub summary: String,
pub body: String,
}
#[derive(Debug, Clone, Default)]
pub struct PageHistory {
// Sorted from newest to oldest, so revision 0 is the current version.
// On the website these are sorted differently: 1 is the oldest revision, succeeding numbers are later revisions.
pub revisions: Vec<Revision>,
}
#[derive(Debug, Clone)]
pub struct Revision {
pub commit_oid: git2::Oid,
pub blob_oid: git2::Oid,
}
impl History {
pub fn get(git: &git2::Repository) -> anyhow::Result<Self> {
debug!("reading git history");
let mut history = History::default();
let mut revwalk = git.revwalk()?;
revwalk.push_head()?;
for commit_oid in revwalk {
let commit_oid = commit_oid?;
let commit = git.find_commit(commit_oid)?;
history.commits.insert(
commit_oid,
Commit {
summary: String::from_utf8_lossy(commit.summary_bytes().unwrap_or(&[]))
.into_owned(),
body: String::from_utf8_lossy(commit.body_bytes().unwrap_or(&[])).into_owned(),
},
);
let tree = commit.tree()?;
tree.walk(git2::TreeWalkMode::PreOrder, |parent_path, entry| {
if parent_path.is_empty() && entry.name() != Some("content") {
// This is content-only history, so skip all directories that don't contain content.
git2::TreeWalkResult::Skip
} else if entry.kind() == Some(git2::ObjectType::Blob)
&& entry.name().is_some_and(|name| name.ends_with(".tree"))
{
let path = format!(
"{parent_path}{}",
String::from_utf8_lossy(entry.name_bytes())
);
let page_history = history.by_page.entry(path).or_default();
let unchanged = page_history
.revisions
.last()
.is_some_and(|rev| rev.blob_oid == entry.id());
if unchanged {
// Note again that the history is reversed as we're walking from HEAD
// backwards, so we need to find the _earliest_ commit with this revision.
// Therefore we update that current revision's commit oid with the
// current commit.
page_history.revisions.last_mut().unwrap().commit_oid = commit_oid;
} else {
page_history.revisions.push(Revision {
commit_oid,
blob_oid: entry.id(),
});
}
git2::TreeWalkResult::Ok
} else {
git2::TreeWalkResult::Ok
}
})?;
}
Ok(history)
}
pub fn read_revision(
&self,
git: &git2::Repository,
revision: &Revision,
) -> anyhow::Result<Vec<u8>> {
Ok(git.find_blob(revision.blob_oid)?.content().to_owned())
}
}
impl Revision {
pub fn commit_short(&self) -> String {
self.commit_oid.to_string()[0..6].to_owned()
}
}

View file

@ -1,13 +1,13 @@
pub mod cli;
pub mod config;
pub mod dirs;
pub mod doc;
pub mod feed;
pub mod fun;
pub mod generate;
pub mod history;
pub mod html;
pub mod import_map;
pub mod parse;
pub mod paths;
pub mod sources;
pub mod state;
pub mod tree;

View file

View file

@ -1,16 +1,17 @@
use std::{collections::HashMap, ops::ControlFlow};
use anyhow::{anyhow, Context};
use anyhow::{Context, anyhow};
use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
use tracing::{error, info_span, instrument};
use crate::{
config::Config,
dirs::Dirs,
doc::Doc,
html::navmap::NavigationMap,
import_map::ImportMap,
parse::parse_tree_with_diagnostics,
state::{report_diagnostics, Source, Treehouse},
state::{Source, Tag, Treehouse, report_diagnostics},
tree::SemaRoots,
vfs::{self, Cd, Content, VPath, VPathBuf},
};
@ -135,20 +136,57 @@ fn load_trees(config: &Config, dirs: &Dirs) -> anyhow::Result<Treehouse> {
}
}
report_diagnostics(&treehouse, &diagnostics)?;
// Docs
for path in doc_paths {
let mut doc_file_ids = vec![];
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);
treehouse.files_by_doc_path.insert(path.clone(), file_id);
doc_file_ids.push(file_id);
} else {
error!("doc {path} does not exist in content directory even though it was enumerated via walk_dir_rec");
error!(
"doc {path} does not exist in content directory even though it was enumerated via walk_dir_rec"
);
}
}
for file_id in doc_file_ids {
let (doc, mut doc_diagnostics) = Doc::parse(&mut treehouse, file_id);
treehouse.docs.insert(file_id, doc);
diagnostics.append(&mut doc_diagnostics);
}
// Tags
for (_, file_id) in &treehouse.files_by_tree_path {
let roots = &treehouse.roots[file_id];
for tag_name in &roots.attributes.tags {
let tag = treehouse
.tags
.entry(tag_name.clone())
.or_insert_with(Tag::default);
tag.files.push(*file_id);
}
}
for (_, file_id) in &treehouse.files_by_doc_path {
let doc = &treehouse.docs[file_id];
for tag_name in &doc.attributes.tags {
let tag = treehouse
.tags
.entry(tag_name.clone())
.or_insert_with(Tag::default);
tag.files.push(*file_id);
}
}
// Diagnostics
report_diagnostics(&treehouse, &diagnostics)?;
Ok(treehouse)
}

View file

@ -9,6 +9,7 @@ use tracing::instrument;
use ulid::Ulid;
use crate::{
doc::Doc,
tree::{SemaBranchId, SemaRoots, SemaTree},
vfs::{VPath, VPathBuf},
};
@ -63,17 +64,24 @@ impl File {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileId(usize);
#[derive(Debug, Clone, Default)]
pub struct Tag {
pub files: Vec<FileId>,
}
/// Treehouse compilation context.
pub struct Treehouse {
pub files: Vec<File>,
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 tags: HashMap<String, Tag>,
pub tree: SemaTree,
pub branches_by_named_id: HashMap<String, SemaBranchId>,
pub roots: HashMap<FileId, SemaRoots>,
pub docs: HashMap<FileId, Doc>,
pub branch_redirects: HashMap<String, SemaBranchId>,
pub missingno_generator: ulid::Generator,
@ -85,12 +93,14 @@ impl Treehouse {
files: vec![],
files_by_tree_path: HashMap::new(),
files_by_doc_path: HashMap::new(),
feeds_by_name: HashMap::new(),
tags: HashMap::new(),
tree: SemaTree::default(),
branches_by_named_id: HashMap::new(),
roots: HashMap::new(),
docs: HashMap::new(),
branch_redirects: HashMap::new(),
missingno_generator: ulid::Generator::new(),

View file

@ -1,6 +1,5 @@
pub mod ast;
pub mod attributes;
pub mod feed;
pub mod mini_template;
pub mod pull;
@ -12,7 +11,7 @@ use tracing::instrument;
use crate::{
config::Config,
state::{toml_error_to_diagnostic, FileId, Source, TomlError, Treehouse},
state::{FileId, Source, TomlError, Treehouse, toml_error_to_diagnostic},
tree::{
ast::{Branch, Roots},
attributes::{Attributes, Content},
@ -171,10 +170,6 @@ impl SemaRoots {
}
}
if let Some(feed_name) = &attributes.feed {
treehouse.feeds_by_name.insert(feed_name.clone(), file_id);
}
attributes
}
}

View file

@ -7,6 +7,11 @@ use crate::{state::FileId, vfs::VPathBuf};
/// Top-level `%%` root attributes.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct RootAttributes {
/// Unique ID of this page.
/// Required for the page to be shown in feeds.
#[serde(default)]
pub id: Option<String>,
/// Template to use for generating the page.
/// Defaults to `_tree.hbs`.
#[serde(default)]
@ -46,12 +51,9 @@ pub struct RootAttributes {
#[serde(default)]
pub timestamps: Option<Timestamps>,
/// 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
/// Tags to assign to this page.
#[serde(default)]
pub feed: Option<String>,
pub tags: Vec<String>,
}
/// A picture reference.
@ -134,15 +136,19 @@ pub struct Attributes {
pub tags: Vec<String>,
}
/// Parses the timestamp out of a branch ID.
/// Returns `None` if the ID does not contain a timestamp.
pub fn timestamp_from_id(id: &str) -> Option<DateTime<Utc>> {
Ulid::from_string(id)
.ok()
.as_ref()
.map(Ulid::timestamp_ms)
.and_then(|ms| DateTime::from_timestamp_millis(ms as i64))
}
impl Attributes {
/// Parses the timestamp out of the branch's ID.
/// Returns `None` if the ID does not contain a timestamp.
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
Ulid::from_string(&self.id)
.ok()
.as_ref()
.map(Ulid::timestamp_ms)
.and_then(|ms| DateTime::from_timestamp_millis(ms as i64))
timestamp_from_id(&self.id)
}
}

View file

@ -1,94 +0,0 @@
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()
}

View file

@ -7,7 +7,7 @@ use dashmap::DashMap;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use tracing::{info_span, instrument};
use super::{query, walk_dir_rec, Content, Dir, Query, VPath, VPathBuf};
use super::{Content, Dir, Query, VPath, VPathBuf, query, walk_dir_rec};
pub struct ContentCache<T> {
inner: T,

View file

@ -3,6 +3,7 @@ use std::{borrow::Borrow, error::Error, fmt, ops::Deref, str::FromStr};
use serde::{Deserialize, Serialize};
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(transparent)]
pub struct VPath {
path: str,
}
@ -41,7 +42,8 @@ impl VPath {
}
const unsafe fn new_unchecked(s: &str) -> &Self {
std::mem::transmute::<_, &Self>(s)
// SAFETY: The representation of &str and &VPath is the same.
unsafe { std::mem::transmute::<_, &Self>(s) }
}
pub fn is_empty(&self) -> bool {
@ -117,11 +119,7 @@ impl VPath {
pub fn extension(&self) -> Option<&str> {
let file_name = self.file_name()?;
let (left, right) = file_name.rsplit_once('.')?;
if left.is_empty() {
None
} else {
Some(right)
}
if left.is_empty() { None } else { Some(right) }
}
pub fn with_extension(&self, extension: &str) -> VPathBuf {