krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
71e9f793b93278fb4119b5b36d71693d41858a51
unsigned
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T23:33:25Z
README.md | 3 +- docs/guide/02-configuration.org | 12 ++++++ docs/guide/10-deploying.org | 12 ++++++ src/config.rs | 22 ++++++++++- src/site.rs | 57 +++++++++++++++++++++++++++ tests/config.rs | 85 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) @@ -94,7 +94,8 @@ each, measured back to back on one machine: That middle row is the interesting one. weblorg alone does not group a blog index by year, write a tags page, rewrite image URLs, minify CSS or emit a sitemap — so I -wrote ~600 lines of Python to do those on top of it. orgo does the first three natively. +wrote ~600 lines of Python to do those on top of it. orgo does four of the five natively — +the sitemap included, since writing this table is what prompted it. Read the numbers with three things in mind. The weblorg figures include Emacs starting and loading its packages, which you pay on every publish and cannot avoid. orgo emits 13 pages @@ -34,6 +34,7 @@ syntaxes_dir = "syntaxes" [build] drafts = false assets = [] +sitemap = true [html] heading_offset = 1 @@ -192,10 +193,21 @@ should not stop a site from building. |-----+---------+---------| | =drafts= | =false= | Include pages marked =#+DRAFT:=. | | =assets= | =[]= | Extra directories copied to the *site root*. | +| =sitemap= | =true= | Write =sitemap.xml=. Needs =site.base_url=. | =--drafts= on the command line turns this on for one run. The flag can only turn drafts on; it never turns off a config that asked for them. +** sitemap.xml + +Every page the build emits, generated ones included — a crawler has no other way to learn +that =/blog/= exists. =lastmod= is the page's own =#+DATE:= where it has one, and absent +where it does not: a filesystem timestamp would say the day you cloned the repository. + +*Nothing is written until =site.base_url= is set.* A sitemap has nowhere to put a relative +URL, so a zero-config build produces no sitemap rather than an invalid one. Set a base URL +and it appears; set =sitemap = false= and it does not. + ** Static files that live elsewhere A site's static files do not always sit where its writing does. weblorg publishes @@ -113,6 +113,18 @@ Both zeros matter. Unresolved links are internal links pointing at nothing; diag are malformed org that degraded rather than failing. With =--strict= neither can reach this line, because either would have failed the build. +* Telling a search engine where things are + +A build with =site.base_url= set writes =sitemap.xml= at the site root, listing every +page. Point a =robots.txt= at it if you want one: + +#+BEGIN_EXAMPLE +Sitemap: https://example.com/sitemap.xml +#+END_EXAMPLE + +=robots.txt= is an ordinary file — put it beside your org files, or in a directory named +by =[build] assets=, and it is copied through. + * After an upgrade The first build on a new version is worth running with =--no-cache=, so you compare the @@ -200,7 +200,7 @@ pub enum SortOrder { Asc, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Build { /// Include pages marked `#+DRAFT:` in the build. @@ -217,6 +217,23 @@ pub struct Build { /// does: weblorg publishes `theme/static/` to `/`, and a repository migrating from it /// should not have to move `robots.txt` next to its blog posts to keep the URL. pub assets: Vec<Utf8PathBuf>, + /// Write `sitemap.xml` listing every published page. + /// + /// On, but a sitemap requires absolute URLs — the format has nowhere to put a + /// relative one — so nothing is written until `site.base_url` is set. That is why a + /// zero-config build produces no sitemap and no complaint: there is no URL to give a + /// search engine yet. + pub sitemap: bool, +} + +impl Default for Build { + fn default() -> Self { + Build { + drafts: false, + assets: Vec::new(), + sitemap: true, + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -585,6 +602,9 @@ drafts = false # source directory. `assets = ["../theme/static"]` publishes that directory's contents at # `/`, not at `/static/`. assets = [] +# Write sitemap.xml. Needs site.base_url — a sitemap has nowhere to put a relative URL — +# so nothing is written until you set one. +sitemap = true [html] # How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>. @@ -893,6 +893,50 @@ fn render_page( /// Site-root-relative name of the generated syntax stylesheet. Every page links to it. pub const SYNTAX_STYLESHEET: &str = "syntax.css"; +/// Site-root-relative name of the generated sitemap. +pub const SITEMAP: &str = "sitemap.xml"; + +/// `sitemap.xml` for every HTML page in `pages`, in URL order. +/// +/// Only HTML: a sitemap is a list of pages for a crawler to read, and a feed or a +/// stylesheet is neither. `lastmod` is the page's own `#+DATE:` where it has one — the +/// nearest honest thing available without trusting a filesystem timestamp that a fresh +/// clone would reset. +fn sitemap(base_url: &str, pages: &[Utf8PathBuf], dated: &HashMap<&Utf8Path, &str>) -> String { + let mut urls: Vec<&Utf8PathBuf> = pages + .iter() + .filter(|p| p.extension() == Some("html")) + .collect(); + urls.sort(); + urls.dedup(); + + let base = base_url.trim_end_matches('/'); + let mut out = String::from( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\ + <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n", + ); + for url in urls { + out.push_str("<url>\n"); + out.push_str(&format!("<loc>{base}/{}</loc>\n", escape_xml(url.as_str()))); + if let Some(date) = dated.get(url.as_path()) { + out.push_str(&format!("<lastmod>{date}</lastmod>\n")); + } + out.push_str("</url>\n"); + } + out.push_str("</urlset>\n"); + out +} + +/// The five XML predefined entities. A `&` in a URL is the common one, from a query +/// string that survived into a filename. +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + /// Full site build with the incremental layer (spec §4). Renders only the pages whose /// `render_key` changed or that link into a changed file's targets; reuses the on-disk /// output of everything else; persists an updated cache manifest. @@ -1123,6 +1167,19 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css) .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; + // A sitemap covers every page the build emits, authored and generated alike, so it is + // written here rather than declared as a collection: a collection lists the pages it + // was pointed at, and this one has to know about all of them including itself. + if cfg.build.sitemap && !cfg.site.base_url.is_empty() { + let dated: HashMap<&Utf8Path, &str> = preps + .iter() + .filter_map(|p| Some((p.output.as_path(), p.context.date_iso.as_deref()?))) + .collect(); + let xml = sitemap(&cfg.site.base_url, &report.pages, &dated); + fs::write(out.join(SITEMAP), xml) + .with_context(|| format!("writing {SITEMAP} under {out}"))?; + } + // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the // full-vs-incremental byte equivalence trivially true for non-`.org` files. for asset in &assets { @@ -2378,3 +2378,88 @@ fn entries_carry_no_content_unless_asked() { let html = page(&out, "blog/index.html"); assert!(!html.contains("false"), "no entry carries a body:\n{html}"); } + +// --------------------------------------------------------------------------- +// Sitemap +// --------------------------------------------------------------------------- + +fn sitemap_site(src: &Utf8PathBuf, config: &str) { + std::fs::create_dir_all(src.join("blog")).unwrap(); + std::fs::create_dir_all(src.join("templates")).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap(); + std::fs::write( + src.join("blog/post.org"), + "#+TITLE: Post\n#+DATE: <2026-01-15 Thu>\n\nBody.\n", + ) + .unwrap(); + std::fs::write(src.join("blog/undated.org"), "#+TITLE: Undated\n\nBody.\n").unwrap(); + std::fs::write(src.join("style.css"), "body{}").unwrap(); + std::fs::write( + src.join("templates/list.html"), + "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>", + ) + .unwrap(); + std::fs::write(src.join("orgo.toml"), config).unwrap(); +} + +/// A sitemap covers every page the build emits, generated ones included — a crawler has no +/// other way to learn that `/blog/` exists. +#[test] +fn a_sitemap_lists_every_page_including_generated_ones() { + let root = tmpdir("sitemap"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + sitemap_site( + &src, + "[site]\nbase_url = \"https://example.com\"\n\n\ + [[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\ + template = \"list.html\"\ntitle = \"Blog\"\n", + ); + let out = root.join("out"); + build(&src, &out); + + let xml = page(&out, "sitemap.xml"); + for url in [ + "https://example.com/index.html", + "https://example.com/blog/post.html", + "https://example.com/blog/index.html", + ] { + assert!(xml.contains(url), "{url} is in the sitemap:\n{xml}"); + } + // A date the author wrote is the only honest `lastmod` available; a page without one + // gets no element rather than a filesystem timestamp a fresh clone would reset. + assert!(xml.contains("<lastmod>2026-01-15</lastmod>"), "{xml}"); + assert_eq!(xml.matches("<lastmod>").count(), 1, "only the dated page:\n{xml}"); + // Assets and the stylesheet are not pages. + assert!(!xml.contains("style.css") && !xml.contains("syntax.css"), "{xml}"); +} + +/// A sitemap has nowhere to put a relative URL, so without a base URL there is nothing +/// honest to write — and a build with no `base_url` set is the zero-config default. +#[test] +fn no_base_url_means_no_sitemap() { + let root = tmpdir("sitemapnobase"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + sitemap_site(&src, ""); + let out = root.join("out"); + build(&src, &out); + + assert!(!out.join("sitemap.xml").exists(), "no base_url, no sitemap"); +} + +/// And it can be turned off outright. +#[test] +fn the_sitemap_can_be_disabled() { + let root = tmpdir("sitemapoff"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + sitemap_site( + &src, + "[site]\nbase_url = \"https://example.com\"\n\n[build]\nsitemap = false\n", + ); + let out = root.join("out"); + build(&src, &out); + + assert!(!out.join("sitemap.xml").exists(), "disabled means absent"); +}