krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
1//! Site build: walk a source directory, PARSE every `.org` file, INDEX their targets,
2//! then RESOLVE + RENDER + TEMPLATE each page into a linked static site, copying
3//! non-`.org` assets through unchanged (spec §2.1 DISCOVER…EMIT).
4//!
5//! v0.3 wires in the incremental layer (spec §4, [`crate::incremental`]): a persisted
6//! cache manifest lets a rebuild re-render only the pages whose composed `render_key`
7//! changed, plus the pages that *link into* a changed file's targets (the dependency
8//! graph, spec §4.3). Unchanged pages keep their existing on-disk output untouched.
9//! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a
10//! full rebuild and an incremental rebuild produce byte-identical output.
11
12use std::collections::{HashMap, HashSet};
13use std::fs;
14
15use anyhow::{Context, Result};
16use camino::{Utf8Path, Utf8PathBuf};
17use rayon::prelude::*;
18use walkdir::WalkDir;
19
20use crate::incremental::{
21 self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash,
22 site_structure_hash_ordered, template_hash, DepGraph, Hash, Manifest, PageRecord,
23 CACHE_FORMAT_VERSION,
24};
25use crate::index::{document_targets, SymbolTable, TargetId};
26use crate::model::{ContentHash, Diagnostic, Document};
27use crate::parser::parse;
28use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
29use crate::resolve::resolve;
30use crate::config::{self, Config, NavMode, SortKey, SortOrder};
31use crate::template::{
32 GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext,
33 Templater,
34};
35use crate::util::{
36 document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled,
37 output_path, output_url,
38 relative_root, slugify, table_of_contents,
39};
40
41/// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure
42/// for prose on screen.
43const WORDS_PER_MINUTE: usize = 200;
44
45/// A fully built page: source and output paths (relative to their roots) and its
46/// final templated HTML.
47#[derive(Debug, Clone)]
48pub struct BuiltPage {
49 pub source: Utf8PathBuf,
50 pub output: Utf8PathBuf,
51 pub title: String,
52 pub html: String,
53}
54
55/// Unresolved internal links found during a build: `(page, target)` (spec §4.3.4).
56pub type BrokenLinks = Vec<(Utf8PathBuf, TargetId)>;
57
58/// Options controlling a site build.
59#[derive(Debug, Clone, Default)]
60pub struct BuildOptions {
61 /// Bypass the incremental cache and re-render every page (spec §4.5).
62 pub no_cache: bool,
63 /// Treat broken internal links as a build error rather than a warning (spec §4.3.4).
64 pub strict: bool,
65 /// Explicit config file, overriding `orgo.toml` in the source directory.
66 pub config_path: Option<Utf8PathBuf>,
67 /// Include pages marked `#+DRAFT:`, overriding `build.drafts` when set.
68 pub drafts: bool,
69}
70
71/// Summary of a site build.
72#[derive(Debug, Default)]
73pub struct SiteReport {
74 /// Every output page (rendered this build or reused from cache).
75 pub pages: Vec<Utf8PathBuf>,
76 /// Pages actually re-rendered and written this build (the invalidation set).
77 pub rendered: Vec<Utf8PathBuf>,
78 /// Pages whose existing on-disk output was reused unchanged (spec §4.1 skip rule).
79 pub skipped: Vec<Utf8PathBuf>,
80 pub assets: Vec<Utf8PathBuf>,
81 /// Unresolved internal links: `(page, target)`. Warnings, not failures (spec §4.3.4).
82 pub broken: Vec<(Utf8PathBuf, TargetId)>,
83 /// Parse diagnostics: `(source file, diagnostic)`, in file then line order.
84 pub diagnostics: Vec<(Utf8PathBuf, Diagnostic)>,
85}
86
87impl SiteReport {
88 /// Every diagnostic and broken link, formatted one per line as
89 /// `file:line: message` — the form an editor can jump to.
90 pub fn warnings(&self) -> Vec<String> {
91 let mut out: Vec<String> = self
92 .diagnostics
93 .iter()
94 .map(|(path, d)| format!("{path}:{}: {}", d.line, d.message))
95 .collect();
96 out.extend(
97 self.broken
98 .iter()
99 .map(|(page, target)| format!("{page}: unresolved link {target}")),
100 );
101 out
102 }
103}
104
105/// Everything a build needs about one page *before* the decision to render it: its
106/// hashes, its resolved element tree, and the dependency edges it participates in.
107struct PagePrep {
108 source: Utf8PathBuf,
109 output: Utf8PathBuf,
110 title: String,
111 content_hash: ContentHash,
112 resolved: crate::resolve::ResolvedDoc,
113 used: HashSet<TargetId>,
114 defines: HashSet<TargetId>,
115 broken: Vec<TargetId>,
116 diagnostics: Vec<Diagnostic>,
117 nav: Vec<NavItem>,
118 context: PageContext,
119 /// The layout this page renders through: `#+TEMPLATE:`, a `[[pages]]` rule, or
120 /// `base.html` (see [`config::page_template`]).
121 template: String,
122}
123
124/// A generated page, resolved against the pages it lists.
125struct Listing {
126 output: Utf8PathBuf,
127 template: String,
128 title: String,
129 /// The pages it lists, already sorted. Empty for a group index, which lists groups.
130 entries: Vec<PageContext>,
131 /// The group this page is for, when it belongs to a grouped collection.
132 group: Option<GroupContext>,
133 /// Every group of the owning collection. The content of a group index, and context
134 /// for a group page.
135 groups: Vec<GroupContext>,
136 /// Set when this is one page of a paginated listing.
137 paginator: Option<Paginator>,
138 /// Render each entry's body into `entry.content` (see
139 /// [`Collection::include_content`](crate::config::Collection::include_content)).
140 include_content: bool,
141 /// Content hash of each entry's source, in `entries` order. Not shown to templates —
142 /// it is how a content-carrying listing notices that a body it embeds has changed,
143 /// without rendering every body to find out.
144 entry_hashes: Vec<ContentHash>,
145}
146
147/// Split one listing's entries across numbered pages, appending each as its own
148/// [`Listing`].
149///
150/// Page 1 keeps `output`, so a section's canonical URL never moves as its page count
151/// changes — only pages 2..N are named by `paginate_output`. An empty listing still
152/// emits page 1, because a section that exists but has nothing in it should be a page
153/// saying so rather than a 404.
154#[allow(clippy::too_many_arguments)]
155fn push_paginated(
156 listings: &mut Vec<Listing>,
157 collection: &config::Collection,
158 output: Utf8PathBuf,
159 title: String,
160 entries: Vec<PageContext>,
161 entry_hashes: Vec<ContentHash>,
162 group: Option<GroupContext>,
163 groups: Vec<GroupContext>,
164) {
165 let per_page = collection.paginate;
166 if per_page == 0 {
167 listings.push(Listing {
168 output,
169 template: collection.template.clone(),
170 title,
171 entries,
172 group,
173 groups,
174 paginator: None,
175 include_content: collection.include_content,
176 entry_hashes: entry_hashes.clone(),
177 });
178 return;
179 }
180
181 let total_entries = entries.len();
182 let total = entries.len().div_ceil(per_page).max(1);
183 let slug = group.as_ref().map(|g| g.slug.clone()).unwrap_or_default();
184 let page_output = |n: usize| -> Utf8PathBuf {
185 if n == 1 {
186 return output.clone();
187 }
188 Utf8PathBuf::from(
189 collection
190 .paginate_output
191 .as_str()
192 .replace(config::GROUP_PLACEHOLDER, &slug)
193 .replace(config::PAGE_PLACEHOLDER, &n.to_string()),
194 )
195 };
196 let outputs: Vec<Utf8PathBuf> = (1..=total).map(page_output).collect();
197
198 for (idx, chunk) in entries.chunks(per_page).chain(
199 // `chunks` yields nothing for an empty slice; page 1 still has to exist.
200 std::iter::once(&[][..]).take(usize::from(total_entries == 0)),
201 ) .enumerate()
202 {
203 let current = idx + 1;
204 let here = &outputs[idx];
205 let url_to = |n: usize| output_url(here, &outputs[n - 1], None);
206 listings.push(Listing {
207 output: here.clone(),
208 template: collection.template.clone(),
209 include_content: collection.include_content,
210 entry_hashes: entry_hashes.clone(),
211 title: title.clone(),
212 entries: chunk.to_vec(),
213 group: group.clone(),
214 groups: groups.clone(),
215 paginator: Some(Paginator {
216 current,
217 total,
218 per_page,
219 total_entries,
220 prev_url: (current > 1).then(|| url_to(current - 1)),
221 next_url: (current < total).then(|| url_to(current + 1)),
222 first_url: url_to(1),
223 last_url: url_to(total),
224 pages: (1..=total)
225 .map(|n| PaginatorPage {
226 number: n,
227 url: url_to(n),
228 current: n == current,
229 })
230 .collect(),
231 }),
232 });
233 }
234}
235
236/// Build the listing pages a config asks for, each with its entries sorted.
237fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
238 let mut listings = Vec::new();
239 let hashes: HashMap<&str, ContentHash> = preps
240 .iter()
241 .map(|p| (p.source.as_str(), p.content_hash))
242 .collect();
243 let hashes_of = |entries: &[PageContext]| -> Vec<ContentHash> {
244 entries
245 .iter()
246 .filter_map(|e| hashes.get(e.source.as_str()).copied())
247 .collect()
248 };
249 for collection in &config.collections {
250 let mut entries: Vec<PageContext> = preps
251 .iter()
252 .filter(|p| {
253 collection.source.as_str().is_empty() || p.source.starts_with(&collection.source)
254 })
255 .map(|p| p.context.clone())
256 .collect();
257
258 // Sort ascending first, then reverse for `desc`, so the two orders are exact
259 // mirrors of one another rather than two separately-written comparisons.
260 match collection.sort {
261 SortKey::Title => entries.sort_by(|a, b| a.title.cmp(&b.title)),
262 SortKey::Path => entries.sort_by(|a, b| a.url.cmp(&b.url)),
263 // Undated pages sort last in the final order regardless of direction: a
264 // draft with no date should not lead an archive.
265 SortKey::Date => entries.sort_by(|a, b| {
266 // Date *and* time: org records when a note was written, and two notes
267 // from the same day have an order that the day alone cannot express.
268 let key = |p: &PageContext| {
269 p.date_iso.as_ref().map(|d| {
270 let time = p
271 .date
272 .as_deref()
273 .and_then(iso_time)
274 .unwrap_or_else(|| "00:00:00".to_string());
275 format!("{d}T{time}")
276 })
277 };
278 match (key(a), key(b)) {
279 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
280 (Some(_), None) => std::cmp::Ordering::Greater,
281 (None, Some(_)) => std::cmp::Ordering::Less,
282 (None, None) => a.url.cmp(&b.url),
283 }
284 }),
285 }
286 if collection.order == SortOrder::Desc {
287 entries.reverse();
288 }
289
290 if collection.group_by.is_empty() {
291 let entry_hashes = hashes_of(&entries);
292 push_paginated(
293 &mut listings,
294 collection,
295 collection.output.clone(),
296 collection.title.clone(),
297 entries,
298 entry_hashes,
299 None,
300 Vec::new(),
301 );
302 continue;
303 }
304
305 // Grouped: one page per distinct term. `entries` is already sorted, and grouping
306 // preserves that order within each group.
307 let mut terms: Vec<String> = Vec::new();
308 let mut members: HashMap<String, Vec<PageContext>> = HashMap::new();
309 for entry in &entries {
310 for term in group_terms(entry, &collection.group_by) {
311 if !members.contains_key(&term) {
312 terms.push(term.clone());
313 }
314 members.entry(term).or_default().push(entry.clone());
315 }
316 }
317 // Terms are discovered in page order, which is arbitrary from a reader's point of
318 // view; sort so a tag index reads alphabetically and hashes deterministically.
319 terms.sort();
320
321 let mut groups: Vec<GroupContext> = Vec::new();
322 let mut slugs: HashMap<String, String> = HashMap::new();
323 for term in &terms {
324 let slug = slugify(term);
325 if slug.is_empty() {
326 anyhow::bail!(
327 "the {} value {term:?} has no URL-safe form; it cannot name a page",
328 collection.group_by
329 );
330 }
331 // `C++` and `C ++` both slugify to `c`, and one would silently overwrite the
332 // other's page.
333 if let Some(other) = slugs.insert(slug.clone(), term.clone()) {
334 anyhow::bail!(
335 "the {} values {other:?} and {term:?} both become {slug:?} in a URL; \
336 rename one so their pages do not collide",
337 collection.group_by
338 );
339 }
340 groups.push(GroupContext {
341 name: term.clone(),
342 slug: slug.clone(),
343 url: if collection.output.as_str().is_empty() {
344 String::new()
345 } else {
346 collection
347 .output
348 .as_str()
349 .replace(config::GROUP_PLACEHOLDER, &slug)
350 }
351 .to_string(),
352 count: members.get(term).map(Vec::len).unwrap_or(0),
353 });
354 }
355
356 if !collection.output.as_str().is_empty() {
357 for group in &groups {
358 let members_of = members.get(&group.name).cloned().unwrap_or_default();
359 let entry_hashes = hashes_of(&members_of);
360 push_paginated(
361 &mut listings,
362 collection,
363 Utf8PathBuf::from(&group.url),
364 collection
365 .title
366 .replace(config::GROUP_PLACEHOLDER, &group.name),
367 members_of,
368 entry_hashes,
369 Some(group.clone()),
370 // Deliberately not the whole group list. A page that can see every
371 // group depends on every group, so one new post would re-render every
372 // tag page — cost that scales with tag count, to support a tag cloud
373 // nobody has asked for. A tag page depends on its own posts, and the
374 // group index is where the group list belongs.
375 Vec::new(),
376 );
377 }
378 }
379 if !collection.index_output.as_str().is_empty() {
380 listings.push(Listing {
381 output: collection.index_output.clone(),
382 template: collection.index_template.clone(),
383 title: collection.index_title.clone(),
384 entries: Vec::new(),
385 group: None,
386 groups: groups.clone(),
387 paginator: None,
388 // A group index lists groups, not pages; there are no bodies to carry.
389 include_content: false,
390 entry_hashes: Vec::new(),
391 });
392 }
393 }
394
395 // A generated page writing over a real page would silently replace it. Group pages
396 // make this easy to hit by accident, since their paths come from content.
397 for listing in &listings {
398 if let Some(clash) = preps.iter().find(|p| p.output == listing.output) {
399 anyhow::bail!(
400 "collection output {} collides with the page built from {}",
401 listing.output,
402 clash.source
403 );
404 }
405 }
406 let mut claimed: HashMap<&Utf8PathBuf, ()> = HashMap::new();
407 for listing in &listings {
408 if claimed.insert(&listing.output, ()).is_some() {
409 anyhow::bail!("two generated pages both write to {}", listing.output);
410 }
411 }
412 Ok(listings)
413}
414
415/// The `(output, title)` a collection contributes to the nav. A grouped collection
416/// offers its index; an ungrouped one offers its single page.
417fn nav_target(collection: &config::Collection) -> (Utf8PathBuf, String) {
418 if !collection.group_by.is_empty() {
419 return (
420 collection.index_output.clone(),
421 collection.index_title.clone(),
422 );
423 }
424 (collection.output.clone(), collection.title.clone())
425}
426
427/// The group terms a page belongs to. `tags` is multi-valued — a page appears under
428/// every tag it carries — while any other key names a single-valued `#+KEYWORD:`.
429fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> {
430 if group_by.eq_ignore_ascii_case("tags") {
431 return page.tags.clone();
432 }
433 page.keywords
434 .get(&group_by.to_lowercase())
435 .map(|v| v.trim())
436 .filter(|v| !v.is_empty())
437 .map(|v| vec![v.to_string()])
438 .unwrap_or_default()
439}
440
441/// Everything a listing template can see about its entries, hashed. This is the listing
442/// page's whole dependency: if none of these change, its output cannot have changed.
443///
444/// Entries are hashed through their *serialization* rather than a hand-picked set of
445/// fields. Picking fields means the hash drifts from what a template can read the moment
446/// one is added — which it had: the excerpt was missing, so rewriting a post's first
447/// paragraph left the old excerpt on the index until something else invalidated it.
448fn listing_entries_hash(listing: &Listing) -> Hash {
449 let mut fields: Vec<(String, String)> = listing
450 .entries
451 .iter()
452 .map(|e| {
453 (
454 e.url.clone(),
455 serde_json::to_string(e).unwrap_or_else(|_| e.title.clone()),
456 )
457 })
458 // A group index has no entries at all — its content *is* the group list, so the
459 // groups have to be in the hash or a tag index would never notice a new tag.
460 .chain(
461 listing
462 .groups
463 .iter()
464 .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))),
465 )
466 .chain(listing.paginator.iter().map(|p| {
467 (
468 format!("{}/{}", p.current, p.total),
469 format!("{:?}|{:?}", p.prev_url, p.next_url),
470 )
471 }))
472 .chain([(listing.title.clone(), listing.template.clone())])
473 .collect();
474
475 // A listing that embeds its entries' bodies depends on those bodies. The source hash
476 // stands in for the rendered HTML, so noticing a change does not cost a render of
477 // every page listed.
478 if listing.include_content {
479 fields.extend(
480 listing
481 .entry_hashes
482 .iter()
483 .map(|h| ("content".to_string(), format!("{h:?}"))),
484 );
485 }
486
487 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
488 // rather than a set: a re-ordering is a real change to the page.
489 site_structure_hash_ordered(&fields)
490}
491
492/// A listing's entries with their rendered bodies attached, for a template that asked
493/// for them — a full-content feed being the case that needs it.
494///
495/// An entry whose source is not among the prepared pages keeps `content: none` rather
496/// than failing: the listing is still a valid page, and a feed item without a body is a
497/// better outcome than no feed.
498fn entries_with_content(
499 entries: &[PageContext],
500 preps: &[PagePrep],
501 highlighter: &SyntectHighlighter,
502 config: &Config,
503) -> Vec<PageContext> {
504 let by_source: HashMap<&str, &PagePrep> =
505 preps.iter().map(|p| (p.source.as_str(), p)).collect();
506 let opts = render_options(config);
507 entries
508 .par_iter()
509 .map(|entry| {
510 let mut entry = entry.clone();
511 if let Some(prep) = by_source.get(entry.source.as_str()) {
512 let Html(html) = render_with(&prep.resolved, highlighter, &opts);
513 entry.content = Some(html);
514 }
515 entry
516 })
517 .collect()
518}
519
520/// The nav a listing page shows: whatever the site's nav is, relativized to this
521/// listing's own location.
522fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> {
523 let Some(first) = preps.first() else {
524 return Vec::new();
525 };
526 first
527 .nav
528 .iter()
529 .map(|item| {
530 // Nav URLs on `preps[0]` are relative to that page; re-resolve them against
531 // the site root, then against this listing's depth.
532 let absolute = resolve_relative(&first.output, &item.url);
533 NavItem {
534 title: item.title.clone(),
535 url: output_url(output, &absolute, None),
536 }
537 })
538 .collect()
539}
540
541/// Turn a URL relative to `from` back into a site-root-relative path.
542fn resolve_relative(from: &Utf8Path, url: &str) -> Utf8PathBuf {
543 if url == "#" {
544 return from.to_owned();
545 }
546 let base = from.parent().unwrap_or_else(|| Utf8Path::new(""));
547 let mut stack: Vec<&str> = base.components().map(|c| c.as_str()).collect();
548 for part in url.split('/') {
549 match part {
550 "." | "" => {}
551 ".." => {
552 stack.pop();
553 }
554 other => stack.push(other),
555 }
556 }
557 Utf8PathBuf::from(stack.join("/"))
558}
559
560/// The `PageContext` a listing page presents for *itself*.
561fn listing_context(listing: &Listing) -> PageContext {
562 PageContext {
563 title: listing.title.clone(),
564 url: listing.output.to_string(),
565 source: String::new(),
566 date: None,
567 date_iso: None,
568 year: None,
569 tags: Vec::new(),
570 excerpt: String::new(),
571 content: None,
572 word_count: 0,
573 reading_time: 0,
574 keywords: Default::default(),
575 toc: Vec::new(),
576 }
577}
578
579/// Which pages the configured [`NavMode`] selects, in nav order.
580fn nav_selection<'a>(config: &Config, candidates: &'a [NavCandidate]) -> Vec<&'a NavCandidate> {
581 match config.nav.mode {
582 NavMode::None => Vec::new(),
583 NavMode::All => candidates.iter().collect(),
584 // Generated pages are a section's landing page, which is what a nav entry should
585 // point at whatever depth the section lives at.
586 NavMode::TopLevel => candidates
587 .iter()
588 .filter(|c| c.generated || is_top_level(&c.output))
589 .collect(),
590 // Configured order wins over discovery order — a hand-written nav is a designed
591 // sequence, not an alphabetical one.
592 NavMode::Explicit => {
593 let mut chosen: Vec<&NavCandidate> = config
594 .nav
595 .pages
596 .iter()
597 .filter_map(|want| candidates.iter().find(|c| c.matches(want)))
598 .collect();
599 // A collection that asked for the nav but was not listed is appended rather
600 // than dropped, so `nav = true` never silently does nothing. Listing it puts
601 // it exactly where you said instead.
602 for candidate in candidates.iter().filter(|c| c.generated) {
603 if !chosen.iter().any(|c| c.output == candidate.output) {
604 chosen.push(candidate);
605 }
606 }
607 chosen
608 }
609 }
610}
611
612/// A page the navigation could contain: one written as `.org`, or one generated by a
613/// collection.
614struct NavCandidate {
615 /// The source path of an authored page. Empty for a generated one, which has none.
616 source: Utf8PathBuf,
617 output: Utf8PathBuf,
618 title: String,
619 /// Generated pages are appended when an explicit nav does not name them.
620 generated: bool,
621}
622
623impl NavCandidate {
624 /// Does `name` in `nav.pages` refer to this entry?
625 ///
626 /// Authored pages are named by their source — `about.org` — because that is the file
627 /// you wrote and its output path may be moved by `#+SLUG:`. Generated pages have no
628 /// source, so they are named by their output — `blog/index.html`. Either spelling is
629 /// accepted for either, so a config that names an output path still works.
630 fn matches(&self, name: &Utf8Path) -> bool {
631 (!self.source.as_str().is_empty() && self.source == name) || self.output == name
632 }
633}
634
635/// Every page the navigation could contain, authored pages first.
636fn nav_candidates(
637 config: &Config,
638 pages: &[(Utf8PathBuf, Utf8PathBuf, String)],
639) -> Vec<NavCandidate> {
640 let mut candidates: Vec<NavCandidate> = pages
641 .iter()
642 .map(|(source, output, title)| NavCandidate {
643 source: source.clone(),
644 output: output.clone(),
645 title: title.clone(),
646 generated: false,
647 })
648 .collect();
649 for collection in config.collections.iter().filter(|c| c.nav) {
650 let (output, title) = nav_target(collection);
651 candidates.push(NavCandidate {
652 source: Utf8PathBuf::new(),
653 output,
654 title,
655 generated: true,
656 });
657 }
658 candidates
659}
660
661/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
662/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
663/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
664/// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4).
665fn prepare_pages(
666 src: &Utf8Path,
667 config: &Config,
668 out: Option<&Utf8Path>,
669) -> Result<(Vec<PagePrep>, SymbolTable)> {
670 let (org_rel, _assets) = discover(src, config, out)?;
671
672 // PARSE every file (relative paths keep snapshots and links machine-independent).
673 // PARSE is a pure function of one file's bytes (spec §2.1), which is exactly the
674 // property that makes it safe to run in parallel. `par_iter().collect()` preserves
675 // input order, so the document list — and everything downstream of it — is identical
676 // to the sequential build regardless of how the work was scheduled.
677 let docs: Vec<Document> = org_rel
678 .par_iter()
679 .map(|rel| {
680 let abs = src.join(rel);
681 let source = fs::read_to_string(&abs).with_context(|| format!("reading {abs}"))?;
682 parse(rel.as_path(), &source).with_context(|| format!("parsing {rel}"))
683 })
684 .collect::<Result<Vec<_>>>()?;
685
686 // Drop drafts before anything else sees them. Removing them here rather than at emit
687 // time means they are absent from listings, the nav and the symbol table too — so a
688 // link *to* a draft is reported as broken, which is exactly what it would be on the
689 // published site.
690 let docs: Vec<Document> = docs
691 .into_iter()
692 .filter(|d| config.build.drafts || !is_draft(&d.keywords))
693 .collect();
694
695 // INDEX: collect every link target across the corpus.
696 let mut symbols = SymbolTable::new();
697 for doc in &docs {
698 symbols.index_document(doc);
699 }
700
701 // `(source, output, title)` for every page. Titles come from #+TITLE (falling back to
702 // the file stem) and URLs from each page's output path, which `#+SLUG:` can rename.
703 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = docs
704 .iter()
705 .map(|d| {
706 (
707 d.source_path.clone(),
708 output_path(&d.source_path, &d.keywords),
709 page_title(d),
710 )
711 })
712 .collect();
713
714 // Two sources emitting one page would silently drop a page — and with slugs, a
715 // collision is a typo away and invisible in the source filenames.
716 let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> =
717 std::collections::HashMap::new();
718 for (source, out, _) in &all_pages {
719 if let Some(other) = claimed.insert(out, source) {
720 anyhow::bail!(
721 "output collision: {other} and {source} both build to {out} \
722 (check their #+SLUG:)"
723 );
724 }
725 }
726
727 // Authored pages and the landing pages collections generate, in one list so an
728 // explicit nav can order them together.
729 let candidates = nav_candidates(config, &all_pages);
730
731 // An explicit nav naming something that does not exist is a typo, and a silently
732 // shorter nav is a poor way to learn about it.
733 if config.nav.mode == NavMode::Explicit {
734 for want in &config.nav.pages {
735 if !candidates.iter().any(|c| c.matches(want)) {
736 anyhow::bail!(
737 "nav.pages lists {want}, which is neither a page in {src} nor a \
738 collection with `nav = true`"
739 );
740 }
741 }
742 }
743 let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &candidates)
744 .into_iter()
745 .map(|c| (c.output.clone(), c.title.clone()))
746 .collect();
747
748 // RESOLVE reads the shared symbol table and writes only into its own page's output,
749 // so it parallelizes for free once INDEX has finished building the table.
750 let pages: Vec<PagePrep> = docs
751 .par_iter()
752 .map(|doc| {
753 let out = resolve(doc, &symbols);
754 let used: HashSet<TargetId> = out.used_targets.iter().cloned().collect();
755 let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect();
756 let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect();
757
758 let output = output_path(&doc.source_path, &doc.keywords);
759
760 // Nav links are relative to *this* page (spec URL scheme, §8 Q3).
761 let nav: Vec<NavItem> = entries
762 .iter()
763 .map(|(path, title)| NavItem {
764 title: title.clone(),
765 url: output_url(&output, path, None),
766 })
767 .collect();
768
769 PagePrep {
770 context: page_context(doc, &output, config),
771 template: config::page_template(config, &doc.source_path, &doc.keywords),
772 source: doc.source_path.clone(),
773 output,
774 title: page_title(doc),
775 content_hash: doc.content_hash,
776 resolved: out.resolved,
777 used,
778 defines,
779 broken,
780 diagnostics: doc.diagnostics.clone(),
781 nav,
782 }
783 })
784 .collect();
785
786 Ok((pages, symbols))
787}
788
789/// Fail before rendering if any page names a template that does not exist.
790///
791/// minijinja would report the missing name on its own, but only once a page reaches it
792/// — and a typo in `#+TEMPLATE:` or a `[[pages]]` rule is worth naming together with the
793/// page that carries it and the templates that do exist.
794fn check_page_templates(templater: &Templater, preps: &[PagePrep]) -> Result<()> {
795 for p in preps {
796 if !templater.has(&p.template) {
797 let mut available = templater.names();
798 available.sort_unstable();
799 anyhow::bail!(
800 "{} renders through {}, which is not in the templates directory. \
801 Available: {}",
802 p.source,
803 p.template,
804 available.join(", ")
805 );
806 }
807 }
808 Ok(())
809}
810
811/// Parse + index + resolve + render + template a whole site *in memory*, without
812/// touching the output directory. Shared by the tests (full render, every page).
813pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
814 let config = Config::load(src)?;
815 config.validate()?;
816 let (preps, _symbols) = prepare_pages(src, &config, None)?;
817 let highlighter = SyntectHighlighter::new();
818 let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?;
819 check_page_templates(&templater, &preps)?;
820 let site = site_context(&config);
821 let listing = page_listing(&config, &preps);
822
823 let mut pages = Vec::new();
824 let mut broken = Vec::new();
825 for p in &preps {
826 for t in &p.broken {
827 broken.push((p.source.clone(), t.clone()));
828 }
829 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &config, p)?;
830 pages.push(BuiltPage {
831 source: p.source.clone(),
832 output: p.output.clone(),
833 title: p.title.clone(),
834 html,
835 });
836 }
837 Ok((pages, broken))
838}
839
840/// The site's render options. A document's own `#+OPTIONS:` switches are applied by the
841/// renderer, so every caller gets them.
842fn render_options(config: &Config) -> RenderOptions {
843 RenderOptions {
844 heading_offset: config.html.heading_offset,
845 section_numbers: config.html.section_numbers,
846 special_strings: config.html.special_strings,
847 sub_superscript: config.html.sub_superscript,
848 entities: config.html.entities,
849 }
850}
851
852fn site_context(config: &Config) -> SiteContext {
853 SiteContext {
854 title: config.site.title.clone(),
855 base_url: config.site.base_url.clone(),
856 description: config.site.description.clone(),
857 language: config.site.language.clone(),
858 }
859}
860
861/// The `pages` list templates see, when configured to see one (see
862/// [`crate::config::Templates::expose_page_list`]).
863fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>> {
864 config
865 .templates
866 .expose_page_list
867 .then(|| preps.iter().map(|p| p.context.clone()).collect())
868}
869
870/// RENDER + TEMPLATE one prepared page into its final HTML string.
871#[allow(clippy::too_many_arguments)]
872fn render_page(
873 templater: &Templater,
874 highlighter: &SyntectHighlighter,
875 site: &SiteContext,
876 pages: Option<&[PageContext]>,
877 config: &Config,
878 p: &PagePrep,
879) -> Result<String> {
880 let opts = render_options(config);
881 let Html(fragment) = render_with(&p.resolved, highlighter, &opts);
882 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
883 let root = relative_root(&p.output);
884 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
885 let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root);
886 ctx.body = &fragment;
887 ctx.pages = pages;
888 templater
889 .render(&p.template, &ctx)
890 .with_context(|| format!("templating {} through {}", p.source, p.template))
891}
892
893/// Site-root-relative name of the generated syntax stylesheet. Every page links to it.
894pub const SYNTAX_STYLESHEET: &str = "syntax.css";
895
896/// Full site build with the incremental layer (spec §4). Renders only the pages whose
897/// `render_key` changed or that link into a changed file's targets; reuses the on-disk
898/// output of everything else; persists an updated cache manifest.
899pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
900 let mut cfg = match &opts.config_path {
901 Some(path) => Config::load_file(path)?,
902 None => Config::load(src)?,
903 };
904 cfg.validate()?;
905 // The flag turns drafts on; it never turns off a config that asked for them.
906 cfg.build.drafts |= opts.drafts;
907
908 // Create the output directory up front so it can be recognised and excluded when it
909 // lives inside the source tree.
910 fs::create_dir_all(out).with_context(|| format!("creating {out}"))?;
911 let (_org_rel, source_assets) = discover(src, &cfg, Some(out))?;
912 let assets = collect_assets(src, &cfg, Some(out), &source_assets)?;
913 let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
914
915 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
916 check_page_templates(&templater, &preps)?;
917 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
918 anyhow::anyhow!(
919 "unknown highlight.theme {:?}. Available: {}",
920 cfg.highlight.theme,
921 render::available_themes().join(", ")
922 )
923 })?;
924
925 // The global hash classes (spec §4.1): a change in any invalidates the site. The
926 // config hash is combined with a site-structure hash covering the global chrome each
927 // page carries, so a change to that chrome re-renders the pages showing it.
928 //
929 // Which pages belong in that hash depends on what a template can *see*. Normally it
930 // is the nav only — a nested page cannot change another page's nav, so adding a blog
931 // post should render one page, not the site. But `expose_page_list` hands every
932 // template every page's metadata, and then any page's output really can depend on
933 // any other page, so the hash has to widen to match. Keyed on output paths, since a
934 // `#+SLUG:` change moves a page's URL without moving its source.
935 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps
936 .iter()
937 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
938 .collect();
939 let structure_hash = if cfg.templates.expose_page_list {
940 let entries: Vec<(String, String)> = all_pages
941 .iter()
942 .map(|(_, out, title)| (out.to_string(), title.clone()))
943 .collect();
944 site_structure_hash(&entries)
945 } else {
946 // The same selection the nav itself is built from, so the two can never drift.
947 // Hashed in order, because the nav's order is itself part of every page.
948 let entries: Vec<(String, String)> = nav_selection(&cfg, &nav_candidates(&cfg, &all_pages))
949 .into_iter()
950 .map(|c| (c.output.to_string(), c.title.clone()))
951 .collect();
952 site_structure_hash_ordered(&entries)
953 };
954 let cfg_hash = combine(config_hash(&cfg), structure_hash);
955 // Per template rather than per site: a page's render key covers the layout it uses
956 // and that layout's own includes, so editing `feed.xml` re-renders the feed.
957 let tmpl_hash_for = |name: &str| template_hash(&templater.sources_for(name));
958
959 // Compose each page's render key and record its dependency edges.
960 let mut new_graph = DepGraph::default();
961 let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new();
962 let listings = build_listings(&cfg, &preps)?;
963 for p in &preps {
964 let rlh = resolved_links_hash(&p.source, &p.used, &symbols);
965 let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash_for(&p.template));
966 new_graph.defines.insert(p.source.clone(), p.defines.clone());
967 new_graph.uses.insert(p.source.clone(), p.used.clone());
968 new_records.push((
969 p.source.clone(),
970 PageRecord {
971 content_hash: p.content_hash,
972 render_key: key,
973 output_path: p.output.clone(),
974 },
975 key,
976 ));
977 }
978
979 // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None
980 // ⇒ full rebuild (spec §4.5).
981 let prior = if opts.no_cache {
982 None
983 } else {
984 incremental::load_manifest(out)
985 };
986
987 let rebuild: HashSet<Utf8PathBuf> = compute_rebuild_set(
988 &preps,
989 &new_records,
990 &new_graph,
991 cfg_hash,
992 out,
993 prior.as_ref(),
994 );
995
996 // Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1:
997 // removed files). Their targets are already in the merged graph, so their linkers were
998 // invalidated above.
999 if let Some(prior) = &prior {
1000 // Keyed by source path for real pages and by output path for generated listings,
1001 // which is also how each records itself in the manifest. Listings have to be in
1002 // this set or the cleanup would delete the file it just decided to keep — and a
1003 // removed collection genuinely should have its output deleted.
1004 let mut current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
1005 current.extend(listings.iter().map(|l| &l.output));
1006 for (key, rec) in &prior.pages {
1007 if !current.contains(key) {
1008 let dest = out.join(&rec.output_path);
1009 let _ = fs::remove_file(&dest);
1010 }
1011 }
1012 }
1013
1014 let highlighter = SyntectHighlighter::with_syntaxes(Some(&src.join(&cfg.highlight.syntaxes_dir)));
1015 let site = site_context(&cfg);
1016 let listing = page_listing(&cfg, &preps);
1017 let mut report = SiteReport::default();
1018
1019 // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes
1020 // (syntect highlighting and templating dominate), and each page writes only its own
1021 // file, so the pages are independent.
1022 //
1023 // The parallel pass returns whether each page was written; the report is assembled
1024 // sequentially afterwards from `preps` order. Pushing to the report from inside the
1025 // parallel pass would make `rendered`/`skipped` ordering depend on thread scheduling,
1026 // which would be a non-deterministic build report over a deterministic build.
1027 let written: Vec<bool> = preps
1028 .par_iter()
1029 .map(|p| {
1030 if !rebuild.contains(&p.source) {
1031 // Skip: the on-disk output is already correct (spec §4.1). Leave it alone.
1032 return Ok(false);
1033 }
1034 let dest = out.join(&p.output);
1035 if let Some(parent) = dest.parent() {
1036 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
1037 }
1038 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &cfg, p)?;
1039 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
1040 Ok(true)
1041 })
1042 .collect::<Result<Vec<_>>>()?;
1043
1044 for (p, was_written) in preps.iter().zip(&written) {
1045 for t in &p.broken {
1046 report.broken.push((p.source.clone(), t.clone()));
1047 }
1048 for d in &p.diagnostics {
1049 report.diagnostics.push((p.source.clone(), d.clone()));
1050 }
1051 report.pages.push(p.output.clone());
1052 if *was_written {
1053 report.rendered.push(p.output.clone());
1054 } else {
1055 report.skipped.push(p.output.clone());
1056 }
1057 }
1058
1059 // Generated listing pages (spec §2.1 EMIT). A listing has no source file, so it is
1060 // cached on the one thing it actually depends on: the entries it lists. Adding a post
1061 // therefore re-renders that section's index and nothing else — the same precision the
1062 // rest of the build gets from content hashing.
1063 for listing in &listings {
1064 let key = combine(
1065 listing_entries_hash(listing),
1066 combine(cfg_hash, tmpl_hash_for(&listing.template)),
1067 );
1068 let dest = out.join(&listing.output);
1069 let cached = prior
1070 .as_ref()
1071 .and_then(|m| m.pages.get(&listing.output))
1072 .map(|rec| rec.render_key == key)
1073 .unwrap_or(false);
1074
1075 report.pages.push(listing.output.clone());
1076 if cached && dest.exists() {
1077 report.skipped.push(listing.output.clone());
1078 } else {
1079 if let Some(parent) = dest.parent() {
1080 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
1081 }
1082 let root = relative_root(&listing.output);
1083 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
1084 let nav = listing_nav(&preps, &listing.output);
1085 let page_ctx = listing_context(listing);
1086 // Bodies are rendered here rather than when the listing was built, so a
1087 // cached feed costs nothing. This is the only place a page is rendered twice.
1088 let with_content = listing
1089 .include_content
1090 .then(|| entries_with_content(&listing.entries, &preps, &highlighter, &cfg));
1091 let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root);
1092 ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries));
1093 ctx.group = listing.group.as_ref();
1094 ctx.groups = &listing.groups;
1095 ctx.paginator = listing.paginator.as_ref();
1096 let html = templater
1097 .render(&listing.template, &ctx)
1098 .with_context(|| {
1099 format!(
1100 "rendering collection {} with template {} (available: {})",
1101 listing.output,
1102 listing.template,
1103 templater.names().join(", ")
1104 )
1105 })?;
1106 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
1107 report.rendered.push(listing.output.clone());
1108 }
1109
1110 new_records.push((
1111 listing.output.clone(),
1112 PageRecord {
1113 content_hash: key,
1114 render_key: key,
1115 output_path: listing.output.clone(),
1116 },
1117 key,
1118 ));
1119 }
1120
1121 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build
1122 // (it is a few KB and depends only on the theme, which lives in the config hash).
1123 fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css)
1124 .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?;
1125
1126 // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
1127 // full-vs-incremental byte equivalence trivially true for non-`.org` files.
1128 for asset in &assets {
1129 let dest = out.join(&asset.rel);
1130 if let Some(parent) = dest.parent() {
1131 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
1132 }
1133 fs::copy(&asset.from, &dest)
1134 .with_context(|| format!("copying {} -> {dest}", asset.from))?;
1135 report.assets.push(asset.rel.clone());
1136 }
1137
1138 // Persist the manifest for the next build.
1139 let manifest = Manifest {
1140 format_version: CACHE_FORMAT_VERSION,
1141 config_hash: Some(cfg_hash),
1142 pages: new_records
1143 .into_iter()
1144 .map(|(src_path, rec, _)| (src_path, rec))
1145 .collect(),
1146 graph: new_graph,
1147 };
1148 incremental::save_manifest(out, &manifest)
1149 .with_context(|| format!("writing cache manifest under {out}"))?;
1150
1151 let warnings = report.warnings();
1152 if opts.strict && !warnings.is_empty() {
1153 for w in &warnings {
1154 eprintln!("error: {w}");
1155 }
1156 anyhow::bail!(
1157 "{} problem(s) under --strict ({} parse diagnostic(s), {} unresolved link(s))",
1158 warnings.len(),
1159 report.diagnostics.len(),
1160 report.broken.len()
1161 );
1162 }
1163 for w in &warnings {
1164 eprintln!("warning: {w}");
1165 }
1166
1167 Ok(report)
1168}
1169
1170/// The set of source files to (re)render this build (spec §4.3 invalidation algorithm),
1171/// as the union of:
1172/// - **no prior cache** (absent/corrupt/version-mismatch/`--no-cache`) ⇒ every page;
1173/// - a **global** config- or template-hash change ⇒ every page (spec §4.1);
1174/// - **content-changed** files ∪ pages that link into a changed file's targets, via the
1175/// dependency graph merged with the prior build's `defines` (spec §4.3, so a removed
1176/// target still invalidates its linkers);
1177/// - any page whose composed **render_key** differs from the cached one (catches URL
1178/// changes on linked targets precisely);
1179/// - any page whose **output file is missing** on disk.
1180fn compute_rebuild_set(
1181 preps: &[PagePrep],
1182 new_records: &[(Utf8PathBuf, PageRecord, Hash)],
1183 new_graph: &DepGraph,
1184 cfg_hash: Hash,
1185 out: &Utf8Path,
1186 prior: Option<&Manifest>,
1187) -> HashSet<Utf8PathBuf> {
1188 let all: HashSet<Utf8PathBuf> = preps.iter().map(|p| p.source.clone()).collect();
1189
1190 let Some(prior) = prior else {
1191 return all; // No usable cache ⇒ full rebuild.
1192 };
1193
1194 // A config change invalidates every page. Template changes do not come through here:
1195 // each page's render key carries the hash of the templates *it* uses, so the key
1196 // comparison below invalidates exactly the pages whose layout moved.
1197 if prior.config_hash != Some(cfg_hash) {
1198 return all;
1199 }
1200
1201 // Content-changed = hash differs from the cached record, or the file is new.
1202 let mut changed: HashSet<Utf8PathBuf> = HashSet::new();
1203 for p in preps {
1204 match prior.pages.get(&p.source) {
1205 Some(rec) if rec.content_hash == p.content_hash => {}
1206 _ => {
1207 changed.insert(p.source.clone());
1208 }
1209 }
1210 }
1211
1212 // Graph expansion: changed files ∪ pages that link into a changed file's targets.
1213 // Merge prior `defines` so a target a changed file removed still pulls its linkers.
1214 let merged = prior.graph.merged_defines_with(new_graph);
1215 let mut rebuild = incremental::invalidation_set(&changed, &merged);
1216
1217 // Precise render_key delta (catches a linked target's URL change; also a belt for the
1218 // graph). A page whose render_key matches the cache and whose output exists is correct.
1219 for (src_path, _rec, key) in new_records {
1220 let unchanged = prior
1221 .pages
1222 .get(src_path)
1223 .map(|old| old.render_key == *key)
1224 .unwrap_or(false);
1225 if !unchanged {
1226 rebuild.insert(src_path.clone());
1227 }
1228 }
1229
1230 // Any page whose output file is missing must be re-emitted regardless.
1231 for p in preps {
1232 if !out.join(&p.output).exists() {
1233 rebuild.insert(p.source.clone());
1234 }
1235 }
1236
1237 rebuild
1238}
1239
1240/// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative
1241/// to `src` and sorted for deterministic output. The cache manifest is not an asset.
1242fn discover(
1243 src: &Utf8Path,
1244 config: &Config,
1245 out: Option<&Utf8Path>,
1246) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
1247 let skip_dirs = excluded_dirs(src, config, out);
1248 let mut org = Vec::new();
1249 let mut assets = Vec::new();
1250
1251 let walker = WalkDir::new(src).sort_by_file_name().into_iter();
1252 for entry in walker.filter_entry(|e| {
1253 let Some(path) = Utf8Path::from_path(e.path()) else {
1254 return false;
1255 };
1256 let rel = path.strip_prefix(src).unwrap_or(path);
1257 // The source root itself always passes; `filter_entry` prunes whole subtrees.
1258 rel.as_str().is_empty() || !is_excluded(rel, &skip_dirs)
1259 }) {
1260 let entry = entry.with_context(|| format!("walking {src}"))?;
1261 if !entry.file_type().is_file() {
1262 continue;
1263 }
1264 let abs = Utf8PathBuf::from_path_buf(entry.into_path())
1265 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
1266 let rel = abs
1267 .strip_prefix(src)
1268 .map(|p| p.to_owned())
1269 .unwrap_or_else(|_| abs.clone());
1270 if rel == config::CONFIG_FILE {
1271 continue;
1272 }
1273 if rel.extension() == Some("org") {
1274 org.push(rel);
1275 } else {
1276 assets.push(rel);
1277 }
1278 }
1279 org.sort();
1280 assets.sort();
1281 Ok((org, assets))
1282}
1283
1284/// One file to copy through to the output: where it is, and where it goes.
1285#[derive(Debug, Clone, PartialEq)]
1286pub struct Asset {
1287 /// Path to read from.
1288 pub from: Utf8PathBuf,
1289 /// Path to write, relative to the output root.
1290 pub rel: Utf8PathBuf,
1291}
1292
1293/// Every file to copy: the source directory's non-`.org` files, then each extra asset
1294/// root's contents, flattened onto the site root.
1295///
1296/// Two files claiming one output path is an error rather than a race — whichever won
1297/// would depend on directory order, and a site whose favicon changes when a file is
1298/// renamed elsewhere is worse than a build that stops.
1299fn collect_assets(
1300 src: &Utf8Path,
1301 config: &Config,
1302 out: Option<&Utf8Path>,
1303 from_source: &[Utf8PathBuf],
1304) -> Result<Vec<Asset>> {
1305 let mut assets: Vec<Asset> = from_source
1306 .iter()
1307 .map(|rel| Asset {
1308 from: src.join(rel),
1309 rel: rel.clone(),
1310 })
1311 .collect();
1312
1313 for root in &config.build.assets {
1314 let base = src.join(root);
1315 if !base.is_dir() {
1316 anyhow::bail!(
1317 "build.assets lists {root}, which is not a directory (looked in {base})"
1318 );
1319 }
1320 let base_canon = std::fs::canonicalize(&base)
1321 .ok()
1322 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1323 .unwrap_or_else(|| base.clone());
1324 // An asset root that contains the output directory would copy the site into
1325 // itself, one build at a time.
1326 let out_canon = out
1327 .and_then(|out| std::fs::canonicalize(out).ok())
1328 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
1329 if out_canon.is_some_and(|o| o.starts_with(&base_canon)) {
1330 anyhow::bail!(
1331 "build.assets lists {root}, which contains the output directory {}",
1332 out.unwrap_or(Utf8Path::new("(none)"))
1333 );
1334 }
1335 for entry in WalkDir::new(&base).sort_by_file_name() {
1336 let entry = entry.with_context(|| format!("walking {base}"))?;
1337 if !entry.file_type().is_file() {
1338 continue;
1339 }
1340 let abs = Utf8PathBuf::from_path_buf(entry.into_path())
1341 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
1342 let rel = abs
1343 .strip_prefix(&base)
1344 .map(|p| p.to_owned())
1345 .unwrap_or_else(|_| abs.clone());
1346 if rel.components().any(|c| c.as_str().starts_with('.')) {
1347 continue;
1348 }
1349 assets.push(Asset { from: abs, rel });
1350 }
1351 }
1352
1353 let mut seen: HashMap<&Utf8Path, &Utf8Path> = HashMap::new();
1354 for asset in &assets {
1355 if let Some(first) = seen.insert(&asset.rel, &asset.from) {
1356 anyhow::bail!(
1357 "two files both publish to {}: {first} and {}",
1358 asset.rel,
1359 asset.from
1360 );
1361 }
1362 }
1363 assets.sort_by(|a, b| a.rel.cmp(&b.rel));
1364 Ok(assets)
1365}
1366
1367/// Source-relative directories that DISCOVER must not descend into: the template
1368/// directory (build input, not content) and the output directory when it lives inside
1369/// the source.
1370///
1371/// The output case is not a corner case — `orgo build . -o _site` is the obvious
1372/// thing to type, and without this the build copies its own output back into itself,
1373/// growing `_site/_site/_site/…` on every run.
1374fn excluded_dirs(src: &Utf8Path, config: &Config, out: Option<&Utf8Path>) -> Vec<Utf8PathBuf> {
1375 let mut dirs = vec![config.templates.dir.clone()];
1376 if let Some(out) = out {
1377 // Compare canonicalized paths so `.`, `./x` and an absolute path all agree.
1378 // The output may not exist yet, in which case it cannot contain anything and
1379 // the textual fallback is enough.
1380 let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
1381 std::fs::canonicalize(p)
1382 .ok()
1383 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1384 };
1385 match (canon(src), canon(out)) {
1386 (Some(src_abs), Some(out_abs)) => {
1387 if let Ok(rel) = out_abs.strip_prefix(&src_abs) {
1388 if !rel.as_str().is_empty() {
1389 dirs.push(rel.to_owned());
1390 }
1391 }
1392 }
1393 _ => {
1394 if let Ok(rel) = out.strip_prefix(src) {
1395 if !rel.as_str().is_empty() {
1396 dirs.push(rel.to_owned());
1397 }
1398 }
1399 }
1400 }
1401 }
1402 dirs
1403}
1404
1405/// Is this source-relative path excluded from discovery?
1406///
1407/// Dot-entries are skipped wholesale. That is the conventional rule for site generators,
1408/// and the reason is safety rather than tidiness: a source directory is very often a git
1409/// repository, and publishing `.git` — or `.env` — is a way to leak a project's entire
1410/// history alongside its homepage.
1411fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool {
1412 if rel
1413 .components()
1414 .any(|c| c.as_str().starts_with('.') && c.as_str() != "." && c.as_str() != "..")
1415 {
1416 return true;
1417 }
1418 skip_dirs
1419 .iter()
1420 .any(|dir| !dir.as_str().is_empty() && rel.starts_with(dir))
1421}
1422
1423/// Does this output path sit at the site root?
1424///
1425/// The nav is the site's global chrome, and listing *every* page in it makes an `n`-page
1426/// site emit `n²` nav links — 1,790 pages produced 284 MB of output, most of it nav. A
1427/// nav is a map of the site's top level, not an index of its contents, so it is built
1428/// from root-level pages only. Section pages reach their siblings through that section's
1429/// own landing page.
1430fn is_top_level(output: &Utf8Path) -> bool {
1431 output.parent().is_none_or(|p| p.as_str().is_empty())
1432}
1433
1434/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through
1435/// under its lowercased name, so a template can use metadata this crate has never heard
1436/// of without the crate needing a release to support it.
1437fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageContext {
1438 let words = document_text(&doc.root).split_whitespace().count();
1439 let keyword = |name: &str| {
1440 doc.keywords
1441 .entries
1442 .iter()
1443 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1444 .map(|(_, v)| v.clone())
1445 };
1446 PageContext {
1447 title: page_title(doc),
1448 url: output.to_string(),
1449 source: doc.source_path.to_string(),
1450 date_iso: keyword("DATE").as_deref().and_then(iso_date),
1451 year: keyword("DATE")
1452 .as_deref()
1453 .and_then(iso_date)
1454 .map(|d| d[..4].to_string()),
1455 date: keyword("DATE"),
1456 excerpt: keyword("DESCRIPTION")
1457 .filter(|d| !d.trim().is_empty())
1458 .or_else(|| first_paragraph(&doc.root))
1459 .unwrap_or_default(),
1460 content: None,
1461 word_count: words,
1462 reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)),
1463 toc: if option_enabled(&doc.keywords, "toc", config.html.toc) {
1464 table_of_contents(&doc.root)
1465 } else {
1466 Vec::new()
1467 },
1468 tags: keyword("FILETAGS")
1469 .unwrap_or_default()
1470 .split(':')
1471 .filter(|t| !t.trim().is_empty())
1472 .map(|t| t.trim().to_string())
1473 .collect(),
1474 keywords: doc
1475 .keywords
1476 .entries
1477 .iter()
1478 .map(|(k, v)| (k.to_lowercase(), v.clone()))
1479 .collect(),
1480 }
1481}
1482
1483fn page_title(doc: &Document) -> String {
1484 doc.keywords
1485 .entries
1486 .iter()
1487 .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
1488 .map(|(_, v)| v.clone())
1489 .unwrap_or_else(|| {
1490 doc.source_path
1491 .file_stem()
1492 .unwrap_or("untitled")
1493 .to_string()
1494 })
1495}