krz/orgo

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

Commit 141ea8a99b

141ea8a99b9f4fc79a2b219173ee5bfee64d311b

parent: c8b514e1a0

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-30 16:15 UTC

Stop copying a previous build's output, and point at the new docs site

`excluded_dirs` drops the output directory from discovery only when it sits
inside the source, so building into the source (`-o _site`) and then previewing
elsewhere (`-o /tmp/preview`) copied the first site into the second: 2 assets
became 21 on orgo's own documentation, and the preview carried a stale nested
copy of the whole site.

Discovery now prunes any source directory holding an `.orgo-cache.json`. Every
build writes that manifest into its output, so it marks a directory as output
rather than content, and no new configuration is needed to say so. The
mirror-image case was already guarded: `collect_assets` refuses a `build.assets`
root that contains the output directory.

The documentation site moved to https://orgo.krz.sh, served from the repository's
pages branch. `homepage` and the README's 25 links follow it.

`is_excluded` and `is_hidden` had their doc comments interleaved by an earlier
edit, leaving `is_excluded` documented by a sentence fragment.

Closes #3

Layout: unified · split

Cargo.toml +1 −1
@@ -9,7 +9,7 @@ readme = "README.md"
9keywords = ["org-mode", "static-site", "ssg", "emacs", "blog"] 9keywords = ["org-mode", "static-site", "ssg", "emacs", "blog"]
10categories = ["command-line-utilities", "text-processing"] 10categories = ["command-line-utilities", "text-processing"]
11repository = "https://gitbay.org/krz/orgo" 11repository = "https://gitbay.org/krz/orgo"
12homepage = "https://krazywarez.github.io/orgo/" 12homepage = "https://orgo.krz.sh/"
13 13
14# The compiler floor. orgo's own code needs 1.82 (`Option::is_none_or`); the floor is 14# The compiler floor. orgo's own code needs 1.82 (`Option::is_none_or`); the floor is
15# 1.88 because dependencies in Cargo.lock declare it — `plist` and `time`, both by way of 15# 1.88 because dependencies in Cargo.lock declare it — `plist` and `time`, both by way of
README.md +25 −25
@@ -14,7 +14,7 @@ heading's TODO state and tags, `#+` keywords, ID links, captions on images. orgo
14of it, and its output is checked page by page against what Emacs' own exporter produces 14of it, and its output is checked page by page against what Emacs' own exporter produces
15from the same file. 15from the same file.
16 16
17**Documentation: <https://krazywarez.github.io/orgo/>** — that site is written in org and 17**Documentation: <https://orgo.krz.sh/>** — that site is written in org and
18built by orgo, so it doubles as the longest worked example available. 18built by orgo, so it doubles as the longest worked example available.
19 19
20## Install 20## Install
@@ -35,7 +35,7 @@ cargo install --path .
35``` 35```
36 36
37Either way you get an `orgo` command on your `PATH`. Full notes, including how to run it 37Either way you get an `orgo` command on your `PATH`. Full notes, including how to run it
38without installing anything: <https://krazywarez.github.io/orgo/install.html> 38without installing anything: <https://orgo.krz.sh/install.html>
39 39
40## Your First Site 40## Your First Site
41 41
@@ -76,15 +76,15 @@ Each of these is a few lines of config, and each has a page in the guide:
76 76
77| Add | Documented in | 77| Add | Documented in |
78|---|---| 78|---|---|
79| A blog index, newest first | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | 79| A blog index, newest first | [Collections](https://orgo.krz.sh/guide/03-collections.html) |
80| Tag pages, and an index of tags | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | 80| Tag pages, and an index of tags | [Collections](https://orgo.krz.sh/guide/03-collections.html) |
81| An RSS feed | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | 81| An RSS feed | [Collections](https://orgo.krz.sh/guide/03-collections.html) |
82| Numbered pages when a list gets long | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | 82| Numbered pages when a list gets long | [Collections](https://orgo.krz.sh/guide/03-collections.html) |
83| A built-in theme for a blog, a wiki or a doc site | [Configuration](https://krazywarez.github.io/orgo/guide/02-configuration.html) | 83| A built-in theme for a blog, a wiki or a doc site | [Configuration](https://orgo.krz.sh/guide/02-configuration.html) |
84| Your own design, in ordinary HTML templates | [Templates](https://krazywarez.github.io/orgo/guide/04-templates.html) | 84| Your own design, in ordinary HTML templates | [Templates](https://orgo.krz.sh/guide/04-templates.html) |
85| Drafts that stay unpublished until you say so | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) | 85| Drafts that stay unpublished until you say so | [Authoring](https://orgo.krz.sh/guide/06-authoring.html) |
86| A table of contents on long posts | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) | 86| A table of contents on long posts | [Authoring](https://orgo.krz.sh/guide/06-authoring.html) |
87| Clean URLs that survive a renamed file | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) | 87| Clean URLs that survive a renamed file | [Authoring](https://orgo.krz.sh/guide/06-authoring.html) |
88 88
89Rebuilds only touch the pages that actually changed, so saving a post on a site with 89Rebuilds only touch the pages that actually changed, so saving a post on a site with
90hundreds of them stays instant. 90hundreds of them stays instant.
@@ -110,28 +110,28 @@ Read the numbers with three things in mind. The weblorg figures include Emacs st
110loading its packages, which you pay on every publish and cannot avoid. orgo emits 13 pages 110loading its packages, which you pay on every publish and cannot avoid. orgo emits 13 pages
111weblorg does not, one per tag, so it is doing slightly more work. And the two do not 111weblorg does not, one per tag, so it is doing slightly more work. And the two do not
112produce byte-identical output — the differences are deliberate and listed under 112produce byte-identical output — the differences are deliberate and listed under
113[Org support](https://krazywarez.github.io/orgo/guide/05-org-support.html). 113[Org support](https://orgo.krz.sh/guide/05-org-support.html).
114 114
115Apple M2 Pro, 12 cores, macOS 26.6, Emacs 30.2, orgo built with `--release`. 115Apple M2 Pro, 12 cores, macOS 26.6, Emacs 30.2, orgo built with `--release`.
116 116
117## Docs 117## Docs
118 118
119<https://krazywarez.github.io/orgo/> 119<https://orgo.krz.sh/>
120 120
121| Page | What is in it | 121| Page | What is in it |
122|---|---| 122|---|---|
123| [Quick start](https://krazywarez.github.io/orgo/quickstart.html) | A working site in two commands, then your own writing, then your own design. | 123| [Quick start](https://orgo.krz.sh/quickstart.html) | A working site in two commands, then your own writing, then your own design. |
124| [Install](https://krazywarez.github.io/orgo/install.html) | Getting the binary, and running it without installing anything. | 124| [Install](https://orgo.krz.sh/install.html) | Getting the binary, and running it without installing anything. |
125| [Commands](https://krazywarez.github.io/orgo/guide/01-cli.html) | Every command and flag, and what each is for. | 125| [Commands](https://orgo.krz.sh/guide/01-cli.html) | Every command and flag, and what each is for. |
126| [Configuration](https://krazywarez.github.io/orgo/guide/02-configuration.html) | Every setting in `orgo.toml`, what it changes, and what it costs. | 126| [Configuration](https://orgo.krz.sh/guide/02-configuration.html) | Every setting in `orgo.toml`, what it changes, and what it costs. |
127| [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | Blog indexes, tag pages, pagination and RSS feeds. | 127| [Collections](https://orgo.krz.sh/guide/03-collections.html) | Blog indexes, tag pages, pagination and RSS feeds. |
128| [Templates](https://krazywarez.github.io/orgo/guide/04-templates.html) | Layouts, and every variable a template can use. | 128| [Templates](https://orgo.krz.sh/guide/04-templates.html) | Layouts, and every variable a template can use. |
129| [Org support](https://krazywarez.github.io/orgo/guide/05-org-support.html) | Which org syntax is handled, which is not, and how the rest degrades. | 129| [Org support](https://orgo.krz.sh/guide/05-org-support.html) | Which org syntax is handled, which is not, and how the rest degrades. |
130| [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) | URLs, drafts, excerpts, tables of contents. | 130| [Authoring](https://orgo.krz.sh/guide/06-authoring.html) | URLs, drafts, excerpts, tables of contents. |
131| [Incremental builds](https://krazywarez.github.io/orgo/guide/07-incremental.html) | How it decides what to rebuild. | 131| [Incremental builds](https://orgo.krz.sh/guide/07-incremental.html) | How it decides what to rebuild. |
132| [Watching and serving](https://krazywarez.github.io/orgo/guide/08-workflow.html) | The write-save-see loop. | 132| [Watching and serving](https://orgo.krz.sh/guide/08-workflow.html) | The write-save-see loop. |
133| [Auditing](https://krazywarez.github.io/orgo/guide/09-auditing.html) | Reading a corpus before trusting a tool with it. | 133| [Auditing](https://orgo.krz.sh/guide/09-auditing.html) | Reading a corpus before trusting a tool with it. |
134| [Deploying](https://krazywarez.github.io/orgo/guide/10-deploying.html) | Producing a production build, and putting it somewhere. | 134| [Deploying](https://orgo.krz.sh/guide/10-deploying.html) | Producing a production build, and putting it somewhere. |
135 135
136## Building 136## Building
137 137
src/site.rs +20 −7
@@ -1332,7 +1332,10 @@ fn discover(
1332 }; 1332 };
1333 let rel = path.strip_prefix(src).unwrap_or(path); 1333 let rel = path.strip_prefix(src).unwrap_or(path);
1334 // The source root itself always passes; `filter_entry` prunes whole subtrees. 1334 // The source root itself always passes; `filter_entry` prunes whole subtrees.
1335 rel.as_str().is_empty() || !is_excluded(rel, &skip_dirs) 1335 if rel.as_str().is_empty() {
1336 return true;
1337 }
1338 !is_excluded(rel, &skip_dirs) && !is_build_output(e)
1336 }) { 1339 }) {
1337 let entry = entry.with_context(|| format!("walking {src}"))?; 1340 let entry = entry.with_context(|| format!("walking {src}"))?;
1338 if !entry.file_type().is_file() { 1341 if !entry.file_type().is_file() {
@@ -1479,9 +1482,6 @@ fn excluded_dirs(src: &Utf8Path, config: &Config, out: Option<&Utf8Path>) -> Vec
1479 dirs 1482 dirs
1480} 1483}
1481 1484
1482/// Is this source-relative path excluded from discovery?
1483///
1484/// Dot-entries are skipped wholesale. That is the conventional rule for site generators,
1485/// Is this path component a dot-entry that must not be published? 1485/// Is this path component a dot-entry that must not be published?
1486/// 1486///
1487/// Dot-directories are excluded because a source directory is very often a git repository, 1487/// Dot-directories are excluded because a source directory is very often a git repository,
@@ -1499,9 +1499,22 @@ fn is_hidden(component: &str) -> bool {
1499/// The one dot-directory the web expects to be published. 1499/// The one dot-directory the web expects to be published.
1500const WELL_KNOWN: &str = ".well-known"; 1500const WELL_KNOWN: &str = ".well-known";
1501 1501
1502/// and the reason is safety rather than tidiness: a source directory is very often a git 1502/// Is this directory a site orgo built earlier?
1503/// repository, and publishing `.git` — or `.env` — is a way to leak a project's entire 1503///
1504/// history alongside its homepage. 1504/// Every build writes `.orgo-cache.json` into its output directory, so a directory in the
1505/// source carrying one is output rather than content someone wrote. `excluded_dirs` only
1506/// covers an output directory nested in the source; without this, building into the
1507/// source (`-o _site`) and then previewing elsewhere (`-o /tmp/preview`) copies the whole
1508/// first site into the second, one asset at a time.
1509fn is_build_output(entry: &walkdir::DirEntry) -> bool {
1510 entry.file_type().is_dir() && entry.path().join(".orgo-cache.json").is_file()
1511}
1512
1513/// Is this source-relative path excluded from discovery?
1514///
1515/// Dot-entries are skipped wholesale, and the reason is safety rather than tidiness: a
1516/// source directory is very often a git repository, and publishing `.git` — or `.env` —
1517/// is a way to leak a project's entire history alongside its homepage.
1505fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool { 1518fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool {
1506 if rel.components().any(|c| is_hidden(c.as_str())) { 1519 if rel.components().any(|c| is_hidden(c.as_str())) {
1507 return true; 1520 return true;
tests/incremental.rs +29
@@ -513,3 +513,32 @@ fn editing_one_template_rebuilds_only_the_pages_that_use_it() {
513 r.rendered 513 r.rendered
514 ); 514 );
515} 515}
516
517/// A previous build's output, left in the source, is not content. Building into the
518/// source and then previewing elsewhere used to copy the whole first site into the
519/// second — 2 assets became 21 on orgo's own documentation.
520#[test]
521fn a_previous_build_output_is_not_copied_as_assets() {
522 let src = tmpdir("prev-output-src");
523 write(&src, "index.org", "#+TITLE: Home\n\nHome.\n");
524 write(&src, "logo.svg", "<svg/>");
525
526 // Build into the source, as docs/guide/10-deploying.org does.
527 let nested = src.join("_site");
528 build_site(&src, &nested, &BuildOptions::default()).unwrap();
529 assert!(manifest_path(&nested).exists(), "the build wrote its cache manifest");
530
531 // Then preview elsewhere, as docs/guide/09-auditing.org does.
532 let preview = tmpdir("prev-output-preview");
533 let r = build_site(&src, &preview, &BuildOptions::default()).unwrap();
534
535 assert_eq!(
536 r.assets,
537 vec![Utf8PathBuf::from("logo.svg")],
538 "only the real asset is copied, not the earlier build's output"
539 );
540 assert!(
541 !preview.join("_site").exists(),
542 "the earlier site must not be nested inside the new one"
543 );
544}