Commit b6270e3bf7
Verified · cmc
Layout: unified · split
README.md +9
| @@ -62,6 +62,10 @@ mode = "top-level" # top-level | all | explicit | none | ||
| 62 | 62 | dir = "templates" # base.html replaces the built-in layout |
| 63 | 63 | expose_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 | 69 | [highlight] |
| 66 | 70 | theme = "InspiredGitHub" |
| 67 | 71 | |
| @@ -90,6 +94,11 @@ receive: | ||
| 90 | 94 | your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is |
| 91 | 95 | `{{ page.keywords.custom_thing }}`. |
| 92 | 96 | |
| 97 | `base.html` is the default layout, not the only one. A `[[pages]]` rule gives a section | |
| 98 | its own — `match = "blog"`, `template = "post.html"` — and `#+TEMPLATE: wide.html` gives | |
| 99 | one page its own, which wins over any rule. A second layout usually starts with | |
| 100 | `{% extends "base.html" %}`. | |
| 101 | ||
| 93 | 102 | Editing a template re-renders the pages that use it — template sources are a hash input, |
| 94 | 103 | so 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 | ||
| 117 | 117 | said. Either spelling works for an authored page too — its source path or its output |
| 118 | 118 | path — though the source path is the one that survives a =#+SLUG:=. |
| 119 | 119 | |
| 120 | * [[pages]] | |
| 121 | ||
| 122 | Which layout a page renders through. Without any of these, every authored page uses | |
| 123 | =base.html=. | |
| 124 | ||
| 125 | #+BEGIN_SRC toml | |
| 126 | [[pages]] | |
| 127 | match = "blog" | |
| 128 | template = "post.html" | |
| 129 | #+END_SRC | |
| 130 | ||
| 131 | =match= is a *source* path relative to the source root — a directory, covering every page | |
| 132 | beneath it however deep, or one =.org= file. It is matched by path component, so =blog= | |
| 133 | covers =blog/2026/post.org= and does not touch =blogroll.org=. | |
| 134 | ||
| 135 | A section's layout is a property of the section, which is why this is a rule and not | |
| 136 | something you write in each file: a blog post carries the same byline and reply footer as | |
| 137 | every other one, and repeating that in 200 files means maintaining one fact 200 times. | |
| 138 | ||
| 139 | ** Which rule wins | |
| 140 | ||
| 141 | Most specific, by path depth — =blog/notes= beats =blog=, whatever order they appear in. | |
| 142 | An empty =match= covers the whole site, which is how you rename the default layout. | |
| 143 | ||
| 144 | A 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 | ||
| 151 | Naming a template that is not in the templates directory is an error that names the page, | |
| 152 | the template and what does exist — a layout typo should not be a hunt. | |
| 153 | ||
| 120 | 154 | * [templates] |
| 121 | 155 | |
| 122 | 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 | ||
| 35 | 35 | someone editing a layout should see the mistake, not output that looks like their edit |
| 36 | 36 | did 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 | |
| 41 | own layout, and =#+TEMPLATE:= gives one page its own: | |
| 42 | ||
| 43 | #+BEGIN_SRC toml | |
| 44 | [[pages]] | |
| 45 | match = "blog" | |
| 46 | template = "post.html" | |
| 47 | #+END_SRC | |
| 48 | ||
| 49 | #+BEGIN_SRC org | |
| 50 | ,#+TEMPLATE: wide.html | |
| 51 | #+END_SRC | |
| 52 | ||
| 53 | The page's own keyword wins over any rule, and the most specific rule wins over a broader | |
| 54 | one. 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 →</a></p> | |
| 61 | {% endblock %} | |
| 62 | #+END_SRC | |
| 63 | ||
| 64 | Full rules in [[file:02-configuration.org][Configuration]]. | |
| 65 | ||
| 38 | 66 | * Names are full filenames |
| 39 | 67 | |
| 40 | 68 | Templates are registered under their full relative filename: =base.html=, |
docs/guide/05-org-support.org +1
| @@ -118,6 +118,7 @@ broken. | ||
| 118 | 118 | | =#+FILETAGS:= | Tags, for grouping and =page.tags=. | |
| 119 | 119 | | =#+SLUG:= | Sets the output filename. | |
| 120 | 120 | | =#+DRAFT:= | Keeps the page out of the build. | |
| 121 | | =#+TEMPLATE:= | The layout this page renders through. | | |
| 121 | 122 | | =#+OPTIONS:= | Per-file export switches. | |
| 122 | 123 | | =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. | |
| 123 | 124 | |
src/config.rs +77
| @@ -34,6 +34,67 @@ pub struct Config { | ||
| 34 | 34 | /// Generated listing pages. Each produces one output file that has no source `.org` |
| 35 | 35 | /// file behind it — a blog index, an archive, a feed. |
| 36 | 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)] | |
| 50 | pub 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 | ||
| 59 | impl 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. | |
| 81 | pub 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 | 100 | /// A generated page that lists other pages. |
| @@ -390,6 +451,15 @@ impl Config { | ||
| 390 | 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 | 463 | if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') { |
| 394 | 464 | anyhow::bail!( |
| 395 | 465 | "site.base_url must not end with a slash (got {:?}) — URLs are joined \ |
| @@ -431,6 +501,13 @@ dir = "templates" | ||
| 431 | 501 | # archive. Costs incremental precision: with this on, adding a page re-renders the site. |
| 432 | 502 | expose_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 | 511 | [highlight] |
| 435 | 512 | # A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark, |
| 436 | 513 | # base16-eighties.dark, base16-mocha.dark, base16-ocean.light. |
src/main.rs +10 −3
| @@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf}; | ||
| 7 | 7 | use clap::{Parser, Subcommand}; |
| 8 | 8 | |
| 9 | 9 | use org_ssg::parser::parse; |
| 10 | use org_ssg::config::Config; | |
| 10 | use org_ssg::config::{self, Config}; | |
| 11 | 11 | use org_ssg::render::{self, render, Html, SyntectHighlighter}; |
| 12 | 12 | use org_ssg::resolve::ResolvedDoc; |
| 13 | 13 | use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; |
| @@ -323,9 +323,16 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | ||
| 323 | 323 | }; |
| 324 | 324 | let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); |
| 325 | 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 | 333 | let page = templater |
| 327 | .render_page(&ctx) | |
| 328 | .with_context(|| format!("templating {input}"))?; | |
| 334 | .render(&name, &ctx) | |
| 335 | .with_context(|| format!("templating {input} through {name}"))?; | |
| 329 | 336 | fs::write(output, page).with_context(|| format!("writing output file {output}"))?; |
| 330 | 337 | |
| 331 | 338 | let css = output.with_file_name(SYNTAX_STYLESHEET); |
src/site.rs +30 −2
| @@ -115,6 +115,9 @@ struct PagePrep { | ||
| 115 | 115 | diagnostics: Vec<Diagnostic>, |
| 116 | 116 | nav: Vec<NavItem>, |
| 117 | 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 | 123 | /// A generated page, resolved against the pages it lists. |
| @@ -677,6 +680,7 @@ fn prepare_pages( | ||
| 677 | 680 | |
| 678 | 681 | PagePrep { |
| 679 | 682 | context: page_context(doc, &output, config), |
| 683 | template: config::page_template(config, &doc.source_path, &doc.keywords), | |
| 680 | 684 | source: doc.source_path.clone(), |
| 681 | 685 | output, |
| 682 | 686 | title: page_title(doc), |
| @@ -694,6 +698,28 @@ fn prepare_pages( | ||
| 694 | 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. | |
| 706 | fn 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 | 723 | /// Parse + index + resolve + render + template a whole site *in memory*, without |
| 698 | 724 | /// touching the output directory. Shared by the tests (full render, every page). |
| 699 | 725 | pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { |
| @@ -702,6 +728,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 702 | 728 | let (preps, _symbols) = prepare_pages(src, &config, None)?; |
| 703 | 729 | let highlighter = SyntectHighlighter::new(); |
| 704 | 730 | let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; |
| 731 | check_page_templates(&templater, &preps)?; | |
| 705 | 732 | let site = site_context(&config); |
| 706 | 733 | let listing = page_listing(&config, &preps); |
| 707 | 734 | |
| @@ -770,8 +797,8 @@ fn render_page( | ||
| 770 | 797 | ctx.body = &fragment; |
| 771 | 798 | ctx.pages = pages; |
| 772 | 799 | templater |
| 773 | .render_page(&ctx) | |
| 774 | .with_context(|| format!("templating {}", p.source)) | |
| 800 | .render(&p.template, &ctx) | |
| 801 | .with_context(|| format!("templating {} through {}", p.source, p.template)) | |
| 775 | 802 | } |
| 776 | 803 | |
| 777 | 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 | 823 | let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; |
| 797 | 824 | |
| 798 | 825 | let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?; |
| 826 | check_page_templates(&templater, &preps)?; | |
| 799 | 827 | let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { |
| 800 | 828 | anyhow::anyhow!( |
| 801 | 829 | "unknown highlight.theme {:?}. Available: {}", |
tests/config.rs +215
| @@ -1913,3 +1913,218 @@ fn export_options_parse_as_org_writes_them() { | ||
| 1913 | 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. | |
| 1923 | fn 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] | |
| 1943 | fn 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] | |
| 1977 | fn 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] | |
| 1998 | fn 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] | |
| 2022 | fn 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] | |
| 2051 | fn 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] | |
| 2072 | fn 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] | |
| 2097 | fn 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] | |
| 2122 | fn 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 | } | |