krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.20.2: 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 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 }} · {{ 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 }} · {{ 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 `/`, 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/// `../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("&"),
640 '<' => escaped.push_str("<"),
641 '>' => escaped.push_str(">"),
642 '"' => escaped.push_str("""),
643 '\'' => escaped.push_str("'"),
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}