treehouse/src/doc.rs
2025-08-26 19:16:47 +02:00

154 lines
5.1 KiB
Rust

use chrono::{DateTime, Utc};
use codespan_reporting::diagnostic::{Diagnostic, Label};
use serde::Deserialize;
use crate::{
config::Config,
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,
config: &Config,
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(),
]),
);
}
for tag in &attributes.tags {
if !config.feed.tags.contains(tag) {
diagnostics.push(
Diagnostic::warning()
.with_code("attr")
.with_message(format!("doc has unregistered tag `{tag}`"))
.with_labels(vec![Label::primary(file_id, attributes_span.clone())]),
);
}
}
}
(
Doc {
attributes,
text: text.to_owned(),
},
diagnostics,
)
}
}