krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.19.1: src/template.rs · raw

  1//! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML.
  2//!
  3//! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile).
  4//!
  5//! Templates come from the configured directory when it exists, and fall back to a
  6//! built-in layout when it does not. That fallback is what lets a bare directory of
  7//! `.org` files build into a real site with no setup, while `base.html` in the templates
  8//! directory replaces the layout entirely for anyone who wants their own.
  9//!
 10//! Template sources are a hashing input for incrementality (spec §4.1): editing a layout
 11//! invalidates the pages that use it, and that has to hold for user templates too, or a
 12//! design change would leave a site half-updated.
 13
 14use std::collections::{BTreeMap, BTreeSet};
 15
 16use anyhow::{Context, Result};
 17use camino::Utf8Path;
 18use minijinja::{context, Environment};
 19use serde::Serialize;
 20
 21/// A navigation entry: a page title and the URL to reach it from the current page.
 22#[derive(Debug, Clone, Serialize)]
 23pub struct NavItem {
 24    pub title: String,
 25    pub url: String,
 26}
 27
 28/// Site-wide values, exposed to templates as `site`.
 29#[derive(Debug, Clone, Serialize)]
 30pub struct SiteContext {
 31    pub title: String,
 32    pub base_url: String,
 33    pub description: String,
 34    pub language: String,
 35}
 36
 37/// One page's metadata, exposed to templates as `page` — and, when
 38/// `templates.expose_page_list` is on, as entries of `pages`.
 39#[derive(Debug, Clone, Serialize)]
 40pub struct PageContext {
 41    pub title: String,
 42    /// Output path relative to the site root, e.g. `blog/post.html`.
 43    pub url: String,
 44    /// Source path relative to the source root, e.g. `blog/post.org`.
 45    pub source: String,
 46    /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because
 47    /// templates are better placed to decide how a date should read.
 48    pub date: Option<String>,
 49    /// The `YYYY-MM-DD` found inside `date`, if there is one. Org dates arrive in many
 50    /// shapes (`[2025-09-05 Fri 10:21:00]`, `<2024-05-01>`, `2024-05-01`), and a listing
 51    /// wants one it can sort and print. `None` when the date is free text like "someday".
 52    pub date_iso: Option<String>,
 53    /// The year from `date_iso`, so a listing can group by it with minijinja's
 54    /// `groupby` filter — which takes an attribute name and cannot slice a date itself.
 55    pub year: Option<String>,
 56    /// `#+FILETAGS:` split on `:`.
 57    pub tags: Vec<String>,
 58    /// A short summary for listings: `#+DESCRIPTION:` when the page sets one, otherwise
 59    /// its first paragraph. Empty only when the page has neither.
 60    pub excerpt: String,
 61    /// Words of prose, excluding code and example blocks.
 62    pub word_count: usize,
 63    /// Minutes to read at 200 words per minute, rounded up; at least 1 for a page with
 64    /// any prose at all.
 65    pub reading_time: usize,
 66    /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
 67    /// project-specific metadata this crate has never heard of.
 68    pub keywords: BTreeMap<String, String>,
 69    /// The page's rendered HTML, when a collection asked for it with
 70    /// `include_content`. `none` everywhere else, because carrying every page's body in
 71    /// every listing context would be most of a site's memory for nothing.
 72    pub content: Option<String>,
 73    /// The page's headings as a tree. Empty when the page has none, when the site turns
 74    /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`.
 75    pub toc: Vec<crate::util::TocEntry>,
 76}
 77
 78/// The built-in layout, used when the templates directory has no `base.html`.
 79/// Deliberately plain: it should be a working starting point and an obvious thing to
 80/// replace, not a design anyone has to live with.
 81const BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
 82<html lang="{{ site.language }}">
 83<head>
 84<meta charset="utf-8">
 85<meta name="viewport" content="width=device-width, initial-scale=1">
 86<title>{{ page.title }} &middot; {{ site.title }}</title>
 87{%- if site.base_url %}
 88<link rel="canonical" href="{{ page.url | absolute }}">
 89{%- endif %}
 90{%- if page.description %}
 91<meta name="description" content="{{ page.description }}">
 92{%- endif %}
 93{%- if stylesheet %}
 94<link rel="stylesheet" href="{{ stylesheet }}">
 95{%- endif %}
 96</head>
 97<body>
 98<header>
 99<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
100{%- if nav %}
101<nav>
102{%- for item in nav %}
103<a href="{{ item.url }}">{{ item.title }}</a>
104{%- endfor %}
105</nav>
106{%- endif %}
107</header>
108<main>
109<h1>{{ page.title }}</h1>
110{%- if page.date %}
111<p class="page-date">{{ page.date }}</p>
112{%- endif %}
113{%- if page.toc | length > 1 %}
114{%- macro toc_list(entries) %}
115<ul>
116{%- for entry in entries %}
117<li><a href="#{{ entry.anchor }}">{{ entry.title }}</a>
118{%- if entry.children %}{{ toc_list(entry.children) }}{% endif %}</li>
119{%- endfor %}
120</ul>
121{%- endmacro %}
122<nav class="toc" aria-label="Table of contents">
123<h2>Contents</h2>
124{{- toc_list(page.toc) }}
125</nav>
126{%- endif %}
127{{ body | safe }}</main>
128</body>
129</html>
130"##;
131
132/// The name a template must have to serve as the page layout.
133pub const BASE_TEMPLATE_NAME: &str = "base.html";
134
135#[derive(Debug, thiserror::Error)]
136pub enum TemplateError {
137    #[error("template error: {0}")]
138    Render(String),
139}
140
141/// Wraps a rendered fragment in its page template.
142pub struct Templater {
143    env: Environment<'static>,
144    /// `(name, source)` for every registered template, for the template hash. Sorted by
145    /// name so the hash does not depend on directory iteration order.
146    sources: Vec<(String, String)>,
147}
148
149impl Templater {
150    /// Load templates from `dir`, falling back to the built-in layout.
151    ///
152    /// A missing directory is fine — that is the zero-config path. A directory that
153    /// exists but contains a template that does not compile is an error: it means
154    /// someone is actively editing their layout, and rendering the built-in default
155    /// instead would look like their edit silently did nothing.
156    pub fn load(dir: Option<&Utf8Path>, base_url: &str) -> Result<Self> {
157        let mut sources: Vec<(String, String)> = Vec::new();
158
159        if let Some(dir) = dir.filter(|d| d.is_dir()) {
160            // Registered by full relative filename — `base.html`, `partials/head.html` —
161            // because that is what `{% extends "base.html" %}` names, and a stem-based
162            // scheme silently breaks the include syntax every Jinja user already knows.
163            // Any extension is loaded, so a feed can be a listing page with an XML
164            // template rather than a separate mechanism.
165            for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
166                let entry = entry.with_context(|| format!("reading templates from {dir}"))?;
167                if !entry.file_type().is_file() {
168                    continue;
169                }
170                let path = Utf8Path::from_path(entry.path())
171                    .map(Utf8Path::to_owned)
172                    .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?;
173                let name = path
174                    .strip_prefix(dir)
175                    .unwrap_or(&path)
176                    .as_str()
177                    .replace('\\', "/");
178                if name.starts_with('.') || name.contains("/.") {
179                    continue;
180                }
181                let source = std::fs::read_to_string(&path)
182                    .with_context(|| format!("reading template {path}"))?;
183                sources.push((name, source));
184            }
185        }
186
187        if !sources.iter().any(|(n, _)| n == BASE_TEMPLATE_NAME) {
188            sources.push((BASE_TEMPLATE_NAME.to_string(), BASE_TEMPLATE.to_string()));
189        }
190        sources.sort_by(|a, b| a.0.cmp(&b.0));
191
192        let mut env = Environment::new();
193        env.set_formatter(html_formatter);
194        add_filters(&mut env, base_url);
195        for (name, source) in &sources {
196            // `Environment<'static>` needs owned sources; leaking is bounded by the
197            // template count and lives as long as the build anyway.
198            let name: &'static str = Box::leak(name.clone().into_boxed_str());
199            let source: &'static str = Box::leak(source.clone().into_boxed_str());
200            env.add_template(name, source)
201                .with_context(|| format!("compiling template {name}"))?;
202        }
203
204        Ok(Templater { env, sources })
205    }
206
207    /// `(name, source)` for every registered template — the template hash's input
208    /// (spec §4.1), covering user templates so editing one invalidates its pages.
209    pub fn sources(&self) -> &[(String, String)] {
210        &self.sources
211    }
212
213    /// The sources a page rendered through `name` actually depends on: that template plus
214    /// everything it extends, includes or imports, transitively.
215    ///
216    /// This is what keeps a layout edit proportional. Hashing *all* templates into every
217    /// page means touching `feed.xml` re-renders a 200-page site, which is most of the
218    /// wait in a `serve` session spent on design.
219    ///
220    /// A template whose include is computed at render time — `{% include chooser %}` —
221    /// cannot be followed statically, so it depends on everything. Over-invalidating is
222    /// slow; under-invalidating publishes a stale page.
223    pub fn sources_for(&self, name: &str) -> Vec<(String, String)> {
224        let mut seen: BTreeSet<String> = BTreeSet::new();
225        let mut queue = vec![name.to_string()];
226        while let Some(current) = queue.pop() {
227            if !seen.insert(current.clone()) {
228                continue;
229            }
230            let Some((_, source)) = self.sources.iter().find(|(n, _)| *n == current) else {
231                continue;
232            };
233            let (deps, dynamic) = referenced_templates(source);
234            if dynamic {
235                return self.sources.clone();
236            }
237            queue.extend(deps);
238        }
239        self.sources
240            .iter()
241            .filter(|(n, _)| seen.contains(n))
242            .cloned()
243            .collect()
244    }
245
246    /// Is a template with this name registered?
247    pub fn has(&self, name: &str) -> bool {
248        self.env.get_template(name).is_ok()
249    }
250
251    /// Every registered template name, for error messages.
252    pub fn names(&self) -> Vec<&str> {
253        self.sources.iter().map(|(n, _)| n.as_str()).collect()
254    }
255
256    /// Render through a named template. Generated pages use this to reach their own
257    /// layout; the context is identical to a normal page's, so a listing template can
258    /// `{% extends "base.html" %}` and inherit the site's chrome for free.
259    pub fn render(&self, template: &str, ctx: &RenderContext) -> Result<String, TemplateError> {
260        let tmpl = self
261            .env
262            .get_template(template)
263            .map_err(|e| TemplateError::Render(e.to_string()))?;
264        tmpl.render(context! {
265            site => ctx.site,
266            page => ctx.page,
267            body => ctx.body,
268            nav => ctx.nav,
269            stylesheet => ctx.stylesheet,
270            root => ctx.root,
271            pages => ctx.pages,
272            group => ctx.group,
273            groups => ctx.groups,
274            paginator => ctx.paginator,
275        })
276        .map_err(|e| TemplateError::Render(render_error_detail(e)))
277    }
278
279    /// Render through the site's base layout.
280    pub fn render_page(&self, ctx: &RenderContext) -> Result<String, TemplateError> {
281        self.render(BASE_TEMPLATE_NAME, ctx)
282    }
283}
284
285/// One group of a grouped collection — a tag, or a `#+CATEGORY:` value.
286#[derive(Debug, Clone, Serialize)]
287pub struct GroupContext {
288    /// The term as written, e.g. `Rust Lang`.
289    pub name: String,
290    /// URL-safe form used in the output path, e.g. `rust-lang`.
291    pub slug: String,
292    /// Output path of this group's page, relative to the site root. Empty when the
293    /// collection emits no per-group pages.
294    pub url: String,
295    /// How many pages carry this term.
296    pub count: usize,
297}
298
299/// One page of a paginated listing, exposed to templates as `paginator`.
300///
301/// Every URL here is relative to the page being rendered, so a template can emit them
302/// directly however deep the page sits.
303#[derive(Debug, Clone, Serialize)]
304pub struct Paginator {
305    /// 1-based number of this page.
306    pub current: usize,
307    /// How many pages the listing splits into.
308    pub total: usize,
309    /// Entries per page, as configured.
310    pub per_page: usize,
311    /// Entries across the whole listing, not just this page.
312    pub total_entries: usize,
313    pub prev_url: Option<String>,
314    pub next_url: Option<String>,
315    pub first_url: String,
316    pub last_url: String,
317    /// Every page, for a numbered strip.
318    pub pages: Vec<PaginatorPage>,
319}
320
321#[derive(Debug, Clone, Serialize)]
322pub struct PaginatorPage {
323    pub number: usize,
324    pub url: String,
325    /// True for the page currently being rendered, so a template can mark it without
326    /// comparing numbers itself.
327    pub current: bool,
328}
329
330/// Everything a template can see. A struct rather than a dozen positional arguments,
331/// because the list grows every time templates learn something new.
332pub struct RenderContext<'a> {
333    pub site: &'a SiteContext,
334    pub page: &'a PageContext,
335    /// Rendered page HTML. Empty for generated pages, which build their body from
336    /// `pages`/`groups` instead.
337    pub body: &'a str,
338    pub nav: &'a [NavItem],
339    /// URL of the syntax stylesheet, relative to this page.
340    pub stylesheet: &'a str,
341    /// `../`-prefix back to the site root from this page.
342    pub root: &'a str,
343    /// The pages this listing shows, or every page when `expose_page_list` is on.
344    pub pages: Option<&'a [PageContext]>,
345    /// The group this page is for, on a grouped collection's per-group page.
346    pub group: Option<&'a GroupContext>,
347    /// Every group of a grouped collection — the group index's content. Empty on a
348    /// per-group page, which depends on its own entries and not on the other groups.
349    pub groups: &'a [GroupContext],
350    /// Present only on a page of a paginated listing.
351    pub paginator: Option<&'a Paginator>,
352}
353
354impl<'a> RenderContext<'a> {
355    /// A context with only the universally-present parts filled in.
356    pub fn new(
357        site: &'a SiteContext,
358        page: &'a PageContext,
359        nav: &'a [NavItem],
360        stylesheet: &'a str,
361        root: &'a str,
362    ) -> Self {
363        RenderContext {
364            site,
365            page,
366            body: "",
367            nav,
368            stylesheet,
369            root,
370            pages: None,
371            group: None,
372            groups: &[],
373            paginator: None,
374        }
375    }
376}
377
378/// The starter tag-index template written by `orgo init`: shows how `groups` is
379/// iterated, and how a group page is linked.
380pub const STARTER_TAGS_TEMPLATE: &str = r#"<!DOCTYPE html>
381<html lang="{{ site.language }}">
382<head>
383<meta charset="utf-8">
384<meta name="viewport" content="width=device-width, initial-scale=1">
385<title>{{ page.title }} &middot; {{ site.title }}</title>
386{%- if stylesheet %}
387<link rel="stylesheet" href="{{ stylesheet }}">
388{%- endif %}
389</head>
390<body>
391<header>
392<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
393{%- if nav %}
394<nav>
395{%- for item in nav %}
396<a href="{{ item.url }}">{{ item.title }}</a>
397{%- endfor %}
398</nav>
399{%- endif %}
400</header>
401<main>
402<h1>{{ page.title }}</h1>
403<ul class="tag-list">
404{%- for tag in groups %}
405<li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
406{%- endfor %}
407</ul>
408</main>
409</body>
410</html>
411"#;
412
413/// The starter listing template written by `orgo init`: a blog index, showing how a
414/// collection's `pages` are iterated.
415pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
416<html lang="{{ site.language }}">
417<head>
418<meta charset="utf-8">
419<meta name="viewport" content="width=device-width, initial-scale=1">
420<title>{{ page.title }} &middot; {{ site.title }}</title>
421{%- if stylesheet %}
422<link rel="stylesheet" href="{{ stylesheet }}">
423{%- endif %}
424</head>
425<body>
426<header>
427<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
428{%- if nav %}
429<nav>
430{%- for item in nav %}
431<a href="{{ item.url }}">{{ item.title }}</a>
432{%- endfor %}
433</nav>
434{%- endif %}
435</header>
436<main>
437<h1>{{ page.title }}</h1>
438<ul class="post-list">
439{%- for post in pages %}
440<li>
441{%- if post.date_iso %}<time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time> {% endif %}
442<a href="{{ root }}{{ post.url }}">{{ post.title }}</a>
443{%- if post.excerpt %}
444<p class="excerpt">{{ post.excerpt | truncate(180) }}</p>
445{%- endif %}
446<span class="reading-time">{{ post.reading_time }} min read</span>
447</li>
448{%- endfor %}
449</ul>
450{%- if paginator and paginator.total > 1 %}
451<nav class="pagination">
452{%- if paginator.prev_url %}
453<a rel="prev" href="{{ paginator.prev_url }}">Newer</a>
454{%- endif %}
455<span>Page {{ paginator.current }} of {{ paginator.total }}</span>
456{%- if paginator.next_url %}
457<a rel="next" href="{{ paginator.next_url }}">Older</a>
458{%- endif %}
459</nav>
460{%- endif %}
461</main>
462</body>
463</html>
464"#;
465
466/// Filters a template can use beyond minijinja's built-ins.
467///
468/// Both exist for the same reason: a syndication feed has requirements an HTML page does
469/// not, and satisfying them by hand in a template is the kind of thing that produces a
470/// feed which *looks* right and fails validation.
471fn add_filters(env: &mut Environment<'static>, base_url: &str) {
472    let base = base_url.trim_end_matches('/').to_string();
473
474    // `absolute`: a site-root-relative path → an absolute URL.
475    //
476    // Feeds are read away from the site that served them, so relative links in one are
477    // simply broken. Applies to the site-root-relative paths — `page.url`, `pages[].url`,
478    // `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`,
479    // which are relative to the page carrying them and already correct in a page.
480    env.add_filter(
481        "absolute",
482        move |path: &str| -> Result<String, minijinja::Error> {
483            if base.is_empty() {
484                // Returning the relative path would produce a feed that validates
485                // nowhere and looks fine everywhere. Say what is missing instead.
486                return Err(minijinja::Error::new(
487                    minijinja::ErrorKind::InvalidOperation,
488                    "the `absolute` filter needs site.base_url, which is empty; \
489                     set it in orgo.toml (e.g. base_url = \"https://example.com\")",
490                ));
491            }
492            if path.starts_with("http://") || path.starts_with("https://") {
493                return Ok(path.to_string());
494            }
495            Ok(format!("{base}/{}", path.trim_start_matches('/')))
496        },
497    );
498
499    // `truncate`: shorten to at most N characters, on a word boundary, with an ellipsis.
500    //
501    // minijinja ships no truncate, and an excerpt is usually a whole first paragraph —
502    // so without this the only options in a listing are the full paragraph or nothing.
503    env.add_filter(
504        "truncate",
505        |text: &str, limit: Option<usize>| -> String {
506            let limit = limit.unwrap_or(160);
507            if text.chars().count() <= limit {
508                return text.to_string();
509            }
510            let head: String = text.chars().take(limit).collect();
511            // Cut at the last space so a word is never sliced in half; if there is no
512            // space at all, the hard cut is the only option.
513            let cut = head.rfind(char::is_whitespace).unwrap_or(head.len());
514            format!("{}", head[..cut].trim_end())
515        },
516    );
517
518    // `rfc822`: an org or ISO date → the format RSS `pubDate` requires.
519    env.add_filter("rfc822", |raw: &str| -> Result<String, minijinja::Error> {
520        let iso = crate::util::iso_date(raw).ok_or_else(|| {
521            minijinja::Error::new(
522                minijinja::ErrorKind::InvalidOperation,
523                format!("cannot read a date out of {raw:?} for an RSS pubDate"),
524            )
525        })?;
526        let date = chrono::NaiveDate::parse_from_str(&iso, "%Y-%m-%d").map_err(|e| {
527            minijinja::Error::new(
528                minijinja::ErrorKind::InvalidOperation,
529                format!("{iso} is not a valid date: {e}"),
530            )
531        })?;
532        // Org dates carry no timezone, so midnight UTC is the honest reading of one.
533        Ok(date
534            .and_hms_opt(0, 0, 0)
535            .expect("midnight is a valid time")
536            .format("%a, %d %b %Y %H:%M:%S +0000")
537            .to_string())
538    });
539}
540
541/// The starter RSS feed written by `orgo init`. A listing page with an XML template:
542/// no feed-specific machinery, just `absolute` and `rfc822` doing what syndication needs.
543///
544/// Emitted commented-out guidance rather than a broken feed when `site.base_url` is
545/// unset — see the `init` scaffold, which leaves the feed collection commented out until
546/// there is a base URL to make absolute links from.
547pub const STARTER_FEED_TEMPLATE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
548<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
549<channel>
550<title>{{ site.title }}</title>
551<link>{{ "index.html" | absolute }}</link>
552<description>{{ site.description }}</description>
553<language>{{ site.language }}</language>
554<atom:link href="{{ page.url | absolute }}" rel="self" type="application/rss+xml"/>
555{%- for post in pages %}
556<item>
557<title>{{ post.title }}</title>
558<link>{{ post.url | absolute }}</link>
559<guid isPermaLink="true">{{ post.url | absolute }}</guid>
560{%- if post.date_iso %}
561<pubDate>{{ post.date_iso | rfc822 }}</pubDate>
562{%- endif %}
563{%- for tag in post.tags %}
564<category>{{ tag }}</category>
565{%- endfor %}
566</item>
567{%- endfor %}
568</channel>
569</rss>
570"#;
571
572/// Template names a source refers to, and whether any reference is computed at render
573/// time rather than written as a literal.
574///
575/// A hand-rolled scan rather than a parse: minijinja does not expose the dependency
576/// graph, and the three tags that pull in another template all name it as the first
577/// string literal in the tag.
578fn referenced_templates(source: &str) -> (Vec<String>, bool) {
579    const TAGS: &[&str] = &["extends", "include", "import", "from"];
580    let mut names = Vec::new();
581    let mut dynamic = false;
582    let mut rest = source;
583    while let Some(start) = rest.find("{%") {
584        let after = &rest[start + 2..];
585        let Some(end) = after.find("%}") else { break };
586        let tag = &after[..end];
587        rest = &after[end + 2..];
588
589        let keyword = tag
590            .trim_start()
591            .trim_start_matches('-')
592            .split_whitespace()
593            .next()
594            .unwrap_or("");
595        if !TAGS.contains(&keyword) {
596            continue;
597        }
598        match string_literal(tag) {
599            Some(name) => names.push(name),
600            // `{% include some_variable %}` or `{% include ["a", "b"] %}` past the first
601            // entry: the set cannot be known here.
602            None => dynamic = true,
603        }
604    }
605    if source.contains("{% include [") || source.contains("{%- include [") {
606        dynamic = true;
607    }
608    (names, dynamic)
609}
610
611/// The first single- or double-quoted string in a tag body.
612fn string_literal(tag: &str) -> Option<String> {
613    let bytes = tag.as_bytes();
614    let quote = bytes.iter().position(|b| *b == b'"' || *b == b'\'')?;
615    let delim = bytes[quote];
616    let after = &tag[quote + 1..];
617    let end = after.find(delim as char)?;
618    Some(after[..end].to_string())
619}
620
621/// HTML-escape template output, escaping the same characters Jinja2 does.
622///
623/// minijinja additionally escapes `/` as `&#x2f;`, which is a defence for values
624/// interpolated into JavaScript. It is correct but, since `<` is escaped anyway, it buys
625/// nothing in an HTML document — and it makes every generated URL read
626/// `..&#x2f;index.html`. Templates emit a lot of URLs, so that is most of the output.
627///
628/// Auto-escaping itself stays on: page titles come from `#+TITLE:` and are user content.
629fn html_formatter(
630    out: &mut minijinja::Output,
631    state: &minijinja::State,
632    value: &minijinja::Value,
633) -> Result<(), minijinja::Error> {
634    if state.auto_escape() == minijinja::AutoEscape::Html && !value.is_safe() {
635        if let Some(text) = value.as_str() {
636            let mut escaped = String::with_capacity(text.len());
637            for c in text.chars() {
638                match c {
639                    '&' => escaped.push_str("&amp;"),
640                    '<' => escaped.push_str("&lt;"),
641                    '>' => escaped.push_str("&gt;"),
642                    '"' => escaped.push_str("&quot;"),
643                    '\'' => escaped.push_str("&#x27;"),
644                    _ => escaped.push(c),
645                }
646            }
647            return out.write_str(&escaped).map_err(minijinja::Error::from);
648        }
649    }
650    minijinja::escape_formatter(out, state, value)
651}
652
653/// minijinja's `Display` gives only the top-level message; the useful part (which
654/// template, which line) is in the source and cause chain.
655fn render_error_detail(error: minijinja::Error) -> String {
656    let mut out = error.to_string();
657    if let Some(name) = error.template_source().map(|_| error.name().unwrap_or("?")) {
658        if let Some(line) = error.line() {
659            out = format!("{out} (in template {name}, line {line})");
660        }
661    }
662    let mut source = std::error::Error::source(&error);
663    while let Some(cause) = source {
664        out.push_str(&format!(": {cause}"));
665        source = cause.source();
666    }
667    out
668}
669
670/// The starter layout written by `orgo init`: the built-in template, on disk, ready
671/// to edit.
672pub fn starter_template() -> &'static str {
673    BASE_TEMPLATE
674}