add a tagging system to the website
This commit is contained in:
parent
701da6bc4b
commit
e1b6578b2a
97 changed files with 1025 additions and 979 deletions
138
src/doc.rs
Normal file
138
src/doc.rs
Normal 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
59
src/feed.rs
Normal 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)
|
||||
}
|
|
@ -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(())
|
||||
|
|
|
@ -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>");
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
106
src/history.rs
106
src/history.rs
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
14
src/state.rs
14
src/state.rs
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue