krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit b6270e3bf7

b6270e3bf7994c0a4894e4b516f63eee371cdeb4

parent: c3d46452b5

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 18:51 UTC

Let pages choose their layout

base.html was the only layout an authored page could have, so a section that
needed its own chrome — a blog post's byline and reply footer — had no way to
ask for it. The workaround was a URL-prefix test inside the shared layout,
which puts routing in the template and gets worse with every section.

Two ways in, because there are two different facts to state:

- `[[pages]]` rules map a source path to a template. A section's layout is a
  property of the section, and asking an author to repeat `#+TEMPLATE:` in 200
  posts is asking them to maintain one fact 200 times. `match` is a directory
  (covering everything beneath it) or one file, matched by path component so
  `blog` does not capture `blogroll.org`. The most specific rule wins, by path
  depth, independent of declaration order.
- `#+TEMPLATE:` on the page itself, which beats any rule — the more local
  statement, written with that page in view.

A name that is not in the templates directory fails the build before anything
renders, naming the page, the template and what does exist. minijinja would
report the missing name eventually, but only once some page reached it.

Incremental correctness comes out unchanged: the keyword is content, the rules
are config, and both already feed the render key.

Layout: unified · split

README.md +9
@@ -62,6 +62,10 @@ mode = "top-level" # top-level | all | explicit | none
62dir = "templates" # base.html replaces the built-in layout 62dir = "templates" # base.html replaces the built-in layout
63expose_page_list = false 63expose_page_list = false
64 64
65# [[pages]] # which layout a section renders through; base.html by default
66# match = "blog" # a source directory or one .org file; most specific rule wins
67# template = "post.html"
68
65[highlight] 69[highlight]
66theme = "InspiredGitHub" 70theme = "InspiredGitHub"
67 71
@@ -90,6 +94,11 @@ receive:
90your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is 94your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is
91`{{ page.keywords.custom_thing }}`. 95`{{ page.keywords.custom_thing }}`.
92 96
97`base.html` is the default layout, not the only one. A `[[pages]]` rule gives a section
98its own — `match = "blog"`, `template = "post.html"` — and `#+TEMPLATE: wide.html` gives
99one page its own, which wins over any rule. A second layout usually starts with
100`{% extends "base.html" %}`.
101
93Editing a template re-renders the pages that use it — template sources are a hash input, 102Editing a template re-renders the pages that use it — template sources are a hash input,
94so a design change never leaves a site half-updated. 103so a design change never leaves a site half-updated.
95 104
docs/guide/02-configuration.org +34
@@ -117,6 +117,40 @@ being listed is appended after everything you did list, so ="about.org"= alone w
117said. Either spelling works for an authored page too — its source path or its output 117said. Either spelling works for an authored page too — its source path or its output
118path — though the source path is the one that survives a =#+SLUG:=. 118path — though the source path is the one that survives a =#+SLUG:=.
119 119
120* [[pages]]
121
122Which layout a page renders through. Without any of these, every authored page uses
123=base.html=.
124
125#+BEGIN_SRC toml
126[[pages]]
127match = "blog"
128template = "post.html"
129#+END_SRC
130
131=match= is a *source* path relative to the source root — a directory, covering every page
132beneath it however deep, or one =.org= file. It is matched by path component, so =blog=
133covers =blog/2026/post.org= and does not touch =blogroll.org=.
134
135A section's layout is a property of the section, which is why this is a rule and not
136something you write in each file: a blog post carries the same byline and reply footer as
137every other one, and repeating that in 200 files means maintaining one fact 200 times.
138
139** Which rule wins
140
141Most specific, by path depth — =blog/notes= beats =blog=, whatever order they appear in.
142An empty =match= covers the whole site, which is how you rename the default layout.
143
144A page that differs from its section says so itself, and that wins over any rule:
145
146#+BEGIN_SRC org
147,#+TITLE: Colophon
148,#+TEMPLATE: wide.html
149#+END_SRC
150
151Naming a template that is not in the templates directory is an error that names the page,
152the template and what does exist — a layout typo should not be a hunt.
153
120* [templates] 154* [templates]
121 155
122| Key | Default | Meaning | 156| Key | Default | Meaning |
docs/guide/04-templates.org +28
@@ -35,6 +35,34 @@ A template that does not compile is a *build error*, not a fallback to the defau
35someone editing a layout should see the mistake, not output that looks like their edit 35someone editing a layout should see the mistake, not output that looks like their edit
36did nothing. 36did nothing.
37 37
38* Pages can render through a different layout
39
40=base.html= is the default, not the only option. A =[[pages]]= rule gives a section its
41own layout, and =#+TEMPLATE:= gives one page its own:
42
43#+BEGIN_SRC toml
44[[pages]]
45match = "blog"
46template = "post.html"
47#+END_SRC
48
49#+BEGIN_SRC org
50,#+TEMPLATE: wide.html
51#+END_SRC
52
53The page's own keyword wins over any rule, and the most specific rule wins over a broader
54one. A second layout almost always wants the first one's chrome, so it extends it:
55
56#+BEGIN_SRC html
57{% extends "base.html" %}
58{% block content %}
59{{ body | safe }}
60<p><a href="mailto:you@example.com">Reply by email &rarr;</a></p>
61{% endblock %}
62#+END_SRC
63
64Full rules in [[file:02-configuration.org][Configuration]].
65
38* Names are full filenames 66* Names are full filenames
39 67
40Templates are registered under their full relative filename: =base.html=, 68Templates are registered under their full relative filename: =base.html=,
docs/guide/05-org-support.org +1
@@ -118,6 +118,7 @@ broken.
118| =#+FILETAGS:= | Tags, for grouping and =page.tags=. | 118| =#+FILETAGS:= | Tags, for grouping and =page.tags=. |
119| =#+SLUG:= | Sets the output filename. | 119| =#+SLUG:= | Sets the output filename. |
120| =#+DRAFT:= | Keeps the page out of the build. | 120| =#+DRAFT:= | Keeps the page out of the build. |
121| =#+TEMPLATE:= | The layout this page renders through. |
121| =#+OPTIONS:= | Per-file export switches. | 122| =#+OPTIONS:= | Per-file export switches. |
122| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. | 123| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. |
123 124
src/config.rs +77
@@ -34,6 +34,67 @@ pub struct Config {
34 /// Generated listing pages. Each produces one output file that has no source `.org` 34 /// Generated listing pages. Each produces one output file that has no source `.org`
35 /// file behind it — a blog index, an archive, a feed. 35 /// file behind it — a blog index, an archive, a feed.
36 pub collections: Vec<Collection>, 36 pub collections: Vec<Collection>,
37 /// Which layout authored pages render through, by source path. Pages matching no
38 /// rule use `base.html`.
39 pub pages: Vec<PageRule>,
40}
41
42/// One layout rule: the pages under `match` render through `template`.
43///
44/// Sections usually want one layout — every blog post carries the same byline and reply
45/// footer — and asking an author to repeat `#+TEMPLATE:` in each of 200 files is asking
46/// them to maintain the same fact 200 times. A rule states it once for the directory; a
47/// page that differs still says so itself with `#+TEMPLATE:`, which wins.
48#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
49#[serde(default, deny_unknown_fields)]
50pub struct PageRule {
51 /// A source path: a directory, matching every page beneath it, or one `.org` file.
52 /// Relative to the source root, like `nav.pages`.
53 #[serde(rename = "match")]
54 pub pattern: Utf8PathBuf,
55 /// Template file name, as it appears in the templates directory.
56 pub template: String,
57}
58
59impl PageRule {
60 /// Does this rule cover `source`? Matching is by path component, so a directory rule
61 /// covers everything beneath it however deep — `blog` matches `blog/2026/post.org`,
62 /// because a section's layout is a property of the section and not of how its files
63 /// happen to be filed — while `blo` matches nothing. An empty `match` covers the
64 /// whole site, which is how you change the default layout's name.
65 pub fn covers(&self, source: &Utf8Path) -> bool {
66 source.starts_with(&self.pattern)
67 }
68
69 /// How specific this rule is, for picking between two that both match. Longer paths
70 /// are more specific, so `blog/notes` beats `blog`.
71 fn specificity(&self) -> usize {
72 self.pattern.components().count()
73 }
74}
75
76/// Which template an authored page renders through: its own `#+TEMPLATE:` if it names
77/// one, else the most specific `[[pages]]` rule covering it, else `base.html`.
78///
79/// A page's own declaration wins because it is the more local statement — the one written
80/// with that page in view.
81pub fn page_template(config: &Config, source: &Utf8Path, keywords: &crate::model::Keywords) -> String {
82 let declared = keywords
83 .entries
84 .iter()
85 .find(|(k, _)| k.eq_ignore_ascii_case("TEMPLATE"))
86 .map(|(_, v)| v.trim())
87 .filter(|v| !v.is_empty());
88 if let Some(name) = declared {
89 return name.to_string();
90 }
91 config
92 .pages
93 .iter()
94 .filter(|rule| rule.covers(source))
95 .max_by_key(|rule| rule.specificity())
96 .map(|rule| rule.template.clone())
97 .unwrap_or_else(|| crate::template::BASE_TEMPLATE_NAME.to_string())
37} 98}
38 99
39/// A generated page that lists other pages. 100/// A generated page that lists other pages.
@@ -390,6 +451,15 @@ impl Config {
390 seen.push(path); 451 seen.push(path);
391 } 452 }
392 } 453 }
454 for rule in &self.pages {
455 if rule.template.trim().is_empty() {
456 anyhow::bail!(
457 "the [[pages]] rule matching {:?} names no `template`; it has nothing \
458 to select",
459 rule.pattern.as_str()
460 );
461 }
462 }
393 if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') { 463 if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
394 anyhow::bail!( 464 anyhow::bail!(
395 "site.base_url must not end with a slash (got {:?}) — URLs are joined \ 465 "site.base_url must not end with a slash (got {:?}) — URLs are joined \
@@ -431,6 +501,13 @@ dir = "templates"
431# archive. Costs incremental precision: with this on, adding a page re-renders the site. 501# archive. Costs incremental precision: with this on, adding a page re-renders the site.
432expose_page_list = false 502expose_page_list = false
433 503
504# Which layout a page renders through. Without a rule, every page uses base.html.
505# `match` is a source path — a directory (covering everything beneath it) or one .org
506# file — and the most specific rule wins. A page overrides any rule with `#+TEMPLATE:`.
507# [[pages]]
508# match = "blog"
509# template = "post.html"
510
434[highlight] 511[highlight]
435# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark, 512# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark,
436# base16-eighties.dark, base16-mocha.dark, base16-ocean.light. 513# base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
src/main.rs +10 −3
@@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf};
7use clap::{Parser, Subcommand}; 7use clap::{Parser, Subcommand};
8 8
9use org_ssg::parser::parse; 9use org_ssg::parser::parse;
10use org_ssg::config::Config; 10use org_ssg::config::{self, Config};
11use org_ssg::render::{self, render, Html, SyntectHighlighter}; 11use org_ssg::render::{self, render, Html, SyntectHighlighter};
12use org_ssg::resolve::ResolvedDoc; 12use org_ssg::resolve::ResolvedDoc;
13use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; 13use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
@@ -323,9 +323,16 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
323 }; 323 };
324 let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); 324 let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
325 ctx.body = &fragment; 325 ctx.body = &fragment;
326 // `#+TEMPLATE:` and `[[pages]]` apply here too, so `build one.org` and a whole-site
327 // build put the same page through the same layout.
328 let name = config::page_template(
329 &config,
330 Utf8Path::new(input.file_name().unwrap_or_default()),
331 &resolved.document.keywords,
332 );
326 let page = templater 333 let page = templater
327 .render_page(&ctx) 334 .render(&name, &ctx)
328 .with_context(|| format!("templating {input}"))?; 335 .with_context(|| format!("templating {input} through {name}"))?;
329 fs::write(output, page).with_context(|| format!("writing output file {output}"))?; 336 fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
330 337
331 let css = output.with_file_name(SYNTAX_STYLESHEET); 338 let css = output.with_file_name(SYNTAX_STYLESHEET);
src/site.rs +30 −2
@@ -115,6 +115,9 @@ struct PagePrep {
115 diagnostics: Vec<Diagnostic>, 115 diagnostics: Vec<Diagnostic>,
116 nav: Vec<NavItem>, 116 nav: Vec<NavItem>,
117 context: PageContext, 117 context: PageContext,
118 /// The layout this page renders through: `#+TEMPLATE:`, a `[[pages]]` rule, or
119 /// `base.html` (see [`config::page_template`]).
120 template: String,
118} 121}
119 122
120/// A generated page, resolved against the pages it lists. 123/// A generated page, resolved against the pages it lists.
@@ -677,6 +680,7 @@ fn prepare_pages(
677 680
678 PagePrep { 681 PagePrep {
679 context: page_context(doc, &output, config), 682 context: page_context(doc, &output, config),
683 template: config::page_template(config, &doc.source_path, &doc.keywords),
680 source: doc.source_path.clone(), 684 source: doc.source_path.clone(),
681 output, 685 output,
682 title: page_title(doc), 686 title: page_title(doc),
@@ -694,6 +698,28 @@ fn prepare_pages(
694 Ok((pages, symbols)) 698 Ok((pages, symbols))
695} 699}
696 700
701/// Fail before rendering if any page names a template that does not exist.
702///
703/// minijinja would report the missing name on its own, but only once a page reaches it
704/// — and a typo in `#+TEMPLATE:` or a `[[pages]]` rule is worth naming together with the
705/// page that carries it and the templates that do exist.
706fn check_page_templates(templater: &Templater, preps: &[PagePrep]) -> Result<()> {
707 for p in preps {
708 if !templater.has(&p.template) {
709 let mut available = templater.names();
710 available.sort_unstable();
711 anyhow::bail!(
712 "{} renders through {}, which is not in the templates directory. \
713 Available: {}",
714 p.source,
715 p.template,
716 available.join(", ")
717 );
718 }
719 }
720 Ok(())
721}
722
697/// Parse + index + resolve + render + template a whole site *in memory*, without 723/// Parse + index + resolve + render + template a whole site *in memory*, without
698/// touching the output directory. Shared by the tests (full render, every page). 724/// touching the output directory. Shared by the tests (full render, every page).
699pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { 725pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
@@ -702,6 +728,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
702 let (preps, _symbols) = prepare_pages(src, &config, None)?; 728 let (preps, _symbols) = prepare_pages(src, &config, None)?;
703 let highlighter = SyntectHighlighter::new(); 729 let highlighter = SyntectHighlighter::new();
704 let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; 730 let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?;
731 check_page_templates(&templater, &preps)?;
705 let site = site_context(&config); 732 let site = site_context(&config);
706 let listing = page_listing(&config, &preps); 733 let listing = page_listing(&config, &preps);
707 734
@@ -770,8 +797,8 @@ fn render_page(
770 ctx.body = &fragment; 797 ctx.body = &fragment;
771 ctx.pages = pages; 798 ctx.pages = pages;
772 templater 799 templater
773 .render_page(&ctx) 800 .render(&p.template, &ctx)
774 .with_context(|| format!("templating {}", p.source)) 801 .with_context(|| format!("templating {} through {}", p.source, p.template))
775} 802}
776 803
777/// Site-root-relative name of the generated syntax stylesheet. Every page links to it. 804/// Site-root-relative name of the generated syntax stylesheet. Every page links to it.
@@ -796,6 +823,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
796 let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; 823 let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
797 824
798 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?; 825 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
826 check_page_templates(&templater, &preps)?;
799 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { 827 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
800 anyhow::anyhow!( 828 anyhow::anyhow!(
801 "unknown highlight.theme {:?}. Available: {}", 829 "unknown highlight.theme {:?}. Available: {}",
tests/config.rs +215
@@ -1913,3 +1913,218 @@ fn export_options_parse_as_org_writes_them() {
1913 assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}"); 1913 assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}");
1914 } 1914 }
1915} 1915}
1916
1917// ---------------------------------------------------------------------------
1918// Per-page template selection
1919// ---------------------------------------------------------------------------
1920
1921/// A site with a `post.html` layout beside the default one, so a page can be shown to
1922/// render through the layout it chose rather than the one every page gets.
1923fn write_two_layouts(src: &Utf8PathBuf, config: &str) {
1924 write_site(src);
1925 std::fs::create_dir_all(src.join("templates")).unwrap();
1926 std::fs::write(
1927 src.join("templates/base.html"),
1928 "<html><body><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
1929 )
1930 .unwrap();
1931 std::fs::write(
1932 src.join("templates/post.html"),
1933 "<html><body class=\"post\"><h1>{{ page.title }}</h1>{{ body | safe }}\
1934 <p>Reply by email</p></body></html>",
1935 )
1936 .unwrap();
1937 std::fs::write(src.join("org-ssg.toml"), config).unwrap();
1938}
1939
1940/// A section's layout is a property of the section: one rule covers every page under it,
1941/// however deep, without touching a single source file.
1942#[test]
1943fn a_pages_rule_gives_a_directory_its_own_layout() {
1944 let root = tmpdir("tmplrule");
1945 let src = root.join("src");
1946 std::fs::create_dir_all(&src).unwrap();
1947 write_two_layouts(
1948 &src,
1949 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
1950 );
1951 std::fs::create_dir_all(src.join("blog/2026")).unwrap();
1952 std::fs::write(
1953 src.join("blog/2026/nested.org"),
1954 "#+TITLE: Nested\n\nDeep.\n",
1955 )
1956 .unwrap();
1957 let out = root.join("out");
1958 build(&src, &out);
1959
1960 assert!(
1961 page(&out, "blog/post.html").contains("Reply by email"),
1962 "a post uses the section layout"
1963 );
1964 assert!(
1965 page(&out, "blog/2026/nested.html").contains("Reply by email"),
1966 "so does a post nested deeper"
1967 );
1968 assert!(
1969 !page(&out, "about.html").contains("Reply by email"),
1970 "a page outside the section does not"
1971 );
1972}
1973
1974/// Matching is by path component, not by string prefix: `blog` must not capture
1975/// `blogroll.org`, which is a different page with a name that happens to start the same.
1976#[test]
1977fn a_pages_rule_matches_whole_path_components() {
1978 let root = tmpdir("tmplprefix");
1979 let src = root.join("src");
1980 std::fs::create_dir_all(&src).unwrap();
1981 write_two_layouts(
1982 &src,
1983 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
1984 );
1985 std::fs::write(src.join("blogroll.org"), "#+TITLE: Blogroll\n\nLinks.\n").unwrap();
1986 let out = root.join("out");
1987 build(&src, &out);
1988
1989 assert!(
1990 !page(&out, "blogroll.html").contains("Reply by email"),
1991 "blogroll.org is not inside blog/"
1992 );
1993}
1994
1995/// The page's own declaration wins: it is the more local statement, written with that
1996/// page in view.
1997#[test]
1998fn a_page_template_keyword_overrides_the_rule() {
1999 let root = tmpdir("tmplkeyword");
2000 let src = root.join("src");
2001 std::fs::create_dir_all(&src).unwrap();
2002 write_two_layouts(
2003 &src,
2004 "[[pages]]\nmatch = \"blog\"\ntemplate = \"base.html\"\n",
2005 );
2006 std::fs::write(
2007 src.join("blog/post.org"),
2008 "#+TITLE: A Post\n#+TEMPLATE: post.html\n\nBody.\n",
2009 )
2010 .unwrap();
2011 let out = root.join("out");
2012 build(&src, &out);
2013
2014 assert!(
2015 page(&out, "blog/post.html").contains("Reply by email"),
2016 "the keyword beats the rule"
2017 );
2018}
2019
2020/// Two rules can both cover a page; the more specific path is the one that meant it.
2021#[test]
2022fn the_most_specific_pages_rule_wins() {
2023 let root = tmpdir("tmplspecific");
2024 let src = root.join("src");
2025 std::fs::create_dir_all(&src).unwrap();
2026 write_two_layouts(
2027 &src,
2028 // Declared before the broader rule, so passing this test means specificity
2029 // decided it and not declaration order.
2030 "[[pages]]\nmatch = \"blog/notes\"\ntemplate = \"post.html\"\n\n\
2031 [[pages]]\nmatch = \"blog\"\ntemplate = \"base.html\"\n",
2032 );
2033 std::fs::create_dir_all(src.join("blog/notes")).unwrap();
2034 std::fs::write(src.join("blog/notes/n.org"), "#+TITLE: Note\n\nBody.\n").unwrap();
2035 let out = root.join("out");
2036 build(&src, &out);
2037
2038 assert!(
2039 page(&out, "blog/notes/n.html").contains("Reply by email"),
2040 "the deeper rule wins"
2041 );
2042 assert!(
2043 !page(&out, "blog/post.html").contains("Reply by email"),
2044 "the shallower rule still covers the rest"
2045 );
2046}
2047
2048/// A template name that does not exist is a typo. Naming the page, the template and what
2049/// does exist is the difference between a fix and a hunt.
2050#[test]
2051fn a_missing_page_template_is_an_error_naming_it() {
2052 let root = tmpdir("tmplmissing");
2053 let src = root.join("src");
2054 std::fs::create_dir_all(&src).unwrap();
2055 write_two_layouts(&src, "");
2056 std::fs::write(
2057 src.join("about.org"),
2058 "#+TITLE: About\n#+TEMPLATE: nope.html\n\nAbout.\n",
2059 )
2060 .unwrap();
2061
2062 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
2063 .expect_err("a missing template must fail the build");
2064 let msg = format!("{err:#}");
2065 assert!(msg.contains("about.org"), "names the page: {msg}");
2066 assert!(msg.contains("nope.html"), "names the template: {msg}");
2067 assert!(msg.contains("post.html"), "lists what exists: {msg}");
2068}
2069
2070/// Changing a page's layout has to re-render that page and no other.
2071#[test]
2072fn changing_a_page_template_keyword_rerenders_only_that_page() {
2073 let root = tmpdir("tmplinc");
2074 let src = root.join("src");
2075 std::fs::create_dir_all(&src).unwrap();
2076 write_two_layouts(&src, "");
2077 let out = root.join("out");
2078 build(&src, &out);
2079
2080 std::fs::write(
2081 src.join("about.org"),
2082 "#+TITLE: About\n#+TEMPLATE: post.html\n\nAbout.\n",
2083 )
2084 .unwrap();
2085 let second = build(&src, &out);
2086
2087 assert_eq!(
2088 second.rendered,
2089 vec![Utf8PathBuf::from("about.html")],
2090 "only the page whose layout changed"
2091 );
2092 assert!(page(&out, "about.html").contains("Reply by email"));
2093}
2094
2095/// A rule is config, so adding one re-renders the pages it covers.
2096#[test]
2097fn adding_a_pages_rule_rerenders_the_pages_it_covers() {
2098 let root = tmpdir("tmplruleinc");
2099 let src = root.join("src");
2100 std::fs::create_dir_all(&src).unwrap();
2101 write_two_layouts(&src, "");
2102 let out = root.join("out");
2103 build(&src, &out);
2104
2105 std::fs::write(
2106 src.join("org-ssg.toml"),
2107 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
2108 )
2109 .unwrap();
2110 let second = build(&src, &out);
2111
2112 assert!(
2113 second.rendered.contains(&Utf8PathBuf::from("blog/post.html")),
2114 "the covered page re-rendered: {:?}",
2115 second.rendered
2116 );
2117 assert!(page(&out, "blog/post.html").contains("Reply by email"));
2118}
2119
2120/// A rule that names no template is a rule that does nothing.
2121#[test]
2122fn a_pages_rule_without_a_template_is_rejected() {
2123 let mut config = Config::default();
2124 config.pages.push(org_ssg::config::PageRule {
2125 pattern: Utf8PathBuf::from("blog"),
2126 template: String::new(),
2127 });
2128 let err = config.validate().expect_err("empty template must fail");
2129 assert!(format!("{err:#}").contains("blog"), "names it: {err:#}");
2130}