remove treehouse-format crate and collapse everything into src

This commit is contained in:
りき萌 2025-07-10 16:50:41 +02:00
parent ca127a9411
commit b792688776
66 changed files with 145 additions and 112 deletions

81
src/tree/ast.rs Normal file
View file

@ -0,0 +1,81 @@
use std::ops::Range;
use super::{
pull::{Attributes, BranchEvent, BranchKind, Parser},
ParseError, ParseErrorKind,
};
#[derive(Debug, Clone)]
pub struct Roots {
pub attributes: Option<Attributes>,
pub branches: Vec<Branch>,
}
impl Roots {
pub fn parse(parser: &mut Parser) -> Result<Self, ParseError> {
let attributes = parser.top_level_attributes()?;
let mut branches = vec![];
while let Some((branch, indent_level)) = Branch::parse_with_indent_level(parser)? {
if indent_level != 0 {
return Err(ParseErrorKind::RootIndentLevel.at(branch.kind_span));
}
branches.push(branch);
}
Ok(Self {
attributes,
branches,
})
}
}
#[derive(Debug, Clone)]
pub struct Branch {
pub indent_level: usize,
pub attributes: Option<Attributes>,
pub kind: BranchKind,
pub kind_span: Range<usize>,
pub content: Range<usize>,
pub children: Vec<Branch>,
}
impl From<BranchEvent> for Branch {
fn from(branch: BranchEvent) -> Self {
Self {
indent_level: branch.indent_level,
attributes: branch.attributes,
kind: branch.kind,
kind_span: branch.kind_span,
content: branch.content,
children: vec![],
}
}
}
impl Branch {
pub fn parse_with_indent_level(
parser: &mut Parser,
) -> Result<Option<(Self, usize)>, ParseError> {
if let Some(branch_event) = parser.next_branch()? {
let own_indent_level = branch_event.indent_level;
let mut branch = Branch::from(branch_event);
let children_indent_level = parser.peek_indent_level();
if children_indent_level > own_indent_level {
while parser.peek_indent_level() == children_indent_level {
if let Some(child) = Branch::parse(parser)? {
branch.children.push(child);
} else {
break;
}
}
}
Ok(Some((branch, own_indent_level)))
} else {
Ok(None)
}
}
pub fn parse(parser: &mut Parser) -> Result<Option<Self>, ParseError> {
Ok(Self::parse_with_indent_level(parser)?.map(|(branch, _)| branch))
}
}

202
src/tree/attributes.rs Normal file
View file

@ -0,0 +1,202 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{state::FileId, vfs::VPathBuf};
/// Top-level `%%` root attributes.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct RootAttributes {
/// Template to use for generating the page.
/// Defaults to `_tree.hbs`.
#[serde(default)]
pub template: Option<String>,
/// Title of the generated .html page.
///
/// The page's tree path is used if empty.
#[serde(default)]
pub title: String,
/// Page icon used in indexes.
/// This is an emoji name, such as `page` (default).
#[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>,
/// 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>,
/// Visibility of a page in the parent page's index.
#[serde(default)]
pub visibility: Visibility,
/// The page's timestamps. These are automatically populated if a page has at least one branch
/// with an ID that includes a timestamp.
#[serde(default)]
pub timestamps: Option<Timestamps>,
/// When specified, this page will have a corresponding Atom feed under `rss/{feed}.xml`.
///
/// In feeds, top-level branches are expected to have a single heading containing the post title.
/// Their children are turned into the post description
#[serde(default)]
pub feed: Option<String>,
}
/// A picture reference.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Picture {
/// ID of the picture.
pub id: String,
/// Optional alt text.
#[serde(default)]
pub alt: Option<String>,
}
/// Visibility of a page.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub enum Visibility {
#[default]
Public,
/// Hidden from the parent page's index.
Private,
}
/// Timestamps for a page.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub struct Timestamps {
/// When the page was created. By default, this is the timestamp of the least recent branch.
pub created: DateTime<Utc>,
/// When the page was last updated. By default, this is the timestamp of the most recent branch.
pub updated: DateTime<Utc>,
}
fn default_icon() -> String {
String::from("page")
}
/// Branch attributes.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub struct Attributes {
/// Unique identifier of the branch.
///
/// Note that this must be unique to the _entire_ site, not just a single tree.
/// This is because trees may be embedded within each other using [`Content::Link`].
#[serde(default)]
pub id: String,
/// Redirect old and deleted listed in the list to this branch.
///
/// This can be used to keep links permanent even in case the structure of the treehouse changes.
#[serde(default)]
pub redirect_here: Vec<String>,
/// Controls how the block should be presented.
#[serde(default)]
pub content: Content,
/// Do not persist the branch in localStorage.
#[serde(default)]
pub do_not_persist: bool,
/// Strings of extra CSS class names to include in the generated HTML.
#[serde(default)]
pub classes: Classes,
/// Enable `mini_template` templating in this branch.
#[serde(default)]
pub template: bool,
/// Publishing stage; if `Draft`, the branch is invisible unless treehouse is compiled in
/// debug mode.
#[serde(default)]
pub stage: Stage,
/// List of extra spells to cast on the branch.
#[serde(default)]
pub cast: String,
/// In feeds, specifies the list of tags to attach to an entry.
/// This only has an effect on top-level branches.
#[serde(default)]
pub tags: Vec<String>,
}
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))
}
}
/// Controls for block content presentation.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Content {
/// Children are stored inline in the block. Nothing special happens.
#[default]
Inline,
/// Link to another tree.
///
/// When JavaScript is enabled, the tree's roots will be embedded inline into the branch and
/// loaded lazily.
///
/// Without JavaScript, the tree will be linked with an `<a>` element.
///
/// The string provided as an argument is relative to the `content` root and should not contain
/// any file extensions. For example, to link to `content/my-article.tree`,
/// use `content.link = "my-article"`.
///
/// Note that `Link` branches must not contain any children. If a `Link` branch does contain
/// children, an `attribute`-type error is raised.
Link(VPathBuf),
/// Valid link to another tree.
/// This replaces `Content::Link` during semantic analysis.
#[serde(skip)]
ResolvedLink(FileId),
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub struct Classes {
/// Classes to append to the branch itself (<li data-cast="b">).
#[serde(default)]
pub branch: String,
/// Classes to append to the branch's <ul> element containing its children.
#[serde(default)]
pub branch_children: String,
}
/// Publish stage of a branch.
///
/// Draft branches are not included in release builds of treehouse. In debug builds, they are also
/// marked with an extra "draft" before the content.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub enum Stage {
#[default]
Public,
Draft,
}

1
src/tree/lib.rs Normal file
View file

@ -0,0 +1 @@

224
src/tree/mini_template.rs Normal file
View file

@ -0,0 +1,224 @@
//! Minimalistic templating engine that integrates with the .tree format and Markdown.
//!
//! Mostly to avoid pulling in Handlebars everywhere; mini_template, unlike Handlebars, also allows
//! for injecting *custom, stateful* context into the renderer, which is important for things like
//! the `pic` template to work.
use std::fmt::Write;
use std::ops::Range;
use crate::{
config::Config,
dirs::Dirs,
html::EscapeHtml,
state::Treehouse,
vfs::{self, Content, VPath},
};
struct Lexer<'a> {
input: &'a str,
position: usize,
// Despite this parser's intentional simplicity, a peekahead buffer needs to be used for
// performance because tokens are usually quite long and therefore reparsing them would be
// too expensive.
peek_buffer: Option<(Token, usize)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TokenKind {
/// Verbatim text, may be inside of a template.
Text,
Open(EscapingMode), // {%
Close, // %}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EscapingMode {
EscapeHtml,
NoEscaping,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Token {
kind: TokenKind,
range: Range<usize>,
}
impl<'a> Lexer<'a> {
fn new(input: &'a str) -> Self {
Self {
input,
position: 0,
peek_buffer: None,
}
}
fn current(&self) -> Option<char> {
self.input[self.position..].chars().next()
}
fn advance(&mut self) {
self.position += self.current().map(|c| c.len_utf8()).unwrap_or(0);
}
fn create_token(&self, start: usize, kind: TokenKind) -> Token {
Token {
kind,
range: start..self.position,
}
}
fn next_inner(&mut self) -> Option<Token> {
if let Some((token, after_token)) = self.peek_buffer.take() {
self.position = after_token;
return Some(token);
}
let start = self.position;
match self.current() {
Some('{') => {
self.advance();
if self.current() == Some('%') {
self.advance();
if self.current() == Some('!') {
Some(self.create_token(start, TokenKind::Open(EscapingMode::NoEscaping)))
} else {
Some(self.create_token(start, TokenKind::Open(EscapingMode::EscapeHtml)))
}
} else {
self.advance();
Some(self.create_token(start, TokenKind::Text))
}
}
Some('%') => {
self.advance();
if self.current() == Some('}') {
self.advance();
Some(self.create_token(start, TokenKind::Close))
} else {
self.advance();
Some(self.create_token(start, TokenKind::Text))
}
}
Some(_) => {
while !matches!(self.current(), Some('{' | '%') | None) {
self.advance();
}
Some(self.create_token(start, TokenKind::Text))
}
None => None,
}
}
fn peek_inner(&mut self) -> Option<Token> {
let position = self.position;
let token = self.next();
let after_token = self.position;
self.position = position;
if let Some(token) = token.clone() {
self.peek_buffer = Some((token, after_token));
}
token
}
fn next(&mut self) -> Option<Token> {
self.next_inner().map(|mut token| {
// Coalesce multiple Text tokens into one.
if token.kind == TokenKind::Text {
while let Some(Token {
kind: TokenKind::Text,
..
}) = self.peek_inner()
{
let next_token = self.next_inner().unwrap();
token.range.end = next_token.range.end;
}
}
token
})
}
}
struct Renderer<'a> {
lexer: Lexer<'a>,
output: String,
}
struct InvalidTemplate;
impl Renderer<'_> {
fn emit_token_verbatim(&mut self, token: &Token) {
self.output.push_str(&self.lexer.input[token.range.clone()]);
}
fn render(&mut self, config: &Config, treehouse: &Treehouse, dirs: &Dirs) {
let kind_of = |token: &Token| token.kind;
while let Some(token) = self.lexer.next() {
match token.kind {
TokenKind::Open(escaping) => {
let inside = self.lexer.next();
let close = self.lexer.next();
if let Some((TokenKind::Text, TokenKind::Close)) = inside
.as_ref()
.map(kind_of)
.zip(close.as_ref().map(kind_of))
{
match Self::render_template(
config,
treehouse,
dirs,
self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(),
) {
Ok(s) => match escaping {
EscapingMode::EscapeHtml => {
_ = write!(self.output, "{}", EscapeHtml(&s));
}
EscapingMode::NoEscaping => self.output.push_str(&s),
},
Err(InvalidTemplate) => {
inside.inspect(|token| self.emit_token_verbatim(token));
close.inspect(|token| self.emit_token_verbatim(token));
}
}
} else {
inside.inspect(|token| self.emit_token_verbatim(token));
close.inspect(|token| self.emit_token_verbatim(token));
}
}
_ => self.emit_token_verbatim(&token),
}
}
}
fn render_template(
config: &Config,
_treehouse: &Treehouse,
dirs: &Dirs,
template: &str,
) -> Result<String, InvalidTemplate> {
let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
match function {
"pic" => Ok(config.pic_url(&*dirs.pic, arguments)),
"include_static" => VPath::try_new(arguments)
.ok()
.and_then(|vpath| vfs::query::<Content>(&dirs.static_, vpath))
.and_then(|c| c.string().ok())
.ok_or(InvalidTemplate),
_ => Err(InvalidTemplate),
}
}
}
pub fn render(config: &Config, treehouse: &Treehouse, dirs: &Dirs, input: &str) -> String {
let mut renderer = Renderer {
lexer: Lexer::new(input),
output: String::new(),
};
renderer.render(config, treehouse, dirs);
renderer.output
}

233
src/tree/pull.rs Normal file
View file

@ -0,0 +1,233 @@
use std::{convert::identity, ops::Range};
use super::{ParseError, ParseErrorKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
/// Expanded by default.
Expanded,
/// Folded by default.
Collapsed,
}
impl BranchKind {
pub fn char(&self) -> char {
match self {
BranchKind::Expanded => '-',
BranchKind::Collapsed => '+',
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchEvent {
pub indent_level: usize,
pub kind: BranchKind,
pub kind_span: Range<usize>,
pub content: Range<usize>,
pub attributes: Option<Attributes>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attributes {
pub percent: Range<usize>,
pub data: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parser<'a> {
pub input: &'a str,
pub position: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AllowCodeBlocks {
No,
Yes,
}
impl Parser<'_> {
fn current(&self) -> Option<char> {
self.input[self.position..].chars().next()
}
fn current_starts_with(&self, s: &str) -> bool {
self.input[self.position..].starts_with(s)
}
fn advance(&mut self) {
self.position += self.current().map(|c| c.len_utf8()).unwrap_or(0);
}
fn eat_as_long_as(&mut self, c: char) -> usize {
let mut count = 0;
while self.current() == Some(c) {
count += 1;
self.advance();
}
count
}
fn eat_while(&mut self, cond: impl Fn(char) -> bool) {
while self.current().map(&cond).is_some_and(|x| x) {
self.advance();
}
}
fn eat_until_line_break(&mut self) {
loop {
match self.current() {
Some('\r') => {
self.advance();
if self.current() == Some('\n') {
self.advance();
break;
}
}
Some('\n') => {
self.advance();
break;
}
Some(_) => self.advance(),
None => break,
}
}
}
pub fn peek_indent_level(&mut self) -> usize {
let position = self.position;
let indent_level = self.eat_as_long_as(' ');
self.position = position;
indent_level
}
fn eat_indented_lines_until(
&mut self,
indent_level: usize,
cond: impl Fn(char) -> bool,
allow_code_blocks: AllowCodeBlocks,
) -> Result<(), ParseError> {
let mut code_block: Option<Range<usize>> = None;
loop {
if let Some(range) = &code_block {
self.eat_while(|c| c == ' ');
if self.current_starts_with("```") {
code_block = None;
self.position += 3;
self.eat_until_line_break();
continue;
}
self.eat_until_line_break();
if self.current().is_none() {
return Err(ParseErrorKind::UnterminatedCodeBlock.at(range.clone()));
}
} else {
self.eat_while(|c| c == ' ');
if allow_code_blocks == AllowCodeBlocks::Yes && self.current_starts_with("```") {
code_block = Some(self.position..self.position + 3);
self.position += 3;
continue;
}
self.eat_until_line_break();
let before_indentation = self.position;
let line_indent_level = self.eat_as_long_as(' ');
let after_indentation = self.position;
if self.current().map(&cond).is_some_and(identity) || self.current().is_none() {
self.position = before_indentation;
break;
} else if !matches!(self.current(), Some('\n') | Some('\r'))
&& line_indent_level < indent_level
{
return Err(ParseErrorKind::InconsistentIndentation {
got: line_indent_level,
expected: indent_level,
}
.at(before_indentation..after_indentation));
}
}
}
Ok(())
}
pub fn top_level_attributes(&mut self) -> Result<Option<Attributes>, ParseError> {
let start = self.position;
match self.current() {
Some('%') => {
let after_one_percent = self.position;
self.advance();
if self.current() == Some('%') {
self.advance();
let after_two_percent = self.position;
self.eat_indented_lines_until(
0,
|c| c == '-' || c == '+' || c == '%',
AllowCodeBlocks::No,
)?;
let end = self.position;
Ok(Some(Attributes {
percent: start..after_two_percent,
data: after_two_percent..end,
}))
} else {
self.position = after_one_percent;
Ok(None)
}
}
_ => Ok(None),
}
}
pub fn next_branch(&mut self) -> Result<Option<BranchEvent>, ParseError> {
if self.current().is_none() {
return Ok(None);
}
let indent_level = self.eat_as_long_as(' ');
let attributes = if self.current() == Some('%') {
let start = self.position;
self.advance();
let after_percent = self.position;
self.eat_indented_lines_until(
indent_level,
|c| c == '-' || c == '+',
AllowCodeBlocks::No,
)?;
self.eat_as_long_as(' ');
let end = self.position;
Some(Attributes {
percent: start..after_percent,
data: after_percent..end,
})
} else {
None
};
let kind_start = self.position;
let kind = match self.current() {
Some('-') => BranchKind::Expanded,
Some('+') => BranchKind::Collapsed,
_ => return Err(ParseErrorKind::BranchKindExpected.at(kind_start..kind_start + 1)),
};
self.advance();
let kind_end = self.position;
let content_start = self.position;
self.eat_indented_lines_until(
indent_level,
|c| c == '-' || c == '+' || c == '%',
AllowCodeBlocks::Yes,
)?;
let content_end = self.position;
Ok(Some(BranchEvent {
indent_level,
attributes,
kind,
kind_span: kind_start..kind_end,
content: content_start..content_end,
}))
}
}