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