krz/orgo

Lightning fast org-mode static site generator.

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

main: 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 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 }} &middot; {{ 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 }} &middot; {{ 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 `&#x2f;`, 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/// `..&#x2f;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("&amp;"),
656                    '<' => escaped.push_str("&lt;"),
657                    '>' => escaped.push_str("&gt;"),
658                    '"' => escaped.push_str("&quot;"),
659                    '\'' => escaped.push_str("&#x27;"),
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}