krz/orgo

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

Commit f4298e6300

f4298e6300cee92e4517c1e1052293203ea37f25

parent: 7e27d608f7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-22 22:08 UTC

Split complex functions; fix table header and config messages

- Tables with several header rows emitted one <thead> per row and closed
  only the last; all header rows now share one <thead>.
- Four pagination errors in Config::validate printed a run of spaces
  mid-sentence from missing line continuations.
- build_site, build_listings, validate, the block/list/inline parsers,
  render_element/list/table, attr_html, the audit scanners, and skeleton
  split into named helpers. Output is byte-identical on the site, docs,
  fixtures, audit, and 20k generated documents.
- is_drawer in audit dropped a redundant length check; audit and util
  share one YYYY-MM-DD shape test.
- Qlty: disable return_statements and file_complexity.

Layout: unified · split

.qlty/qlty.toml +10
@@ -41,3 +41,13 @@ name = "trufflehog"
41match.plugins = ["osv-scanner"] 41match.plugins = ["osv-scanner"]
42match.rules = ["osv-scanner:RUSTSEC-2025-0141", "osv-scanner:RUSTSEC-2024-0320"] 42match.rules = ["osv-scanner:RUSTSEC-2025-0141", "osv-scanner:RUSTSEC-2024-0320"]
43set.ignored = true 43set.ignored = true
44
45
46# Early returns are guard clauses, which keep the happy path unindented.
47[smells.return_statements]
48enabled = false
49
50# Total file complexity is the sum of its functions, which function_complexity
51# already checks.
52[smells.file_complexity]
53enabled = false
src/audit.rs +118 −106
@@ -24,6 +24,8 @@ use anyhow::{Context, Result};
24use camino::{Utf8Path, Utf8PathBuf}; 24use camino::{Utf8Path, Utf8PathBuf};
25use walkdir::WalkDir; 25use walkdir::WalkDir;
26 26
27use crate::util::is_iso_date_shape;
28
27/// Where a construct sits relative to the supported set, which 29/// Where a construct sits relative to the supported set, which
28/// `docs/guide/05-org-support.org` defines. 30/// `docs/guide/05-org-support.org` defines.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -179,7 +181,31 @@ pub fn audit(root: &Utf8Path) -> Result<Audit> {
179 181
180impl Audit { 182impl Audit {
181 fn scan_file(&mut self, path: &Utf8Path, source: &str) { 183 fn scan_file(&mut self, path: &Utf8Path, source: &str) {
182 // Reset the per-file flags so each construct counts this file at most once. 184 self.start_file();
185 let mut in_block = false;
186 for (idx, line) in source.lines().enumerate() {
187 self.lines += 1;
188 let at = format!("{path}:{}", idx + 1);
189 let trimmed = line.trim_start();
190
191 // Inside a block only the terminator matters — a `*` in a source block is
192 // not a heading, and counting it as one would corrupt the audit.
193 if in_block {
194 in_block = !trimmed.to_ascii_uppercase().starts_with("#+END_");
195 continue;
196 }
197 if let Some(rest) = trimmed.to_ascii_uppercase().strip_prefix("#+BEGIN_") {
198 self.scan_block_begin(rest, &at);
199 in_block = true;
200 continue;
201 }
202
203 self.scan_line(line, trimmed, &at);
204 }
205 }
206
207 /// Reset the per-file flags so each construct counts this file at most once.
208 fn start_file(&mut self) {
183 for tally in self.constructs.values_mut() { 209 for tally in self.constructs.values_mut() {
184 tally.seen_in_current_file = false; 210 tally.seen_in_current_file = false;
185 } 211 }
@@ -193,63 +219,21 @@ impl Audit {
193 tally.seen_in_current_file = false; 219 tally.seen_in_current_file = false;
194 } 220 }
195 } 221 }
222 }
196 223
197 let mut in_block: Option<String> = None; 224 /// `rest` is an uppercased `#+BEGIN_` line after that prefix.
198 for (idx, line) in source.lines().enumerate() { 225 fn scan_block_begin(&mut self, rest: &str, at: &str) {
199 self.lines += 1; 226 let kind = rest.split_whitespace().next().unwrap_or("");
200 let at = format!("{path}:{}", idx + 1); 227 self.count_census(Census::Block, kind, at);
201 let trimmed = line.trim_start(); 228 self.count(Scope::In, block_construct(kind), at);
202 229 if rest.contains(":RESULTS") {
203 // Inside a verbatim block only the terminator matters — a `*` in a source 230 self.count(Scope::Out, "babel header args (:results)", at);
204 // block is not a heading, and counting it as one would corrupt the audit.
205 if let Some(kind) = &in_block {
206 if trimmed.to_ascii_uppercase().starts_with("#+END_") {
207 in_block = None;
208 } else if kind.eq_ignore_ascii_case("SRC") || kind.eq_ignore_ascii_case("EXAMPLE") {
209 continue;
210 }
211 continue;
212 }
213 if let Some(rest) = trimmed.to_ascii_uppercase().strip_prefix("#+BEGIN_") {
214 let kind = rest.split_whitespace().next().unwrap_or("").to_string();
215 self.count_census(Census::Block, &kind, &at);
216 self.count(Scope::In, block_construct(&kind), &at);
217 if trimmed.to_ascii_uppercase().contains(":RESULTS") {
218 self.count(Scope::Out, "babel header args (:results)", &at);
219 }
220 in_block = Some(kind);
221 continue;
222 }
223
224 self.scan_line(line, trimmed, &at);
225 } 231 }
226 } 232 }
227 233
228 fn scan_line(&mut self, line: &str, trimmed: &str, at: &str) { 234 fn scan_line(&mut self, line: &str, trimmed: &str, at: &str) {
229 // --- headings and their metadata ---
230 if let Some(stars) = heading_stars(line) { 235 if let Some(stars) = heading_stars(line) {
231 self.count(Scope::In, "heading", at); 236 self.scan_heading(line[stars..].trim(), at);
232 let rest = line[stars..].trim();
233 let word = rest.split_whitespace().next().unwrap_or("");
234 if word == "TODO" || word == "DONE" {
235 self.count(Scope::In, "TODO keyword (default set)", at);
236 } else if CONVENTIONAL_TODO_KEYWORDS.contains(&word) {
237 // Only conventional keyword names count. "Any all-caps first word" is
238 // the tempting rule and it is wrong: it reads `* CSS Variables` as the
239 // keyword `CSS`, which on this corpus produced 23 false positives and
240 // zero true ones. An audit that overstates a gap is worse than no audit,
241 // because it argues for work nobody needs.
242 self.count(Scope::Out, "TODO keyword (custom sequence)", at);
243 }
244 if rest.contains("[#") {
245 self.count(Scope::In, "priority cookie", at);
246 }
247 if rest.trim_end().ends_with(':') && rest.trim_end().matches(':').count() >= 2 {
248 self.count(Scope::In, "heading tags", at);
249 }
250 if rest.contains("[/") || rest.contains("[%") {
251 self.count(Scope::Out, "statistics cookie", at);
252 }
253 return; 237 return;
254 } 238 }
255 239
@@ -265,60 +249,19 @@ impl Audit {
265 249
266 // --- keywords and drawers --- 250 // --- keywords and drawers ---
267 if let Some(rest) = trimmed.strip_prefix("#+") { 251 if let Some(rest) = trimmed.strip_prefix("#+") {
268 if let Some(colon) = rest.find(':') { 252 self.scan_keyword(rest, at);
269 let key = rest[..colon].trim().to_ascii_uppercase();
270 if !key.is_empty() && !key.contains(char::is_whitespace) {
271 self.count_census(Census::Keyword, &key, at);
272 match key.as_str() {
273 "CAPTION" | "NAME" | "ATTR_HTML" => {
274 self.count(Scope::In, "affiliated keyword", at)
275 }
276 // Not a gap: org's HTML exporter does not recalculate `#+TBLFM:`
277 // either, so the cells as written are what both exporters emit.
278 // `fixtures/tblfm.org` holds the oracle to that (`tests/oracle.rs`).
279 // Unlike `#+INCLUDE:`, nothing is lost by leaving it inert.
280 "TBLFM" => self.count(Scope::In, "table formula (#+TBLFM:)", at),
281 "INCLUDE" => self.count(Scope::Out, "#+INCLUDE:", at),
282 "RESULTS" => self.count(Scope::Out, "babel results block", at),
283 "TODO" => self.count(Scope::Out, "#+TODO: keyword sequence", at),
284 "MACRO" => self.count(Scope::Out, "macro definition", at),
285 _ => self.count(Scope::In, "#+ keyword", at),
286 }
287 }
288 }
289 } else if is_drawer(trimmed) { 253 } else if is_drawer(trimmed) {
290 let name = trimmed[1..trimmed.len() - 1].to_ascii_uppercase(); 254 self.scan_drawer(&trimmed[1..trimmed.len() - 1].to_ascii_uppercase(), at);
291 if name != "END" {
292 self.count_census(Census::Drawer, &name, at);
293 match name.as_str() {
294 "PROPERTIES" => self.count(Scope::In, "property drawer", at),
295 _ => self.count(Scope::Out, "non-PROPERTIES drawer", at),
296 }
297 }
298 } 255 }
299 256
300 // --- lists, tables, rules --- 257 // --- lists, tables, rules ---
301 if let Some(bullet) = list_bullet(trimmed) { 258 if let Some(bullet) = list_bullet(trimmed) {
302 self.count(Scope::In, "list item", at); 259 self.scan_list_item(bullet, line.len() - trimmed.len(), trimmed, at);
303 if bullet == Bullet::Ordered {
304 self.count(Scope::In, "ordered list", at);
305 }
306 let indent = line.len() - trimmed.len();
307 if indent > 0 {
308 self.count(Scope::In, "nested list item", at);
309 }
310 if trimmed.contains(" :: ") {
311 self.count(Scope::In, "description list", at);
312 }
313 let after = trimmed.trim_start_matches(['-', '+', '*', ' ']);
314 if after.starts_with("[ ]") || after.starts_with("[X]") || after.starts_with("[-]") {
315 self.count(Scope::In, "checkbox", at);
316 }
317 } 260 }
318 if trimmed.starts_with('|') { 261 if trimmed.starts_with('|') {
319 self.count(Scope::In, "table row", at); 262 self.count(Scope::In, "table row", at);
320 } 263 }
321 if trimmed.starts_with(':') && !is_drawer(trimmed) && trimmed.starts_with(": ") { 264 if trimmed.starts_with(": ") {
322 self.count(Scope::Out, "fixed-width line", at); 265 self.count(Scope::Out, "fixed-width line", at);
323 } 266 }
324 267
@@ -333,6 +276,83 @@ impl Audit {
333 self.scan_inline(line, at); 276 self.scan_inline(line, at);
334 } 277 }
335 278
279 /// `rest` is a heading line after its stars, trimmed.
280 fn scan_heading(&mut self, rest: &str, at: &str) {
281 self.count(Scope::In, "heading", at);
282 let word = rest.split_whitespace().next().unwrap_or("");
283 if word == "TODO" || word == "DONE" {
284 self.count(Scope::In, "TODO keyword (default set)", at);
285 } else if CONVENTIONAL_TODO_KEYWORDS.contains(&word) {
286 // Only conventional keyword names count. "Any all-caps first word" is
287 // the tempting rule and it is wrong: it reads `* CSS Variables` as the
288 // keyword `CSS`, which on this corpus produced 23 false positives and
289 // zero true ones. An audit that overstates a gap is worse than no audit,
290 // because it argues for work nobody needs.
291 self.count(Scope::Out, "TODO keyword (custom sequence)", at);
292 }
293 if rest.contains("[#") {
294 self.count(Scope::In, "priority cookie", at);
295 }
296 if rest.trim_end().ends_with(':') && rest.trim_end().matches(':').count() >= 2 {
297 self.count(Scope::In, "heading tags", at);
298 }
299 if rest.contains("[/") || rest.contains("[%") {
300 self.count(Scope::Out, "statistics cookie", at);
301 }
302 }
303
304 /// `rest` is a `#+` line after that prefix.
305 fn scan_keyword(&mut self, rest: &str, at: &str) {
306 let Some(colon) = rest.find(':') else { return };
307 let key = rest[..colon].trim().to_ascii_uppercase();
308 if key.is_empty() || key.contains(char::is_whitespace) {
309 return;
310 }
311 self.count_census(Census::Keyword, &key, at);
312 match key.as_str() {
313 "CAPTION" | "NAME" | "ATTR_HTML" => self.count(Scope::In, "affiliated keyword", at),
314 // Not a gap: org's HTML exporter does not recalculate `#+TBLFM:`
315 // either, so the cells as written are what both exporters emit.
316 // `fixtures/tblfm.org` holds the oracle to that (`tests/oracle.rs`).
317 // Unlike `#+INCLUDE:`, nothing is lost by leaving it inert.
318 "TBLFM" => self.count(Scope::In, "table formula (#+TBLFM:)", at),
319 "INCLUDE" => self.count(Scope::Out, "#+INCLUDE:", at),
320 "RESULTS" => self.count(Scope::Out, "babel results block", at),
321 "TODO" => self.count(Scope::Out, "#+TODO: keyword sequence", at),
322 "MACRO" => self.count(Scope::Out, "macro definition", at),
323 _ => self.count(Scope::In, "#+ keyword", at),
324 }
325 }
326
327 /// `name` is a drawer line's name, uppercased.
328 fn scan_drawer(&mut self, name: &str, at: &str) {
329 if name == "END" {
330 return;
331 }
332 self.count_census(Census::Drawer, name, at);
333 match name {
334 "PROPERTIES" => self.count(Scope::In, "property drawer", at),
335 _ => self.count(Scope::Out, "non-PROPERTIES drawer", at),
336 }
337 }
338
339 fn scan_list_item(&mut self, bullet: Bullet, indent: usize, trimmed: &str, at: &str) {
340 self.count(Scope::In, "list item", at);
341 if bullet == Bullet::Ordered {
342 self.count(Scope::In, "ordered list", at);
343 }
344 if indent > 0 {
345 self.count(Scope::In, "nested list item", at);
346 }
347 if trimmed.contains(" :: ") {
348 self.count(Scope::In, "description list", at);
349 }
350 let after = trimmed.trim_start_matches(['-', '+', '*', ' ']);
351 if ["[ ]", "[X]", "[-]"].iter().any(|cb| after.starts_with(cb)) {
352 self.count(Scope::In, "checkbox", at);
353 }
354 }
355
336 fn scan_inline(&mut self, line: &str, at: &str) { 356 fn scan_inline(&mut self, line: &str, at: &str) {
337 // Links: count each `[[target]]`, censusing its scheme. 357 // Links: count each `[[target]]`, censusing its scheme.
338 let mut rest = line; 358 let mut rest = line;
@@ -460,7 +480,6 @@ fn is_drawer(trimmed: &str) -> bool {
460 && t[1..t.len() - 1] 480 && t[1..t.len() - 1]
461 .chars() 481 .chars()
462 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') 482 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
463 && t.len() > 2
464} 483}
465 484
466fn block_construct(kind: &str) -> &'static str { 485fn block_construct(kind: &str) -> &'static str {
@@ -504,14 +523,7 @@ fn has_timestamp(line: &str) -> bool {
504 if c != '<' && c != '[' { 523 if c != '<' && c != '[' {
505 continue; 524 continue;
506 } 525 }
507 let rest = &bytes[i + 1..]; 526 if bytes[i + 1..].get(..10).is_some_and(is_iso_date_shape) {
508 if rest.len() >= 10
509 && rest[..4].iter().all(u8::is_ascii_digit)
510 && rest[4] == b'-'
511 && rest[5..7].iter().all(u8::is_ascii_digit)
512 && rest[7] == b'-'
513 && rest[8..10].iter().all(u8::is_ascii_digit)
514 {
515 return true; 527 return true;
516 } 528 }
517 } 529 }
src/config.rs +97 −71
@@ -459,79 +459,10 @@ impl Config {
459 /// Validate settings that only make sense in combination. Catching these up front 459 /// Validate settings that only make sense in combination. Catching these up front
460 /// beats emitting a site with a silently empty nav. 460 /// beats emitting a site with a silently empty nav.
461 pub fn validate(&self) -> Result<()> { 461 pub fn validate(&self) -> Result<()> {
462 if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() { 462 self.validate_nav()?;
463 anyhow::bail!(
464 "nav.mode is \"explicit\" but nav.pages is empty: list the pages to \
465 include, or use mode = \"top-level\"/\"all\"/\"none\""
466 );
467 }
468 if self.nav.mode != NavMode::Explicit && !self.nav.pages.is_empty() {
469 anyhow::bail!(
470 "nav.pages is set but nav.mode is \"{}\", so it would be ignored; set \
471 mode = \"explicit\" to use it",
472 toml::to_string(&self.nav.mode)
473 .unwrap_or_default()
474 .trim()
475 .trim_matches('"')
476 );
477 }
478 let mut seen: Vec<&Utf8PathBuf> = Vec::new(); 463 let mut seen: Vec<&Utf8PathBuf> = Vec::new();
479 for collection in &self.collections { 464 for collection in &self.collections {
480 let grouped = !collection.group_by.is_empty(); 465 collection.validate()?;
481 if collection.output.as_str().is_empty() && collection.index_output.as_str().is_empty()
482 {
483 anyhow::bail!("a collection has no `output`; it needs a file to write");
484 }
485 if grouped
486 && !collection.output.as_str().is_empty()
487 && !collection.output.as_str().contains(GROUP_PLACEHOLDER)
488 {
489 anyhow::bail!(
490 "collection output {} groups by \"{}\" but has no {GROUP_PLACEHOLDER} in \
491 its path, so every group would overwrite the same file",
492 collection.output,
493 collection.group_by
494 );
495 }
496 if !grouped && collection.output.as_str().contains(GROUP_PLACEHOLDER) {
497 anyhow::bail!(
498 "collection output {} uses {GROUP_PLACEHOLDER} but sets no `group_by`",
499 collection.output
500 );
501 }
502 if !grouped && !collection.index_output.as_str().is_empty() {
503 anyhow::bail!(
504 "collection writes an `index_output` of {} but sets no `group_by`; \
505 there are no groups to index",
506 collection.index_output
507 );
508 }
509 if collection.paginate > 0 {
510 let pattern = collection.paginate_output.as_str();
511 if pattern.is_empty() {
512 anyhow::bail!(
513 "collection output {} sets `paginate` but no `paginate_output`; pages 2 and up need somewhere to go, e.g. \"blog/page/{PAGE_PLACEHOLDER}.html\"",
514 collection.output
515 );
516 }
517 if !pattern.contains(PAGE_PLACEHOLDER) {
518 anyhow::bail!(
519 "collection `paginate_output` {pattern} has no {PAGE_PLACEHOLDER}, so every page after the first would overwrite the same file"
520 );
521 }
522 if grouped && !pattern.contains(GROUP_PLACEHOLDER) {
523 anyhow::bail!(
524 "collection `paginate_output` {pattern} groups by \"{}\" but has no {GROUP_PLACEHOLDER}, so page 2 of one group would overwrite page 2 of another",
525 collection.group_by
526 );
527 }
528 }
529 if collection.paginate == 0 && !collection.paginate_output.as_str().is_empty() {
530 anyhow::bail!(
531 "collection sets `paginate_output` {} but `paginate` is 0, so it would never be used; set `paginate` to a page size",
532 collection.paginate_output
533 );
534 }
535 for path in [&collection.output, &collection.index_output] { 466 for path in [&collection.output, &collection.index_output] {
536 if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) { 467 if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) {
537 continue; 468 continue;
@@ -554,6 +485,30 @@ impl Config {
554 ); 485 );
555 } 486 }
556 } 487 }
488 self.validate_site()
489 }
490
491 fn validate_nav(&self) -> Result<()> {
492 if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() {
493 anyhow::bail!(
494 "nav.mode is \"explicit\" but nav.pages is empty: list the pages to \
495 include, or use mode = \"top-level\"/\"all\"/\"none\""
496 );
497 }
498 if self.nav.mode != NavMode::Explicit && !self.nav.pages.is_empty() {
499 anyhow::bail!(
500 "nav.pages is set but nav.mode is \"{}\", so it would be ignored; set \
501 mode = \"explicit\" to use it",
502 toml::to_string(&self.nav.mode)
503 .unwrap_or_default()
504 .trim()
505 .trim_matches('"')
506 );
507 }
508 Ok(())
509 }
510
511 fn validate_site(&self) -> Result<()> {
557 if !self.site.theme.is_empty() && crate::theme::theme_css(&self.site.theme).is_none() { 512 if !self.site.theme.is_empty() && crate::theme::theme_css(&self.site.theme).is_none() {
558 anyhow::bail!( 513 anyhow::bail!(
559 "unknown site.theme {:?}. Available: {} — or leave it empty for no \ 514 "unknown site.theme {:?}. Available: {} — or leave it empty for no \
@@ -573,6 +528,76 @@ impl Config {
573 } 528 }
574} 529}
575 530
531impl Collection {
532 /// Reject output paths that could not hold this collection's pages.
533 fn validate(&self) -> Result<()> {
534 let grouped = !self.group_by.is_empty();
535 if self.output.as_str().is_empty() && self.index_output.as_str().is_empty() {
536 anyhow::bail!("a collection has no `output`; it needs a file to write");
537 }
538 if grouped
539 && !self.output.as_str().is_empty()
540 && !self.output.as_str().contains(GROUP_PLACEHOLDER)
541 {
542 anyhow::bail!(
543 "collection output {} groups by \"{}\" but has no {GROUP_PLACEHOLDER} in \
544 its path, so every group would overwrite the same file",
545 self.output,
546 self.group_by
547 );
548 }
549 if !grouped && self.output.as_str().contains(GROUP_PLACEHOLDER) {
550 anyhow::bail!(
551 "collection output {} uses {GROUP_PLACEHOLDER} but sets no `group_by`",
552 self.output
553 );
554 }
555 if !grouped && !self.index_output.as_str().is_empty() {
556 anyhow::bail!(
557 "collection writes an `index_output` of {} but sets no `group_by`; \
558 there are no groups to index",
559 self.index_output
560 );
561 }
562 self.validate_pagination()
563 }
564
565 fn validate_pagination(&self) -> Result<()> {
566 let pattern = self.paginate_output.as_str();
567 if self.paginate == 0 {
568 if !pattern.is_empty() {
569 anyhow::bail!(
570 "collection sets `paginate_output` {pattern} but `paginate` is 0, so it \
571 would never be used; set `paginate` to a page size"
572 );
573 }
574 return Ok(());
575 }
576 if pattern.is_empty() {
577 anyhow::bail!(
578 "collection output {} sets `paginate` but no `paginate_output`; pages 2 \
579 and up need somewhere to go, e.g. \"blog/page/{PAGE_PLACEHOLDER}.html\"",
580 self.output
581 );
582 }
583 if !pattern.contains(PAGE_PLACEHOLDER) {
584 anyhow::bail!(
585 "collection `paginate_output` {pattern} has no {PAGE_PLACEHOLDER}, so \
586 every page after the first would overwrite the same file"
587 );
588 }
589 if !self.group_by.is_empty() && !pattern.contains(GROUP_PLACEHOLDER) {
590 anyhow::bail!(
591 "collection `paginate_output` {pattern} groups by \"{}\" but has no \
592 {GROUP_PLACEHOLDER}, so page 2 of one group would overwrite page 2 of \
593 another",
594 self.group_by
595 );
596 }
597 Ok(())
598 }
599}
600
576/// The starter config written by `orgo init`, and the documentation of record for 601/// The starter config written by `orgo init`, and the documentation of record for
577/// what is configurable. Every value shown is the default — except `site.theme`, which 602/// what is configurable. Every value shown is the default — except `site.theme`, which
578/// picks a stylesheet so a new site looks like something on its first build — so 603/// picks a stylesheet so a new site looks like something on its first build — so
@@ -696,3 +721,4 @@ index_template = "tags.html"
696index_title = "Tags" 721index_title = "Tags"
697nav = true # adds the tag *index*, not every tag 722nav = true # adds the tag *index*, not every tag
698"#; 723"#;
724
src/parser.rs +182 −152
@@ -338,39 +338,46 @@ fn parse_section_body(
338 idx += 1; 338 idx += 1;
339 } 339 }
340 if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") { 340 if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") {
341 let opened_at = base + idx; 341 idx = parse_property_drawer(&mut heading, body, idx, base, diags);
342 let mut terminated = false;
343 idx += 1;
344 while idx < body.len() {
345 let t = body[idx].trim();
346 if t.eq_ignore_ascii_case(":END:") {
347 idx += 1;
348 terminated = true;
349 break;
350 }
351 if let Some((k, v)) = parse_property(t) {
352 if k.eq_ignore_ascii_case("CUSTOM_ID") {
353 heading.custom_id = Some(v.clone());
354 } else if k.eq_ignore_ascii_case("ID") {
355 heading.id = Some(v.clone());
356 }
357 heading.properties.entries.push((k, v));
358 }
359 idx += 1;
360 }
361 if !terminated {
362 diags.push(Diagnostic {
363 line: opened_at + 1,
364 message: "unterminated :PROPERTIES: drawer (no :END:); the rest of the \
365 section was read as properties"
366 .to_string(),
367 });
368 }
369 } 342 }
370 let content = parse_elements(&body[idx..], base + idx, diags); 343 let content = parse_elements(&body[idx..], base + idx, diags);
371 (heading, content) 344 (heading, content)
372} 345}
373 346
347/// Read the `:PROPERTIES:` drawer opening at `body[open]` into `heading`, returning the
348/// index of the first line past it.
349fn parse_property_drawer(
350 heading: &mut Heading,
351 body: &[&str],
352 open: usize,
353 base: usize,
354 diags: &mut Vec<Diagnostic>,
355) -> usize {
356 let mut idx = open + 1;
357 while idx < body.len() {
358 let t = body[idx].trim();
359 if t.eq_ignore_ascii_case(":END:") {
360 return idx + 1;
361 }
362 if let Some((k, v)) = parse_property(t) {
363 if k.eq_ignore_ascii_case("CUSTOM_ID") {
364 heading.custom_id = Some(v.clone());
365 } else if k.eq_ignore_ascii_case("ID") {
366 heading.id = Some(v.clone());
367 }
368 heading.properties.entries.push((k, v));
369 }
370 idx += 1;
371 }
372 diags.push(Diagnostic {
373 line: base + open + 1,
374 message: "unterminated :PROPERTIES: drawer (no :END:); the rest of the \
375 section was read as properties"
376 .to_string(),
377 });
378 idx
379}
380
374/// `:KEY: value` inside a drawer. 381/// `:KEY: value` inside a drawer.
375fn parse_property(line: &str) -> Option<(String, String)> { 382fn parse_property(line: &str) -> Option<(String, String)> {
376 let line = line.trim(); 383 let line = line.trim();
@@ -410,30 +417,10 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V
410 continue; 417 continue;
411 } 418 }
412 if let Some((key, value)) = keyword_kv(line) { 419 if let Some((key, value)) = keyword_kv(line) {
413 if key.eq_ignore_ascii_case("INCLUDE") { 420 match keyword_role(&key, &value, base + i + 1, diags) {
414 // Never expanded (§"Not supported"). Expanding it means resolving paths, 421 KeywordRole::DropNext => drop_next = true,
415 // recursion and `:lines`/`:only-contents`; dropping it silently means a 422 KeywordRole::Affiliated => affiliated.push((key, value)),
416 // page missing content nobody was told about. Saying so is the honest 423 KeywordRole::Standalone => out.push(Element::Keyword { key, value }),
417 // middle, and `--strict` turns it into a failure.
418 diags.push(Diagnostic {
419 line: base + i + 1,
420 message: format!(
421 "`#+INCLUDE: {}` is not expanded; that content will be missing \
422 from the page",
423 value.trim()
424 ),
425 });
426 }
427 if key.eq_ignore_ascii_case("RESULTS") {
428 // Babel is never executed (§"Not supported"), so a checked-in `#+RESULTS:`
429 // block is output from someone else's Emacs session at some other time.
430 // Emitting it would put unverifiable content on the page dressed as
431 // real content, so the block it labels is dropped.
432 drop_next = true;
433 } else if is_affiliated(&key) {
434 affiliated.push((key, value));
435 } else {
436 out.push(Element::Keyword { key, value });
437 } 424 }
438 i += 1; 425 i += 1;
439 continue; 426 continue;
@@ -451,6 +438,46 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V
451 out 438 out
452} 439}
453 440
441/// What a `#+KEY: value` line contributes to the elements around it.
442enum KeywordRole {
443 /// Drop the element that follows.
444 DropNext,
445 /// Attach to the element that follows.
446 Affiliated,
447 /// An element of its own.
448 Standalone,
449}
450
451/// Classify a keyword line, reporting keywords orgo does not act on. `line` is the
452/// 1-based source line.
453fn keyword_role(key: &str, value: &str, line: usize, diags: &mut Vec<Diagnostic>) -> KeywordRole {
454 if key.eq_ignore_ascii_case("INCLUDE") {
455 // Never expanded (§"Not supported"). Expanding it means resolving paths,
456 // recursion and `:lines`/`:only-contents`; dropping it silently means a
457 // page missing content nobody was told about. Saying so is the honest
458 // middle, and `--strict` turns it into a failure.
459 diags.push(Diagnostic {
460 line,
461 message: format!(
462 "`#+INCLUDE: {}` is not expanded; that content will be missing \
463 from the page",
464 value.trim()
465 ),
466 });
467 }
468 if key.eq_ignore_ascii_case("RESULTS") {
469 // Babel is never executed (§"Not supported"), so a checked-in `#+RESULTS:`
470 // block is output from someone else's Emacs session at some other time.
471 // Emitting it would put unverifiable content on the page dressed as
472 // real content, so the block it labels is dropped.
473 KeywordRole::DropNext
474 } else if is_affiliated(key) {
475 KeywordRole::Affiliated
476 } else {
477 KeywordRole::Standalone
478 }
479}
480
454/// Build the single element starting at `lines[start]`, returning it with the index of 481/// Build the single element starting at `lines[start]`, returning it with the index of
455/// the first line past it. `None` means the lines were consumed without producing an 482/// the first line past it. `None` means the lines were consumed without producing an
456/// element. `start` is guaranteed non-blank and not an affiliated keyword. 483/// element. `start` is guaranteed non-blank and not an affiliated keyword.
@@ -464,8 +491,8 @@ fn parse_one_element(
464 if let Some(text) = comment_text(line) { 491 if let Some(text) = comment_text(line) {
465 return (Some(Element::Comment(text)), start + 1); 492 return (Some(Element::Comment(text)), start + 1);
466 } 493 }
467 if let Some((kind, after)) = block_begin(line) { 494 if let Some(header) = block_begin(line) {
468 let (el, next) = parse_block(lines, start, &kind, &after, base, diags); 495 let (el, next) = parse_block(lines, start, &header, base, diags);
469 return (Some(el), next); 496 return (Some(el), next);
470 } 497 }
471 if let Some(name) = drawer_begin_name(line) { 498 if let Some(name) = drawer_begin_name(line) {
@@ -517,19 +544,23 @@ fn parse_one_element(
517 (Some(Element::Paragraph(inline(&para.join(" ")))), i) 544 (Some(Element::Paragraph(inline(&para.join(" ")))), i)
518} 545}
519 546
547/// Tests for each line that starts a non-paragraph construct.
548const STRUCTURAL: [fn(&str) -> bool; 10] = [
549 |l| block_begin(l).is_some(),
550 is_block_end,
551 is_rule,
552 |l| keyword_kv(l).is_some(),
553 |l| comment_text(l).is_some(),
554 |l| drawer_begin_name(l).is_some(),
555 |l| is_list_item(l.trim_start()).is_some(),
556 |l| l.trim_start().starts_with('|'),
557 |l| footnote_def_label(l).is_some(),
558 |l| heading_level(l).is_some(),
559];
560
520/// Is this line the start of a non-paragraph construct? 561/// Is this line the start of a non-paragraph construct?
521fn is_structural(line: &str) -> bool { 562fn is_structural(line: &str) -> bool {
522 let t = line.trim_start(); 563 STRUCTURAL.iter().any(|test| test(line))
523 block_begin(line).is_some()
524 || is_block_end(line)
525 || is_rule(line)
526 || keyword_kv(line).is_some()
527 || comment_text(line).is_some()
528 || drawer_begin_name(line).is_some()
529 || is_list_item(t).is_some()
530 || t.starts_with('|')
531 || footnote_def_label(line).is_some()
532 || heading_level(line).is_some()
533} 564}
534 565
535// --------------------------------------------------------------------------- 566// ---------------------------------------------------------------------------
@@ -542,8 +573,7 @@ fn is_structural(line: &str) -> bool {
542fn parse_block( 573fn parse_block(
543 lines: &[&str], 574 lines: &[&str],
544 start: usize, 575 start: usize,
545 kind: &str, 576 (kind, after): &(String, String),
546 after: &str,
547 base: usize, 577 base: usize,
548 diags: &mut Vec<Diagnostic>, 578 diags: &mut Vec<Diagnostic>,
549) -> (Element, usize) { 579) -> (Element, usize) {
@@ -853,22 +883,7 @@ fn parse_list(
853 883
854 let mut items = Vec::new(); 884 let mut items = Vec::new();
855 let mut i = start; 885 let mut i = start;
856 loop { 886 while let Some((j, bullet)) = next_sibling(lines, i, base_indent, &family) {
857 // Skip blank lines, but only stay in the list if a sibling bullet follows.
858 let mut j = i;
859 while j < lines.len() && lines[j].trim().is_empty() {
860 j += 1;
861 }
862 if j >= lines.len() || indent_of(lines[j]) != base_indent {
863 break;
864 }
865 let Some(bullet) = is_list_item(lines[j].trim_start()) else {
866 break;
867 };
868 if bullet_family(&bullet) != family {
869 break;
870 }
871
872 // Body = the text after the bullet, plus every following line indented past the 887 // Body = the text after the bullet, plus every following line indented past the
873 // bullet column (blank lines included, so an item can hold several paragraphs). 888 // bullet column (blank lines included, so an item can hold several paragraphs).
874 let rest = item_body(lines[j].trim_start(), &bullet); 889 let rest = item_body(lines[j].trim_start(), &bullet);
@@ -883,28 +898,8 @@ fn parse_list(
883 _ => (None, rest), 898 _ => (None, rest),
884 }; 899 };
885 900
886 let mut body: Vec<String> = vec![rest.trim().to_string()]; 901 let body;
887 i = j + 1; 902 (body, i) = item_body_lines(lines, j + 1, base_indent, rest.trim().to_string());
888 while i < lines.len() {
889 if lines[i].trim().is_empty() {
890 // Trailing blanks belong to the item only if more of it follows.
891 let mut k = i;
892 while k < lines.len() && lines[k].trim().is_empty() {
893 k += 1;
894 }
895 if k < lines.len() && indent_of(lines[k]) > base_indent {
896 body.resize(body.len() + (k - i), String::new());
897 i = k;
898 continue;
899 }
900 break;
901 }
902 if indent_of(lines[i]) <= base_indent {
903 break;
904 }
905 body.push(lines[i].to_string());
906 i += 1;
907 }
908 903
909 items.push(ListItem { 904 items.push(ListItem {
910 bullet, 905 bullet,
@@ -919,6 +914,60 @@ fn parse_list(
919 (List { kind, items }, i) 914 (List { kind, items }, i)
920} 915}
921 916
917/// The next item of a list whose bullets sit at `base_indent`, searching from
918/// `lines[from]`: its line index and bullet. Blank lines are skipped, but the list only
919/// continues if a sibling bullet of the same family follows them.
920fn next_sibling(
921 lines: &[&str],
922 from: usize,
923 base_indent: usize,
924 family: &ListKind,
925) -> Option<(usize, Bullet)> {
926 let mut j = from;
927 while j < lines.len() && lines[j].trim().is_empty() {
928 j += 1;
929 }
930 if j >= lines.len() || indent_of(lines[j]) != base_indent {
931 return None;
932 }
933 let bullet = is_list_item(lines[j].trim_start())?;
934 (bullet_family(&bullet) == *family).then_some((j, bullet))
935}
936
937/// An item's body: `first` (the text after its bullet), then every line from
938/// `lines[from]` indented past `base_indent`. Returns the body with the index of the
939/// first line past it.
940fn item_body_lines(
941 lines: &[&str],
942 from: usize,
943 base_indent: usize,
944 first: String,
945) -> (Vec<String>, usize) {
946 let mut body: Vec<String> = vec![first];
947 let mut i = from;
948 while i < lines.len() {
949 if lines[i].trim().is_empty() {
950 // Trailing blanks belong to the item only if more of it follows.
951 let mut k = i;
952 while k < lines.len() && lines[k].trim().is_empty() {
953 k += 1;
954 }
955 if k < lines.len() && indent_of(lines[k]) > base_indent {
956 body.resize(body.len() + (k - i), String::new());
957 i = k;
958 continue;
959 }
960 break;
961 }
962 if indent_of(lines[i]) <= base_indent {
963 break;
964 }
965 body.push(lines[i].to_string());
966 i += 1;
967 }
968 (body, i)
969}
970
922/// Ordered and unordered bullets cannot share a list; description items use unordered 971/// Ordered and unordered bullets cannot share a list; description items use unordered
923/// bullets, so they are the same family. 972/// bullets, so they are the same family.
924fn bullet_family(bullet: &Bullet) -> ListKind { 973fn bullet_family(bullet: &Bullet) -> ListKind {
@@ -1132,63 +1181,44 @@ fn is_list_item(t: &str) -> Option<Bullet> {
1132// Inline tokenizer (spec §3.1, R3) 1181// Inline tokenizer (spec §3.1, R3)
1133// --------------------------------------------------------------------------- 1182// ---------------------------------------------------------------------------
1134 1183
1184/// A recognizer for one kind of inline object: a cheap test for whether it can start at
1185/// `chars[i]`, and the parser that tries it.
1186type ObjectParser = (
1187 fn(&[char], usize) -> bool,
1188 fn(&[char], usize) -> Option<(Object, usize)>,
1189);
1190
1191/// Inline objects in the order they are tried at each position. The first that parses
1192/// wins; text that none of them claims is plain text.
1193const OBJECT_PARSERS: [ObjectParser; 6] = [
1194 (|cs, i| cs[i] == '[' && starts_with_at(cs, i, "[fn:"), try_footnote_ref),
1195 (|cs, i| cs[i] == '[' && cs.get(i + 1) == Some(&'['), try_link),
1196 (|cs, i| matches!(cs[i], '<' | '['), try_timestamp),
1197 (|cs, i| is_scheme_start(cs, i) && boundary_before(cs, i), try_bare_url),
1198 (|cs, i| cs[i] == '\\', try_entity),
1199 (|cs, i| is_marker(cs[i]), try_emphasis),
1200];
1201
1135fn parse_inline_run(chars: &[char]) -> Vec<Object> { 1202fn parse_inline_run(chars: &[char]) -> Vec<Object> {
1136 let mut out = Vec::new(); 1203 let mut out = Vec::new();
1137 let mut buf = String::new(); 1204 let mut buf = String::new();
1138 let mut i = 0; 1205 let mut i = 0;
1139 let n = chars.len(); 1206 while i < chars.len() {
1140 while i < n { 1207 let found = OBJECT_PARSERS
1141 let c = chars[i]; 1208 .iter()
1142 if c == '[' && starts_with_at(chars, i, "[fn:") { 1209 .filter(|(applies, _)| applies(chars, i))
1143 if let Some((obj, next)) = try_footnote_ref(chars, i) { 1210 .find_map(|(_, parse)| parse(chars, i));
1144 flush(&mut buf, &mut out); 1211 match found {
1145 out.push(obj); 1212 Some((obj, next)) => {
1146 i = next;
1147 continue;
1148 }
1149 }
1150 if c == '[' && i + 1 < n && chars[i + 1] == '[' {
1151 if let Some((obj, next)) = try_link(chars, i) {
1152 flush(&mut buf, &mut out);
1153 out.push(obj);
1154 i = next;
1155 continue;
1156 }
1157 }
1158 if c == '<' || c == '[' {
1159 if let Some((obj, next)) = try_timestamp(chars, i) {
1160 flush(&mut buf, &mut out);
1161 out.push(obj);
1162 i = next;
1163 continue;
1164 }
1165 }
1166 if is_scheme_start(chars, i) && boundary_before(chars, i) {
1167 if let Some((obj, next)) = try_bare_url(chars, i) {
1168 flush(&mut buf, &mut out);
1169 out.push(obj);
1170 i = next;
1171 continue;
1172 }
1173 }
1174 if c == '\\' {
1175 if let Some((obj, next)) = try_entity(chars, i) {
1176 flush(&mut buf, &mut out); 1213 flush(&mut buf, &mut out);
1177 out.push(obj); 1214 out.push(obj);
1178 i = next; 1215 i = next;
1179 continue;
1180 } 1216 }
1181 } 1217 None => {
1182 if is_marker(c) { 1218 buf.push(chars[i]);
1183 if let Some((obj, next)) = try_emphasis(chars, i) { 1219 i += 1;
1184 flush(&mut buf, &mut out);
1185 out.push(obj);
1186 i = next;
1187 continue;
1188 } 1220 }
1189 } 1221 }
1190 buf.push(c);
1191 i += 1;
1192 } 1222 }
1193 flush(&mut buf, &mut out); 1223 flush(&mut buf, &mut out);
1194 out 1224 out
src/render.rs +175 −151
@@ -450,18 +450,10 @@ impl Renderer<'_> {
450 out.push_str(&format!("<pre>{}</pre>\n", escape_html(code))); 450 out.push_str(&format!("<pre>{}</pre>\n", escape_html(code)));
451 } 451 }
452 Element::QuoteBlock(inner) => { 452 Element::QuoteBlock(inner) => {
453 out.push_str("<blockquote>\n"); 453 self.render_wrapped("<blockquote>\n", inner, "</blockquote>\n", out);
454 for el in inner {
455 self.render_element(el, out);
456 }
457 out.push_str("</blockquote>\n");
458 } 454 }
459 Element::CenterBlock(inner) => { 455 Element::CenterBlock(inner) => {
460 out.push_str("<div class=\"center\">\n"); 456 self.render_wrapped("<div class=\"center\">\n", inner, "</div>\n", out);
461 for el in inner {
462 self.render_element(el, out);
463 }
464 out.push_str("</div>\n");
465 } 457 }
466 // An `html` export block is verbatim output by definition; every other 458 // An `html` export block is verbatim output by definition; every other
467 // backend is out of scope and drops (§"Not supported"). 459 // backend is out of scope and drops (§"Not supported").
@@ -475,37 +467,12 @@ impl Renderer<'_> {
475 link, 467 link,
476 caption, 468 caption,
477 attrs, 469 attrs,
478 } => { 470 } => self.render_figure(link, caption, attrs, out),
479 out.push_str("<figure>");
480 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
481 if !caption.is_empty() {
482 self.figures += 1;
483 out.push_str(&format!(
484 "<figcaption><span class=\"figure-number\">Figure {}: </span>",
485 self.figures
486 ));
487 self.render_objects(caption, out);
488 out.push_str("</figcaption>");
489 }
490 out.push_str("</figure>\n");
491 }
492 Element::SpecialBlock { name, content } => { 471 Element::SpecialBlock { name, content } => {
493 out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name))); 472 let open = format!("<div class=\"{}\">\n", escape_attr(name));
494 for child in content { 473 self.render_wrapped(&open, content, "</div>\n", out);
495 self.render_element(child, out);
496 }
497 out.push_str("</div>\n");
498 }
499 Element::VerseBlock(lines) => {
500 out.push_str("<p class=\"verse\">\n");
501 for (i, line) in lines.iter().enumerate() {
502 if i > 0 {
503 out.push_str("<br>\n");
504 }
505 self.render_objects(&crate::parser::inline(line), out);
506 }
507 out.push_str("\n</p>\n");
508 } 474 }
475 Element::VerseBlock(lines) => self.render_verse(lines, out),
509 Element::HorizontalRule => out.push_str("<hr>\n"), 476 Element::HorizontalRule => out.push_str("<hr>\n"),
510 // Definitions are emitted in the footnotes section, not inline. 477 // Definitions are emitted in the footnotes section, not inline.
511 Element::FootnoteDefinition { .. } => {} 478 Element::FootnoteDefinition { .. } => {}
@@ -515,19 +482,49 @@ impl Renderer<'_> {
515 } 482 }
516 } 483 }
517 484
485 fn render_wrapped(&mut self, open: &str, inner: &[Element], close: &str, out: &mut String) {
486 out.push_str(open);
487 for el in inner {
488 self.render_element(el, out);
489 }
490 out.push_str(close);
491 }
492
493 fn render_figure(
494 &mut self,
495 link: &Link,
496 caption: &[Object],
497 attrs: &str,
498 out: &mut String,
499 ) {
500 out.push_str("<figure>");
501 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
502 if !caption.is_empty() {
503 self.figures += 1;
504 out.push_str(&format!(
505 "<figcaption><span class=\"figure-number\">Figure {}: </span>",
506 self.figures
507 ));
508 self.render_objects(caption, out);
509 out.push_str("</figcaption>");
510 }
511 out.push_str("</figure>\n");
512 }
513
514 fn render_verse(&mut self, lines: &[String], out: &mut String) {
515 out.push_str("<p class=\"verse\">\n");
516 for (i, line) in lines.iter().enumerate() {
517 if i > 0 {
518 out.push_str("<br>\n");
519 }
520 self.render_objects(&crate::parser::inline(line), out);
521 }
522 out.push_str("\n</p>\n");
523 }
524
518 fn render_list(&mut self, list: &crate::model::List, out: &mut String) { 525 fn render_list(&mut self, list: &crate::model::List, out: &mut String) {
519 if list.kind == ListKind::Description { 526 if list.kind == ListKind::Description {
520 out.push_str("<dl>\n"); 527 self.render_description_list(list, out);
521 for item in &list.items {
522 out.push_str("<dt>");
523 if let Some(term) = &item.term {
524 self.render_objects(term, out);
525 }
526 out.push_str("</dt>\n<dd>");
527 self.render_item_content(&item.content, out);
528 out.push_str("</dd>\n");
529 }
530 out.push_str("</dl>\n");
531 return; 528 return;
532 } 529 }
533 let tag = if list.kind == ListKind::Ordered { 530 let tag = if list.kind == ListKind::Ordered {
@@ -537,34 +534,52 @@ impl Renderer<'_> {
537 }; 534 };
538 out.push_str(&format!("<{}>\n", tag)); 535 out.push_str(&format!("<{}>\n", tag));
539 for item in &list.items { 536 for item in &list.items {
540 // Org writes a checkbox as literal text, which keeps the third state — `[-]`, 537 self.render_list_item(item, out);
541 // partially done — that a disabled <input> cannot express. The state goes on 538 }
542 // the item, where it can style the whole line. 539 out.push_str(&format!("</{}>\n", tag));
543 let class = item.checkbox.as_ref().map(|cb| match cb { 540 }
544 Checkbox::On => "on", 541
545 Checkbox::Off => "off", 542 fn render_description_list(&mut self, list: &crate::model::List, out: &mut String) {
546 Checkbox::Trans => "trans", 543 out.push_str("<dl>\n");
547 }); 544 for item in &list.items {
548 out.push_str("<li"); 545 out.push_str("<dt>");
549 if let Some(class) = class { 546 if let Some(term) = &item.term {
550 out.push_str(&format!(" class=\"{class}\"")); 547 self.render_objects(term, out);
551 }
552 // `[@4]` restarts the numbering, and HTML says so with `value`.
553 if let Some(n) = item.counter {
554 out.push_str(&format!(" value=\"{n}\""));
555 }
556 out.push('>');
557 if let Some(cb) = &item.checkbox {
558 out.push_str(match cb {
559 Checkbox::On => "<code>[X]</code> ",
560 Checkbox::Off => "<code>[&nbsp;]</code> ",
561 Checkbox::Trans => "<code>[-]</code> ",
562 });
563 } 548 }
549 out.push_str("</dt>\n<dd>");
564 self.render_item_content(&item.content, out); 550 self.render_item_content(&item.content, out);
565 out.push_str("</li>\n"); 551 out.push_str("</dd>\n");
566 } 552 }
567 out.push_str(&format!("</{}>\n", tag)); 553 out.push_str("</dl>\n");
554 }
555
556 fn render_list_item(&mut self, item: &crate::model::ListItem, out: &mut String) {
557 // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
558 // partially done — that a disabled <input> cannot express. The state goes on
559 // the item, where it can style the whole line.
560 let class = item.checkbox.as_ref().map(|cb| match cb {
561 Checkbox::On => "on",
562 Checkbox::Off => "off",
563 Checkbox::Trans => "trans",
564 });
565 out.push_str("<li");
566 if let Some(class) = class {
567 out.push_str(&format!(" class=\"{class}\""));
568 }
569 // `[@4]` restarts the numbering, and HTML says so with `value`.
570 if let Some(n) = item.counter {
571 out.push_str(&format!(" value=\"{n}\""));
572 }
573 out.push('>');
574 if let Some(cb) = &item.checkbox {
575 out.push_str(match cb {
576 Checkbox::On => "<code>[X]</code> ",
577 Checkbox::Off => "<code>[&nbsp;]</code> ",
578 Checkbox::Trans => "<code>[-]</code> ",
579 });
580 }
581 self.render_item_content(&item.content, out);
582 out.push_str("</li>\n");
568 } 583 }
569 584
570 /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than 585 /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than
@@ -598,56 +613,48 @@ impl Renderer<'_> {
598 .position(|r| matches!(r, TableRow::Rule)); 613 .position(|r| matches!(r, TableRow::Rule));
599 out.push_str("<table>\n"); 614 out.push_str("<table>\n");
600 if !table.caption.is_empty() { 615 if !table.caption.is_empty() {
601 self.tables += 1; 616 self.render_table_caption(&table.caption, out);
602 out.push_str(&format!(
603 "<caption><span class=\"table-number\">Table {}: </span>",
604 self.tables
605 ));
606 let caption = table.caption.clone();
607 self.render_objects(&caption, out);
608 out.push_str("</caption>\n");
609 } 617 }
610 let mut wrote_body = false; 618 let (head, body) = match rule_at {
611 let mut in_body = rule_at.is_none(); 619 Some(r) => (&table.rows[..r], &table.rows[r + 1..]),
612 for (idx, row) in table.rows.iter().enumerate() { 620 None => (&table.rows[..0], &table.rows[..]),
613 match row { 621 };
614 TableRow::Rule => { 622 self.render_band("thead", "th", head, out);
615 if Some(idx) == rule_at { 623 self.render_band("tbody", "td", body, out);
616 in_body = true; 624 out.push_str("</table>\n");
617 } 625 }
618 continue; 626
619 } 627 fn render_table_caption(&mut self, caption: &[Object], out: &mut String) {
620 TableRow::Cells(cells) => { 628 self.tables += 1;
621 let (open, cell_tag) = if in_body { 629 out.push_str(&format!(
622 if !wrote_body { 630 "<caption><span class=\"table-number\">Table {}: </span>",
623 wrote_body = true; 631 self.tables
624 ("<tbody>\n<tr>", "td") 632 ));
625 } else { 633 self.render_objects(caption, out);
626 ("<tr>", "td") 634 out.push_str("</caption>\n");
627 } 635 }
628 } else { 636
629 ("<thead>\n<tr>", "th") 637 /// One `<thead>` or `<tbody>` of `rows`, skipping rule rows. Nothing at all when
630 }; 638 /// there are no cell rows.
631 out.push_str(open); 639 fn render_band(&mut self, band: &str, cell_tag: &str, rows: &[TableRow], out: &mut String) {
632 for cell in cells { 640 let mut opened = false;
633 out.push_str(&format!("<{}>", cell_tag)); 641 for row in rows {
634 self.render_objects(cell, out); 642 let TableRow::Cells(cells) = row else { continue };
635 out.push_str(&format!("</{}>", cell_tag)); 643 if !opened {
636 } 644 out.push_str(&format!("<{band}>\n"));
637 out.push_str("</tr>\n"); 645 opened = true;
638 // Close the header band right after its last row. 646 }
639 if !in_body 647 out.push_str("<tr>");
640 && rule_at.map(|r| idx + 1 == r).unwrap_or(false) 648 for cell in cells {
641 { 649 out.push_str(&format!("<{}>", cell_tag));
642 out.push_str("</thead>\n"); 650 self.render_objects(cell, out);
643 } 651 out.push_str(&format!("</{}>", cell_tag));
644 }
645 } 652 }
653 out.push_str("</tr>\n");
646 } 654 }
647 if wrote_body { 655 if opened {
648 out.push_str("</tbody>\n"); 656 out.push_str(&format!("</{band}>\n"));
649 } 657 }
650 out.push_str("</table>\n");
651 } 658 }
652 659
653 /// Plain text as HTML: escaped, then org's export-time text conversions. 660 /// Plain text as HTML: escaped, then org's export-time text conversions.
@@ -806,56 +813,72 @@ fn image_tag(link: &Link, attrs: &str, alt: &str) -> String {
806/// the next `:key` token and may be double-quoted to include spaces. A malformed spec 813/// the next `:key` token and may be double-quoted to include spaces. A malformed spec
807/// contributes nothing rather than emitting broken markup. 814/// contributes nothing rather than emitting broken markup.
808fn attr_html(spec: &str) -> Vec<(String, String)> { 815fn attr_html(spec: &str) -> Vec<(String, String)> {
809 let mut out = Vec::new(); 816 let mut parser = AttrParser::default();
810 let mut key: Option<&str> = None;
811 let mut value = String::new();
812 let mut quoted: Option<String> = None;
813
814 let flush = |out: &mut Vec<(String, String)>, key: &mut Option<&str>, value: &mut String| {
815 if let Some(k) = key.take() {
816 out.push((k.to_string(), value.trim().to_string()));
817 }
818 value.clear();
819 };
820
821 for token in spec.split_whitespace() { 817 for token in spec.split_whitespace() {
818 parser.token(token);
819 }
820 parser.finish()
821}
822
823/// [`attr_html`]'s state between tokens.
824#[derive(Default)]
825struct AttrParser<'a> {
826 out: Vec<(String, String)>,
827 key: Option<&'a str>,
828 value: String,
829 /// A double-quoted value still waiting for its closing quote.
830 quoted: Option<String>,
831}
832
833impl<'a> AttrParser<'a> {
834 fn token(&mut self, token: &'a str) {
822 // Inside a quoted value, everything up to the closing quote is literal. 835 // Inside a quoted value, everything up to the closing quote is literal.
823 if let Some(buf) = &mut quoted { 836 if let Some(buf) = &mut self.quoted {
824 buf.push(' '); 837 buf.push(' ');
825 buf.push_str(token.trim_end_matches('"')); 838 buf.push_str(token.trim_end_matches('"'));
826 if token.ends_with('"') { 839 if token.ends_with('"') {
827 value = quoted.take().expect("quoted value in progress"); 840 self.value = self.quoted.take().expect("quoted value in progress");
828 } 841 }
829 continue; 842 return;
830 } 843 }
831 if let Some(k) = token.strip_prefix(':') { 844 if let Some(k) = token.strip_prefix(':') {
832 flush(&mut out, &mut key, &mut value); 845 self.flush();
833 if !k.is_empty() { 846 if !k.is_empty() {
834 key = Some(k); 847 self.key = Some(k);
835 } 848 }
836 continue; 849 return;
837 } 850 }
838 if key.is_none() { 851 if self.key.is_none() {
839 continue; 852 return;
840 } 853 }
841 if let Some(rest) = token.strip_prefix('"') { 854 if let Some(rest) = token.strip_prefix('"') {
842 if let Some(inner) = rest.strip_suffix('"') { 855 if let Some(inner) = rest.strip_suffix('"') {
843 value = inner.to_string(); 856 self.value = inner.to_string();
844 } else { 857 } else {
845 quoted = Some(rest.to_string()); 858 self.quoted = Some(rest.to_string());
846 } 859 }
847 continue; 860 return;
848 } 861 }
849 if !value.is_empty() { 862 if !self.value.is_empty() {
850 value.push(' '); 863 self.value.push(' ');
851 } 864 }
852 value.push_str(token); 865 self.value.push_str(token);
853 } 866 }
854 if let Some(buf) = quoted { 867
855 value = buf; 868 fn flush(&mut self) {
869 if let Some(k) = self.key.take() {
870 self.out.push((k.to_string(), self.value.trim().to_string()));
871 }
872 self.value.clear();
873 }
874
875 fn finish(mut self) -> Vec<(String, String)> {
876 if let Some(buf) = self.quoted.take() {
877 self.value = buf;
878 }
879 self.flush();
880 self.out
856 } 881 }
857 flush(&mut out, &mut key, &mut value);
858 out
859} 882}
860 883
861/// `<time>` markup for a timestamp. A range emits both endpoints; a same-day range 884/// `<time>` markup for a timestamp. A range emits both endpoints; a same-day range
@@ -1159,3 +1182,4 @@ fn escape_attr(s: &str) -> String {
1159 out = out.replace('"', "&quot;"); 1182 out = out.replace('"', "&quot;");
1160 out 1183 out
1161} 1184}
1185
src/resolve.rs +25 −21
@@ -14,7 +14,7 @@
14use camino::Utf8Path; 14use camino::Utf8Path;
15 15
16use crate::index::{SymbolTable, TargetId}; 16use crate::index::{SymbolTable, TargetId};
17use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow}; 17use crate::model::{Document, Element, Link, LinkTarget, List, Object, Section, Table, TableRow};
18use crate::util::{normalize_link_path, output_path, output_url}; 18use crate::util::{normalize_link_path, output_path, output_url};
19 19
20/// A document whose links have been rewritten to concrete URLs. 20/// A document whose links have been rewritten to concrete URLs.
@@ -95,26 +95,8 @@ impl Cx<'_> {
95 fn element(&mut self, el: &mut Element) { 95 fn element(&mut self, el: &mut Element) {
96 match el { 96 match el {
97 Element::Paragraph(objs) => self.objects(objs), 97 Element::Paragraph(objs) => self.objects(objs),
98 Element::List(list) => { 98 Element::List(list) => self.list(list),
99 for item in &mut list.items { 99 Element::Table(table) => self.table(table),
100 if let Some(term) = &mut item.term {
101 self.objects(term);
102 }
103 for e in &mut item.content {
104 self.element(e);
105 }
106 }
107 }
108 Element::Table(table) => {
109 self.objects(&mut table.caption);
110 for row in &mut table.rows {
111 if let TableRow::Cells(cells) = row {
112 for cell in cells {
113 self.objects(cell);
114 }
115 }
116 }
117 }
118 Element::Figure { caption, .. } => self.objects(caption), 100 Element::Figure { caption, .. } => self.objects(caption),
119 Element::QuoteBlock(inner) 101 Element::QuoteBlock(inner)
120 | Element::CenterBlock(inner) 102 | Element::CenterBlock(inner)
@@ -129,6 +111,28 @@ impl Cx<'_> {
129 } 111 }
130 } 112 }
131 113
114 fn list(&mut self, list: &mut List) {
115 for item in &mut list.items {
116 if let Some(term) = &mut item.term {
117 self.objects(term);
118 }
119 for e in &mut item.content {
120 self.element(e);
121 }
122 }
123 }
124
125 fn table(&mut self, table: &mut Table) {
126 self.objects(&mut table.caption);
127 for row in &mut table.rows {
128 if let TableRow::Cells(cells) = row {
129 for cell in cells {
130 self.objects(cell);
131 }
132 }
133 }
134 }
135
132 fn objects(&mut self, objs: &mut [Object]) { 136 fn objects(&mut self, objs: &mut [Object]) {
133 for obj in objs { 137 for obj in objs {
134 match obj { 138 match obj {
src/site.rs +508 −362
@@ -145,6 +145,17 @@ struct Listing {
145 entry_hashes: Vec<ContentHash>, 145 entry_hashes: Vec<ContentHash>,
146} 146}
147 147
148/// One listing before pagination: everything [`push_paginated`] needs besides the
149/// collection it came from.
150struct ListingPage {
151 output: Utf8PathBuf,
152 title: String,
153 entries: Vec<PageContext>,
154 entry_hashes: Vec<ContentHash>,
155 group: Option<GroupContext>,
156 groups: Vec<GroupContext>,
157}
158
148/// Split one listing's entries across numbered pages, appending each as its own 159/// Split one listing's entries across numbered pages, appending each as its own
149/// [`Listing`]. 160/// [`Listing`].
150/// 161///
@@ -152,17 +163,15 @@ struct Listing {
152/// changes — only pages 2..N are named by `paginate_output`. An empty listing still 163/// changes — only pages 2..N are named by `paginate_output`. An empty listing still
153/// emits page 1, because a section that exists but has nothing in it should be a page 164/// emits page 1, because a section that exists but has nothing in it should be a page
154/// saying so rather than a 404. 165/// saying so rather than a 404.
155#[allow(clippy::too_many_arguments)] 166fn push_paginated(listings: &mut Vec<Listing>, collection: &config::Collection, page: ListingPage) {
156fn push_paginated( 167 let ListingPage {
157 listings: &mut Vec<Listing>, 168 output,
158 collection: &config::Collection, 169 title,
159 output: Utf8PathBuf, 170 entries,
160 title: String, 171 entry_hashes,
161 entries: Vec<PageContext>, 172 group,
162 entry_hashes: Vec<ContentHash>, 173 groups,
163 group: Option<GroupContext>, 174 } = page;
164 groups: Vec<GroupContext>,
165) {
166 let per_page = collection.paginate; 175 let per_page = collection.paginate;
167 if per_page == 0 { 176 if per_page == 0 {
168 listings.push(Listing { 177 listings.push(Listing {
@@ -234,6 +243,138 @@ fn push_paginated(
234 } 243 }
235} 244}
236 245
246/// The pages a collection lists, in its configured order.
247fn collection_entries(collection: &config::Collection, preps: &[PagePrep]) -> Vec<PageContext> {
248 let mut entries: Vec<PageContext> = preps
249 .iter()
250 .filter(|p| {
251 collection.source.as_str().is_empty() || p.source.starts_with(&collection.source)
252 })
253 .map(|p| p.context.clone())
254 .collect();
255
256 // Sort ascending first, then reverse for `desc`, so the two orders are exact
257 // mirrors of one another rather than two separately-written comparisons.
258 match collection.sort {
259 SortKey::Title => entries.sort_by(|a, b| a.title.cmp(&b.title)),
260 SortKey::Path => entries.sort_by(|a, b| a.url.cmp(&b.url)),
261 SortKey::Date => entries.sort_by(compare_dates),
262 }
263 if collection.order == SortOrder::Desc {
264 entries.reverse();
265 }
266 entries
267}
268
269/// Ascending date order. Undated pages sort last in the final order regardless of
270/// direction: a draft with no date should not lead an archive.
271fn compare_dates(a: &PageContext, b: &PageContext) -> std::cmp::Ordering {
272 // Date *and* time: org records when a note was written, and two notes
273 // from the same day have an order that the day alone cannot express.
274 let key = |p: &PageContext| {
275 p.date_iso.as_ref().map(|d| {
276 let time = p
277 .date
278 .as_deref()
279 .and_then(iso_time)
280 .unwrap_or_else(|| "00:00:00".to_string());
281 format!("{d}T{time}")
282 })
283 };
284 match (key(a), key(b)) {
285 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
286 (Some(_), None) => std::cmp::Ordering::Greater,
287 (None, Some(_)) => std::cmp::Ordering::Less,
288 (None, None) => a.url.cmp(&b.url),
289 }
290}
291
292/// Each distinct `group_by` term, sorted, with its members in `entries` order.
293fn group_members(
294 entries: &[PageContext],
295 group_by: &str,
296) -> (Vec<String>, HashMap<String, Vec<PageContext>>) {
297 let mut terms: Vec<String> = Vec::new();
298 let mut members: HashMap<String, Vec<PageContext>> = HashMap::new();
299 for entry in entries {
300 for term in group_terms(entry, group_by) {
301 if !members.contains_key(&term) {
302 terms.push(term.clone());
303 }
304 members.entry(term).or_default().push(entry.clone());
305 }
306 }
307 // Terms are discovered in page order, which is arbitrary from a reader's point of
308 // view; sort so a tag index reads alphabetically and hashes deterministically.
309 terms.sort();
310 (terms, members)
311}
312
313/// One [`GroupContext`] per term, rejecting terms that cannot name a page.
314fn group_contexts(
315 collection: &config::Collection,
316 terms: &[String],
317 members: &HashMap<String, Vec<PageContext>>,
318) -> Result<Vec<GroupContext>> {
319 let mut groups: Vec<GroupContext> = Vec::new();
320 let mut slugs: HashMap<String, String> = HashMap::new();
321 for term in terms {
322 let slug = slugify(term);
323 if slug.is_empty() {
324 anyhow::bail!(
325 "the {} value {term:?} has no URL-safe form; it cannot name a page",
326 collection.group_by
327 );
328 }
329 // `C++` and `C ++` both slugify to `c`, and one would silently overwrite the
330 // other's page.
331 if let Some(other) = slugs.insert(slug.clone(), term.clone()) {
332 anyhow::bail!(
333 "the {} values {other:?} and {term:?} both become {slug:?} in a URL; \
334 rename one so their pages do not collide",
335 collection.group_by
336 );
337 }
338 groups.push(GroupContext {
339 name: term.clone(),
340 slug: slug.clone(),
341 url: if collection.output.as_str().is_empty() {
342 String::new()
343 } else {
344 collection
345 .output
346 .as_str()
347 .replace(config::GROUP_PLACEHOLDER, &slug)
348 }
349 .to_string(),
350 count: members.get(term).map(Vec::len).unwrap_or(0),
351 });
352 }
353 Ok(groups)
354}
355
356/// Reject generated pages that would overwrite a real page or one another.
357fn check_listing_outputs(listings: &[Listing], preps: &[PagePrep]) -> Result<()> {
358 // A generated page writing over a real page would silently replace it. Group pages
359 // make this easy to hit by accident, since their paths come from content.
360 for listing in listings {
361 if let Some(clash) = preps.iter().find(|p| p.output == listing.output) {
362 anyhow::bail!(
363 "collection output {} collides with the page built from {}",
364 listing.output,
365 clash.source
366 );
367 }
368 }
369 let mut claimed: HashMap<&Utf8PathBuf, ()> = HashMap::new();
370 for listing in listings {
371 if claimed.insert(&listing.output, ()).is_some() {
372 anyhow::bail!("two generated pages both write to {}", listing.output);
373 }
374 }
375 Ok(())
376}
377
237/// Build the listing pages a config asks for, each with its entries sorted. 378/// Build the listing pages a config asks for, each with its entries sorted.
238fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { 379fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
239 let mut listings = Vec::new(); 380 let mut listings = Vec::new();
@@ -248,111 +389,29 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
248 .collect() 389 .collect()
249 }; 390 };
250 for collection in &config.collections { 391 for collection in &config.collections {
251 let mut entries: Vec<PageContext> = preps 392 let entries = collection_entries(collection, preps);
252 .iter()
253 .filter(|p| {
254 collection.source.as_str().is_empty() || p.source.starts_with(&collection.source)
255 })
256 .map(|p| p.context.clone())
257 .collect();
258
259 // Sort ascending first, then reverse for `desc`, so the two orders are exact
260 // mirrors of one another rather than two separately-written comparisons.
261 match collection.sort {
262 SortKey::Title => entries.sort_by(|a, b| a.title.cmp(&b.title)),
263 SortKey::Path => entries.sort_by(|a, b| a.url.cmp(&b.url)),
264 // Undated pages sort last in the final order regardless of direction: a
265 // draft with no date should not lead an archive.
266 SortKey::Date => entries.sort_by(|a, b| {
267 // Date *and* time: org records when a note was written, and two notes
268 // from the same day have an order that the day alone cannot express.
269 let key = |p: &PageContext| {
270 p.date_iso.as_ref().map(|d| {
271 let time = p
272 .date
273 .as_deref()
274 .and_then(iso_time)
275 .unwrap_or_else(|| "00:00:00".to_string());
276 format!("{d}T{time}")
277 })
278 };
279 match (key(a), key(b)) {
280 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
281 (Some(_), None) => std::cmp::Ordering::Greater,
282 (None, Some(_)) => std::cmp::Ordering::Less,
283 (None, None) => a.url.cmp(&b.url),
284 }
285 }),
286 }
287 if collection.order == SortOrder::Desc {
288 entries.reverse();
289 }
290 393
291 if collection.group_by.is_empty() { 394 if collection.group_by.is_empty() {
292 let entry_hashes = hashes_of(&entries); 395 let entry_hashes = hashes_of(&entries);
293 push_paginated( 396 push_paginated(
294 &mut listings, 397 &mut listings,
295 collection, 398 collection,
296 collection.output.clone(), 399 ListingPage {
297 collection.title.clone(), 400 output: collection.output.clone(),
298 entries, 401 title: collection.title.clone(),
299 entry_hashes, 402 entries,
300 None, 403 entry_hashes,
301 Vec::new(), 404 group: None,
405 groups: Vec::new(),
406 },
302 ); 407 );
303 continue; 408 continue;
304 } 409 }
305 410
306 // Grouped: one page per distinct term. `entries` is already sorted, and grouping 411 // Grouped: one page per distinct term. `entries` is already sorted, and grouping
307 // preserves that order within each group. 412 // preserves that order within each group.
308 let mut terms: Vec<String> = Vec::new(); 413 let (terms, members) = group_members(&entries, &collection.group_by);
309 let mut members: HashMap<String, Vec<PageContext>> = HashMap::new(); 414 let groups = group_contexts(collection, &terms, &members)?;
310 for entry in &entries {
311 for term in group_terms(entry, &collection.group_by) {
312 if !members.contains_key(&term) {
313 terms.push(term.clone());
314 }
315 members.entry(term).or_default().push(entry.clone());
316 }
317 }
318 // Terms are discovered in page order, which is arbitrary from a reader's point of
319 // view; sort so a tag index reads alphabetically and hashes deterministically.
320 terms.sort();
321
322 let mut groups: Vec<GroupContext> = Vec::new();
323 let mut slugs: HashMap<String, String> = HashMap::new();
324 for term in &terms {
325 let slug = slugify(term);
326 if slug.is_empty() {
327 anyhow::bail!(
328 "the {} value {term:?} has no URL-safe form; it cannot name a page",
329 collection.group_by
330 );
331 }
332 // `C++` and `C ++` both slugify to `c`, and one would silently overwrite the
333 // other's page.
334 if let Some(other) = slugs.insert(slug.clone(), term.clone()) {
335 anyhow::bail!(
336 "the {} values {other:?} and {term:?} both become {slug:?} in a URL; \
337 rename one so their pages do not collide",
338 collection.group_by
339 );
340 }
341 groups.push(GroupContext {
342 name: term.clone(),
343 slug: slug.clone(),
344 url: if collection.output.as_str().is_empty() {
345 String::new()
346 } else {
347 collection
348 .output
349 .as_str()
350 .replace(config::GROUP_PLACEHOLDER, &slug)
351 }
352 .to_string(),
353 count: members.get(term).map(Vec::len).unwrap_or(0),
354 });
355 }
356 415
357 if !collection.output.as_str().is_empty() { 416 if !collection.output.as_str().is_empty() {
358 for group in &groups { 417 for group in &groups {
@@ -361,19 +420,21 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
361 push_paginated( 420 push_paginated(
362 &mut listings, 421 &mut listings,
363 collection, 422 collection,
364 Utf8PathBuf::from(&group.url), 423 ListingPage {
365 collection 424 output: Utf8PathBuf::from(&group.url),
366 .title 425 title: collection
367 .replace(config::GROUP_PLACEHOLDER, &group.name), 426 .title
368 members_of, 427 .replace(config::GROUP_PLACEHOLDER, &group.name),
369 entry_hashes, 428 entries: members_of,
370 Some(group.clone()), 429 entry_hashes,
371 // Deliberately not the whole group list. A page that can see every 430 group: Some(group.clone()),
372 // group depends on every group, so one new post would re-render every 431 // Deliberately not the whole group list. A page that can see every
373 // tag page — cost that scales with tag count, to support a tag cloud 432 // group depends on every group, so one new post would re-render every
374 // nobody has asked for. A tag page depends on its own posts, and the 433 // tag page — cost that scales with tag count, to support a tag cloud
375 // group index is where the group list belongs. 434 // nobody has asked for. A tag page depends on its own posts, and the
376 Vec::new(), 435 // group index is where the group list belongs.
436 groups: Vec::new(),
437 },
377 ); 438 );
378 } 439 }
379 } 440 }
@@ -393,23 +454,7 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
393 } 454 }
394 } 455 }
395 456
396 // A generated page writing over a real page would silently replace it. Group pages 457 check_listing_outputs(&listings, preps)?;
397 // make this easy to hit by accident, since their paths come from content.
398 for listing in &listings {
399 if let Some(clash) = preps.iter().find(|p| p.output == listing.output) {
400 anyhow::bail!(
401 "collection output {} collides with the page built from {}",
402 listing.output,
403 clash.source
404 );
405 }
406 }
407 let mut claimed: HashMap<&Utf8PathBuf, ()> = HashMap::new();
408 for listing in &listings {
409 if claimed.insert(&listing.output, ()).is_some() {
410 anyhow::bail!("two generated pages both write to {}", listing.output);
411 }
412 }
413 Ok(listings) 458 Ok(listings)
414} 459}
415 460
@@ -820,6 +865,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
820 check_page_templates(&templater, &preps)?; 865 check_page_templates(&templater, &preps)?;
821 let site = site_context(&config); 866 let site = site_context(&config);
822 let listing = page_listing(&config, &preps); 867 let listing = page_listing(&config, &preps);
868 let renderer = PageRenderer {
869 templater: &templater,
870 highlighter: &highlighter,
871 site: &site,
872 pages: listing.as_deref(),
873 config: &config,
874 preps: &preps,
875 };
823 876
824 let mut pages = Vec::new(); 877 let mut pages = Vec::new();
825 let mut broken = Vec::new(); 878 let mut broken = Vec::new();
@@ -827,7 +880,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
827 for t in &p.broken { 880 for t in &p.broken {
828 broken.push((p.source.clone(), t.clone())); 881 broken.push((p.source.clone(), t.clone()));
829 } 882 }
830 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &config, p)?; 883 let html = renderer.render_page(p)?;
831 pages.push(BuiltPage { 884 pages.push(BuiltPage {
832 source: p.source.clone(), 885 source: p.source.clone(),
833 output: p.output.clone(), 886 output: p.output.clone(),
@@ -868,29 +921,64 @@ fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>>
868 .then(|| preps.iter().map(|p| p.context.clone()).collect()) 921 .then(|| preps.iter().map(|p| p.context.clone()).collect())
869} 922}
870 923
871/// RENDER + TEMPLATE one prepared page into its final HTML string. 924/// What every page render in one build shares.
872#[allow(clippy::too_many_arguments)] 925struct PageRenderer<'a> {
873fn render_page( 926 templater: &'a Templater,
874 templater: &Templater, 927 highlighter: &'a SyntectHighlighter,
875 highlighter: &SyntectHighlighter, 928 site: &'a SiteContext,
876 site: &SiteContext, 929 /// The `pages` list templates see (see [`page_listing`]).
877 pages: Option<&[PageContext]>, 930 pages: Option<&'a [PageContext]>,
878 config: &Config, 931 config: &'a Config,
879 p: &PagePrep, 932 preps: &'a [PagePrep],
880) -> Result<String> { 933}
881 let opts = render_options(config); 934
882 let Html(fragment) = render_with(&p.resolved, highlighter, &opts); 935impl PageRenderer<'_> {
883 // Relative to the *output* path, since `#+SLUG:` can move a page between depths. 936 /// RENDER + TEMPLATE one prepared page into its final HTML string.
884 let root = relative_root(&p.output); 937 fn render_page(&self, p: &PagePrep) -> Result<String> {
885 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); 938 let opts = render_options(self.config);
886 let theme = theme_href(config, &root); 939 let Html(fragment) = render_with(&p.resolved, self.highlighter, &opts);
887 let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root); 940 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
888 ctx.body = &fragment; 941 let root = relative_root(&p.output);
889 ctx.theme = &theme; 942 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
890 ctx.pages = pages; 943 let theme = theme_href(self.config, &root);
891 templater 944 let mut ctx = RenderContext::new(self.site, &p.context, &p.nav, &stylesheet, &root);
892 .render(&p.template, &ctx) 945 ctx.body = &fragment;
893 .with_context(|| format!("templating {} through {}", p.source, p.template)) 946 ctx.theme = &theme;
947 ctx.pages = self.pages;
948 self.templater
949 .render(&p.template, &ctx)
950 .with_context(|| format!("templating {} through {}", p.source, p.template))
951 }
952
953 /// TEMPLATE one generated listing page into its final HTML string.
954 fn render_listing(&self, listing: &Listing) -> Result<String> {
955 let root = relative_root(&listing.output);
956 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
957 let nav = listing_nav(self.preps, &listing.output);
958 let page_ctx = listing_context(listing);
959 // Bodies are rendered here rather than when the listing was built, so a
960 // cached feed costs nothing. This is the only place a page is rendered twice.
961 let with_content = listing.include_content.then(|| {
962 entries_with_content(&listing.entries, self.preps, self.highlighter, self.config)
963 });
964 let theme = theme_href(self.config, &root);
965 let mut ctx = RenderContext::new(self.site, &page_ctx, &nav, &stylesheet, &root);
966 ctx.theme = &theme;
967 ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries));
968 ctx.group = listing.group.as_ref();
969 ctx.groups = &listing.groups;
970 ctx.paginator = listing.paginator.as_ref();
971 self.templater
972 .render(&listing.template, &ctx)
973 .with_context(|| {
974 format!(
975 "rendering collection {} with template {} (available: {})",
976 listing.output,
977 listing.template,
978 self.templater.names().join(", ")
979 )
980 })
981 }
894} 982}
895 983
896/// Site-root-relative name of the generated syntax stylesheet. Every page links to it. 984/// Site-root-relative name of the generated syntax stylesheet. Every page links to it.
@@ -958,13 +1046,7 @@ fn escape_xml(s: &str) -> String {
958/// `render_key` changed or that link into a changed file's targets; reuses the on-disk 1046/// `render_key` changed or that link into a changed file's targets; reuses the on-disk
959/// output of everything else; persists an updated cache manifest. 1047/// output of everything else; persists an updated cache manifest.
960pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { 1048pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
961 let mut cfg = match &opts.config_path { 1049 let cfg = build_config(src, opts)?;
962 Some(path) => Config::load_file(path)?,
963 None => Config::load(src)?,
964 };
965 cfg.validate()?;
966 // The flag turns drafts on; it never turns off a config that asked for them.
967 cfg.build.drafts |= opts.drafts;
968 1050
969 // Create the output directory up front so it can be recognised and excluded when it 1051 // Create the output directory up front so it can be recognised and excluded when it
970 // lives inside the source tree. 1052 // lives inside the source tree.
@@ -977,21 +1059,127 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
977 check_page_templates(&templater, &preps)?; 1059 check_page_templates(&templater, &preps)?;
978 let syntax_css = render::syntax_stylesheet(&cfg.highlight)?; 1060 let syntax_css = render::syntax_stylesheet(&cfg.highlight)?;
979 1061
980 // The global hash classes (spec §4.1): a change in any invalidates the site. The 1062 let cfg_hash = combine(config_hash(&cfg), structure_hash(&cfg, &preps));
981 // config hash is combined with a site-structure hash covering the global chrome each 1063 // Per template rather than per site: a page's render key covers the layout it uses
982 // page carries, so a change to that chrome re-renders the pages showing it. 1064 // and that layout's own includes, so editing `feed.xml` re-renders the feed.
983 // 1065 let tmpl_hash_for = |name: &str| template_hash(&templater.sources_for(name));
984 // Which pages belong in that hash depends on what a template can *see*. Normally it 1066
985 // is the nav only — a nested page cannot change another page's nav, so adding a blog 1067 let listings = build_listings(&cfg, &preps)?;
986 // post should render one page, not the site. But `expose_page_list` hands every 1068 let mut keys = page_keys(&preps, &symbols, cfg_hash, &tmpl_hash_for);
987 // template every page's metadata, and then any page's output really can depend on 1069
988 // any other page, so the hash has to widen to match. Keyed on output paths, since a 1070 // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None
989 // `#+SLUG:` change moves a page's URL without moving its source. 1071 // ⇒ full rebuild (spec §4.5).
1072 let prior = if opts.no_cache {
1073 None
1074 } else {
1075 incremental::load_manifest(out)
1076 };
1077
1078 let rebuild = compute_rebuild_set(&preps, &keys, cfg_hash, out, prior.as_ref());
1079 if let Some(prior) = &prior {
1080 remove_deleted_outputs(prior, &preps, &listings, out);
1081 }
1082
1083 let highlighter = SyntectHighlighter::with_syntaxes(Some(&src.join(&cfg.highlight.syntaxes_dir)));
1084 let site = site_context(&cfg);
1085 let listing = page_listing(&cfg, &preps);
1086 let renderer = PageRenderer {
1087 templater: &templater,
1088 highlighter: &highlighter,
1089 site: &site,
1090 pages: listing.as_deref(),
1091 config: &cfg,
1092 preps: &preps,
1093 };
1094 let mut report = emit_pages(&renderer, &rebuild, out)?;
1095
1096 // Generated listing pages (spec §2.1 EMIT). A listing has no source file, so it is
1097 // cached on the one thing it actually depends on: the entries it lists. Adding a post
1098 // therefore re-renders that section's index and nothing else — the same precision the
1099 // rest of the build gets from content hashing.
1100 for listing in &listings {
1101 let key = combine(
1102 listing_entries_hash(listing),
1103 combine(cfg_hash, tmpl_hash_for(&listing.template)),
1104 );
1105 let dest = out.join(&listing.output);
1106 let cached = prior
1107 .as_ref()
1108 .and_then(|m| m.pages.get(&listing.output))
1109 .map(|rec| rec.render_key == key)
1110 .unwrap_or(false);
1111
1112 report.pages.push(listing.output.clone());
1113 if cached && dest.exists() {
1114 report.skipped.push(listing.output.clone());
1115 } else {
1116 create_parent(&dest)?;
1117 let html = renderer.render_listing(listing)?;
1118 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
1119 report.rendered.push(listing.output.clone());
1120 }
1121
1122 keys.records.push((
1123 listing.output.clone(),
1124 PageRecord {
1125 content_hash: key,
1126 render_key: key,
1127 output_path: listing.output.clone(),
1128 },
1129 key,
1130 ));
1131 }
1132
1133 write_site_files(&cfg, &preps, &syntax_css, &report.pages, out)?;
1134 copy_assets(&assets, out, &mut report)?;
1135
1136 // Persist the manifest for the next build.
1137 let manifest = Manifest {
1138 format_version: CACHE_FORMAT_VERSION,
1139 config_hash: Some(cfg_hash),
1140 pages: keys
1141 .records
1142 .into_iter()
1143 .map(|(src_path, rec, _)| (src_path, rec))
1144 .collect(),
1145 graph: keys.graph,
1146 };
1147 incremental::save_manifest(out, &manifest)
1148 .with_context(|| format!("writing cache manifest under {out}"))?;
1149
1150 report_warnings(&report, opts.strict)?;
1151 Ok(report)
1152}
1153
1154/// The config a build runs with: `--config` or the source directory's, validated, with
1155/// `--drafts` applied.
1156fn build_config(src: &Utf8Path, opts: &BuildOptions) -> Result<Config> {
1157 let mut cfg = match &opts.config_path {
1158 Some(path) => Config::load_file(path)?,
1159 None => Config::load(src)?,
1160 };
1161 cfg.validate()?;
1162 // The flag turns drafts on; it never turns off a config that asked for them.
1163 cfg.build.drafts |= opts.drafts;
1164 Ok(cfg)
1165}
1166
1167/// The site-structure half of the global hash class (spec §4.1): a change in any
1168/// invalidates the site. It covers the global chrome each page carries, so a change to
1169/// that chrome re-renders the pages showing it.
1170///
1171/// Which pages belong in that hash depends on what a template can *see*. Normally it
1172/// is the nav only — a nested page cannot change another page's nav, so adding a blog
1173/// post should render one page, not the site. But `expose_page_list` hands every
1174/// template every page's metadata, and then any page's output really can depend on
1175/// any other page, so the hash has to widen to match. Keyed on output paths, since a
1176/// `#+SLUG:` change moves a page's URL without moving its source.
1177fn structure_hash(cfg: &Config, preps: &[PagePrep]) -> Hash {
990 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps 1178 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps
991 .iter() 1179 .iter()
992 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone())) 1180 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
993 .collect(); 1181 .collect();
994 let structure_hash = if cfg.templates.expose_page_list { 1182 if cfg.templates.expose_page_list {
995 let entries: Vec<(String, String)> = all_pages 1183 let entries: Vec<(String, String)> = all_pages
996 .iter() 1184 .iter()
997 .map(|(_, out, title)| (out.to_string(), title.clone())) 1185 .map(|(_, out, title)| (out.to_string(), title.clone()))
@@ -1000,27 +1188,38 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1000 } else { 1188 } else {
1001 // The same selection the nav itself is built from, so the two can never drift. 1189 // The same selection the nav itself is built from, so the two can never drift.
1002 // Hashed in order, because the nav's order is itself part of every page. 1190 // Hashed in order, because the nav's order is itself part of every page.
1003 let entries: Vec<(String, String)> = nav_selection(&cfg, &nav_candidates(&cfg, &all_pages)) 1191 let entries: Vec<(String, String)> = nav_selection(cfg, &nav_candidates(cfg, &all_pages))
1004 .into_iter() 1192 .into_iter()
1005 .map(|c| (c.output.to_string(), c.title.clone())) 1193 .map(|c| (c.output.to_string(), c.title.clone()))
1006 .collect(); 1194 .collect();
1007 site_structure_hash_ordered(&entries) 1195 site_structure_hash_ordered(&entries)
1008 }; 1196 }
1009 let cfg_hash = combine(config_hash(&cfg), structure_hash); 1197}
1010 // Per template rather than per site: a page's render key covers the layout it uses
1011 // and that layout's own includes, so editing `feed.xml` re-renders the feed.
1012 let tmpl_hash_for = |name: &str| template_hash(&templater.sources_for(name));
1013 1198
1014 // Compose each page's render key and record its dependency edges. 1199/// Each page's composed render key and dependency edges.
1015 let mut new_graph = DepGraph::default(); 1200struct PageKeys {
1016 let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new(); 1201 graph: DepGraph,
1017 let listings = build_listings(&cfg, &preps)?; 1202 /// `(key, record, render key)`, where the key is a page's source path or a
1018 for p in &preps { 1203 /// listing's output path.
1019 let rlh = resolved_links_hash(&p.source, &p.used, &symbols); 1204 records: Vec<(Utf8PathBuf, PageRecord, Hash)>,
1205}
1206
1207fn page_keys(
1208 preps: &[PagePrep],
1209 symbols: &SymbolTable,
1210 cfg_hash: Hash,
1211 tmpl_hash_for: &dyn Fn(&str) -> Hash,
1212) -> PageKeys {
1213 let mut keys = PageKeys {
1214 graph: DepGraph::default(),
1215 records: Vec::new(),
1216 };
1217 for p in preps {
1218 let rlh = resolved_links_hash(&p.source, &p.used, symbols);
1020 let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash_for(&p.template)); 1219 let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash_for(&p.template));
1021 new_graph.defines.insert(p.source.clone(), p.defines.clone()); 1220 keys.graph.defines.insert(p.source.clone(), p.defines.clone());
1022 new_graph.uses.insert(p.source.clone(), p.used.clone()); 1221 keys.graph.uses.insert(p.source.clone(), p.used.clone());
1023 new_records.push(( 1222 keys.records.push((
1024 p.source.clone(), 1223 p.source.clone(),
1025 PageRecord { 1224 PageRecord {
1026 content_hash: p.content_hash, 1225 content_hash: p.content_hash,
@@ -1030,55 +1229,54 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1030 key, 1229 key,
1031 )); 1230 ));
1032 } 1231 }
1232 keys
1233}
1033 1234
1034 // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None 1235/// Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1:
1035 // ⇒ full rebuild (spec §4.5). 1236/// removed files). Their targets are already in the merged graph, so their linkers were
1036 let prior = if opts.no_cache { 1237/// invalidated by [`compute_rebuild_set`].
1037 None 1238fn remove_deleted_outputs(
1038 } else { 1239 prior: &Manifest,
1039 incremental::load_manifest(out) 1240 preps: &[PagePrep],
1040 }; 1241 listings: &[Listing],
1041 1242 out: &Utf8Path,
1042 let rebuild: HashSet<Utf8PathBuf> = compute_rebuild_set( 1243) {
1043 &preps, 1244 // Keyed by source path for real pages and by output path for generated listings,
1044 &new_records, 1245 // which is also how each records itself in the manifest. Listings have to be in
1045 &new_graph, 1246 // this set or the cleanup would delete the file it just decided to keep — and a
1046 cfg_hash, 1247 // removed collection genuinely should have its output deleted.
1047 out, 1248 let mut current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
1048 prior.as_ref(), 1249 current.extend(listings.iter().map(|l| &l.output));
1049 ); 1250 for (key, rec) in &prior.pages {
1050 1251 if !current.contains(key) {
1051 // Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1: 1252 let dest = out.join(&rec.output_path);
1052 // removed files). Their targets are already in the merged graph, so their linkers were 1253 let _ = fs::remove_file(&dest);
1053 // invalidated above.
1054 if let Some(prior) = &prior {
1055 // Keyed by source path for real pages and by output path for generated listings,
1056 // which is also how each records itself in the manifest. Listings have to be in
1057 // this set or the cleanup would delete the file it just decided to keep — and a
1058 // removed collection genuinely should have its output deleted.
1059 let mut current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
1060 current.extend(listings.iter().map(|l| &l.output));
1061 for (key, rec) in &prior.pages {
1062 if !current.contains(key) {
1063 let dest = out.join(&rec.output_path);
1064 let _ = fs::remove_file(&dest);
1065 }
1066 } 1254 }
1067 } 1255 }
1256}
1068 1257
1069 let highlighter = SyntectHighlighter::with_syntaxes(Some(&src.join(&cfg.highlight.syntaxes_dir))); 1258fn create_parent(dest: &Utf8Path) -> Result<()> {
1070 let site = site_context(&cfg); 1259 if let Some(parent) = dest.parent() {
1071 let listing = page_listing(&cfg, &preps); 1260 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
1072 let mut report = SiteReport::default(); 1261 }
1262 Ok(())
1263}
1073 1264
1074 // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes 1265/// RENDER + TEMPLATE + EMIT every page in `rebuild`, in parallel, and report on all of
1075 // (syntect highlighting and templating dominate), and each page writes only its own 1266/// them.
1076 // file, so the pages are independent. 1267fn emit_pages(
1268 renderer: &PageRenderer,
1269 rebuild: &HashSet<Utf8PathBuf>,
1270 out: &Utf8Path,
1271) -> Result<SiteReport> {
1272 // This is where a build's time actually goes (syntect highlighting and templating
1273 // dominate), and each page writes only its own file, so the pages are independent.
1077 // 1274 //
1078 // The parallel pass returns whether each page was written; the report is assembled 1275 // The parallel pass returns whether each page was written; the report is assembled
1079 // sequentially afterwards from `preps` order. Pushing to the report from inside the 1276 // sequentially afterwards from `preps` order. Pushing to the report from inside the
1080 // parallel pass would make `rendered`/`skipped` ordering depend on thread scheduling, 1277 // parallel pass would make `rendered`/`skipped` ordering depend on thread scheduling,
1081 // which would be a non-deterministic build report over a deterministic build. 1278 // which would be a non-deterministic build report over a deterministic build.
1279 let preps = renderer.preps;
1082 let written: Vec<bool> = preps 1280 let written: Vec<bool> = preps
1083 .par_iter() 1281 .par_iter()
1084 .map(|p| { 1282 .map(|p| {
@@ -1087,15 +1285,14 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1087 return Ok(false); 1285 return Ok(false);
1088 } 1286 }
1089 let dest = out.join(&p.output); 1287 let dest = out.join(&p.output);
1090 if let Some(parent) = dest.parent() { 1288 create_parent(&dest)?;
1091 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; 1289 let html = renderer.render_page(p)?;
1092 }
1093 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &cfg, p)?;
1094 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; 1290 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
1095 Ok(true) 1291 Ok(true)
1096 }) 1292 })
1097 .collect::<Result<Vec<_>>>()?; 1293 .collect::<Result<Vec<_>>>()?;
1098 1294
1295 let mut report = SiteReport::default();
1099 for (p, was_written) in preps.iter().zip(&written) { 1296 for (p, was_written) in preps.iter().zip(&written) {
1100 for t in &p.broken { 1297 for t in &p.broken {
1101 report.broken.push((p.source.clone(), t.clone())); 1298 report.broken.push((p.source.clone(), t.clone()));
@@ -1110,74 +1307,20 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1110 report.skipped.push(p.output.clone()); 1307 report.skipped.push(p.output.clone());
1111 } 1308 }
1112 } 1309 }
1310 Ok(report)
1311}
1113 1312
1114 // Generated listing pages (spec §2.1 EMIT). A listing has no source file, so it is 1313/// The syntax stylesheet, the theme stylesheet, and the sitemap.
1115 // cached on the one thing it actually depends on: the entries it lists. Adding a post 1314fn write_site_files(
1116 // therefore re-renders that section's index and nothing else — the same precision the 1315 cfg: &Config,
1117 // rest of the build gets from content hashing. 1316 preps: &[PagePrep],
1118 for listing in &listings { 1317 syntax_css: &str,
1119 let key = combine( 1318 pages: &[Utf8PathBuf],
1120 listing_entries_hash(listing), 1319 out: &Utf8Path,
1121 combine(cfg_hash, tmpl_hash_for(&listing.template)), 1320) -> Result<()> {
1122 );
1123 let dest = out.join(&listing.output);
1124 let cached = prior
1125 .as_ref()
1126 .and_then(|m| m.pages.get(&listing.output))
1127 .map(|rec| rec.render_key == key)
1128 .unwrap_or(false);
1129
1130 report.pages.push(listing.output.clone());
1131 if cached && dest.exists() {
1132 report.skipped.push(listing.output.clone());
1133 } else {
1134 if let Some(parent) = dest.parent() {
1135 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
1136 }
1137 let root = relative_root(&listing.output);
1138 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
1139 let nav = listing_nav(&preps, &listing.output);
1140 let page_ctx = listing_context(listing);
1141 // Bodies are rendered here rather than when the listing was built, so a
1142 // cached feed costs nothing. This is the only place a page is rendered twice.
1143 let with_content = listing
1144 .include_content
1145 .then(|| entries_with_content(&listing.entries, &preps, &highlighter, &cfg));
1146 let theme = theme_href(&cfg, &root);
1147 let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root);
1148 ctx.theme = &theme;
1149 ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries));
1150 ctx.group = listing.group.as_ref();
1151 ctx.groups = &listing.groups;
1152 ctx.paginator = listing.paginator.as_ref();
1153 let html = templater
1154 .render(&listing.template, &ctx)
1155 .with_context(|| {
1156 format!(
1157 "rendering collection {} with template {} (available: {})",
1158 listing.output,
1159 listing.template,
1160 templater.names().join(", ")
1161 )
1162 })?;
1163 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
1164 report.rendered.push(listing.output.clone());
1165 }
1166
1167 new_records.push((
1168 listing.output.clone(),
1169 PageRecord {
1170 content_hash: key,
1171 render_key: key,
1172 output_path: listing.output.clone(),
1173 },
1174 key,
1175 ));
1176 }
1177
1178 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build 1321 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build
1179 // (it is a few KB and depends only on the theme, which lives in the config hash). 1322 // (it is a few KB and depends only on the theme, which lives in the config hash).
1180 fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css) 1323 fs::write(out.join(SYNTAX_STYLESHEET), syntax_css)
1181 .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; 1324 .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?;
1182 1325
1183 // The built-in theme, on the same terms: compiled in, written every build, and named 1326 // The built-in theme, on the same terms: compiled in, written every build, and named
@@ -1195,38 +1338,30 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1195 .iter() 1338 .iter()
1196 .filter_map(|p| Some((p.output.as_path(), p.context.date_iso.as_deref()?))) 1339 .filter_map(|p| Some((p.output.as_path(), p.context.date_iso.as_deref()?)))
1197 .collect(); 1340 .collect();
1198 let xml = sitemap(&cfg.site.base_url, &report.pages, &dated); 1341 let xml = sitemap(&cfg.site.base_url, pages, &dated);
1199 fs::write(out.join(SITEMAP), xml) 1342 fs::write(out.join(SITEMAP), xml)
1200 .with_context(|| format!("writing {SITEMAP} under {out}"))?; 1343 .with_context(|| format!("writing {SITEMAP} under {out}"))?;
1201 } 1344 }
1345 Ok(())
1346}
1202 1347
1203 // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the 1348/// Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
1204 // full-vs-incremental byte equivalence trivially true for non-`.org` files. 1349/// full-vs-incremental byte equivalence trivially true for non-`.org` files.
1205 for asset in &assets { 1350fn copy_assets(assets: &[Asset], out: &Utf8Path, report: &mut SiteReport) -> Result<()> {
1351 for asset in assets {
1206 let dest = out.join(&asset.rel); 1352 let dest = out.join(&asset.rel);
1207 if let Some(parent) = dest.parent() { 1353 create_parent(&dest)?;
1208 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
1209 }
1210 fs::copy(&asset.from, &dest) 1354 fs::copy(&asset.from, &dest)
1211 .with_context(|| format!("copying {} -> {dest}", asset.from))?; 1355 .with_context(|| format!("copying {} -> {dest}", asset.from))?;
1212 report.assets.push(asset.rel.clone()); 1356 report.assets.push(asset.rel.clone());
1213 } 1357 }
1358 Ok(())
1359}
1214 1360
1215 // Persist the manifest for the next build. 1361/// Print the build's warnings, or fail with them under `--strict`.
1216 let manifest = Manifest { 1362fn report_warnings(report: &SiteReport, strict: bool) -> Result<()> {
1217 format_version: CACHE_FORMAT_VERSION,
1218 config_hash: Some(cfg_hash),
1219 pages: new_records
1220 .into_iter()
1221 .map(|(src_path, rec, _)| (src_path, rec))
1222 .collect(),
1223 graph: new_graph,
1224 };
1225 incremental::save_manifest(out, &manifest)
1226 .with_context(|| format!("writing cache manifest under {out}"))?;
1227
1228 let warnings = report.warnings(); 1363 let warnings = report.warnings();
1229 if opts.strict && !warnings.is_empty() { 1364 if strict && !warnings.is_empty() {
1230 for w in &warnings { 1365 for w in &warnings {
1231 eprintln!("error: {w}"); 1366 eprintln!("error: {w}");
1232 } 1367 }
@@ -1240,8 +1375,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1240 for w in &warnings { 1375 for w in &warnings {
1241 eprintln!("warning: {w}"); 1376 eprintln!("warning: {w}");
1242 } 1377 }
1243 1378 Ok(())
1244 Ok(report)
1245} 1379}
1246 1380
1247/// The set of source files to (re)render this build (spec §4.3 invalidation algorithm), 1381/// The set of source files to (re)render this build (spec §4.3 invalidation algorithm),
@@ -1256,8 +1390,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1256/// - any page whose **output file is missing** on disk. 1390/// - any page whose **output file is missing** on disk.
1257fn compute_rebuild_set( 1391fn compute_rebuild_set(
1258 preps: &[PagePrep], 1392 preps: &[PagePrep],
1259 new_records: &[(Utf8PathBuf, PageRecord, Hash)], 1393 keys: &PageKeys,
1260 new_graph: &DepGraph,
1261 cfg_hash: Hash, 1394 cfg_hash: Hash,
1262 out: &Utf8Path, 1395 out: &Utf8Path,
1263 prior: Option<&Manifest>, 1396 prior: Option<&Manifest>,
@@ -1288,12 +1421,12 @@ fn compute_rebuild_set(
1288 1421
1289 // Graph expansion: changed files ∪ pages that link into a changed file's targets. 1422 // Graph expansion: changed files ∪ pages that link into a changed file's targets.
1290 // Merge prior `defines` so a target a changed file removed still pulls its linkers. 1423 // Merge prior `defines` so a target a changed file removed still pulls its linkers.
1291 let merged = prior.graph.merged_defines_with(new_graph); 1424 let merged = prior.graph.merged_defines_with(&keys.graph);
1292 let mut rebuild = incremental::invalidation_set(&changed, &merged); 1425 let mut rebuild = incremental::invalidation_set(&changed, &merged);
1293 1426
1294 // Precise render_key delta (catches a linked target's URL change; also a belt for the 1427 // Precise render_key delta (catches a linked target's URL change; also a belt for the
1295 // graph). A page whose render_key matches the cache and whose output exists is correct. 1428 // graph). A page whose render_key matches the cache and whose output exists is correct.
1296 for (src_path, _rec, key) in new_records { 1429 for (src_path, _rec, key) in &keys.records {
1297 let unchanged = prior 1430 let unchanged = prior
1298 .pages 1431 .pages
1299 .get(src_path) 1432 .get(src_path)
@@ -1392,42 +1525,8 @@ fn collect_assets(
1392 1525
1393 for root in &config.build.assets { 1526 for root in &config.build.assets {
1394 let base = src.join(root); 1527 let base = src.join(root);
1395 if !base.is_dir() { 1528 check_asset_root(root, &base, out)?;
1396 anyhow::bail!( 1529 assets.extend(walk_asset_root(&base)?);
1397 "build.assets lists {root}, which is not a directory (looked in {base})"
1398 );
1399 }
1400 let base_canon = std::fs::canonicalize(&base)
1401 .ok()
1402 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1403 .unwrap_or_else(|| base.clone());
1404 // An asset root that contains the output directory would copy the site into
1405 // itself, one build at a time.
1406 let out_canon = out
1407 .and_then(|out| std::fs::canonicalize(out).ok())
1408 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
1409 if out_canon.is_some_and(|o| o.starts_with(&base_canon)) {
1410 anyhow::bail!(
1411 "build.assets lists {root}, which contains the output directory {}",
1412 out.unwrap_or(Utf8Path::new("(none)"))
1413 );
1414 }
1415 for entry in WalkDir::new(&base).sort_by_file_name() {
1416 let entry = entry.with_context(|| format!("walking {base}"))?;
1417 if !entry.file_type().is_file() {
1418 continue;
1419 }
1420 let abs = Utf8PathBuf::from_path_buf(entry.into_path())
1421 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
1422 let rel = abs
1423 .strip_prefix(&base)
1424 .map(|p| p.to_owned())
1425 .unwrap_or_else(|_| abs.clone());
1426 if rel.components().any(|c| is_hidden(c.as_str())) {
1427 continue;
1428 }
1429 assets.push(Asset { from: abs, rel });
1430 }
1431 } 1530 }
1432 1531
1433 let mut seen: HashMap<&Utf8Path, &Utf8Path> = HashMap::new(); 1532 let mut seen: HashMap<&Utf8Path, &Utf8Path> = HashMap::new();
@@ -1444,6 +1543,53 @@ fn collect_assets(
1444 Ok(assets) 1543 Ok(assets)
1445} 1544}
1446 1545
1546/// Reject an asset root that is missing or that would copy the site into itself.
1547fn check_asset_root(root: &Utf8Path, base: &Utf8Path, out: Option<&Utf8Path>) -> Result<()> {
1548 if !base.is_dir() {
1549 anyhow::bail!(
1550 "build.assets lists {root}, which is not a directory (looked in {base})"
1551 );
1552 }
1553 let base_canon = std::fs::canonicalize(base)
1554 .ok()
1555 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1556 .unwrap_or_else(|| base.to_owned());
1557 // An asset root that contains the output directory would copy the site into
1558 // itself, one build at a time.
1559 let out_canon = out
1560 .and_then(|out| std::fs::canonicalize(out).ok())
1561 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
1562 if out_canon.is_some_and(|o| o.starts_with(&base_canon)) {
1563 anyhow::bail!(
1564 "build.assets lists {root}, which contains the output directory {}",
1565 out.unwrap_or(Utf8Path::new("(none)"))
1566 );
1567 }
1568 Ok(())
1569}
1570
1571/// Every non-hidden file under an asset root, relative to it, in file-name order.
1572fn walk_asset_root(base: &Utf8Path) -> Result<Vec<Asset>> {
1573 let mut assets = Vec::new();
1574 for entry in WalkDir::new(base).sort_by_file_name() {
1575 let entry = entry.with_context(|| format!("walking {base}"))?;
1576 if !entry.file_type().is_file() {
1577 continue;
1578 }
1579 let abs = Utf8PathBuf::from_path_buf(entry.into_path())
1580 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
1581 let rel = abs
1582 .strip_prefix(base)
1583 .map(|p| p.to_owned())
1584 .unwrap_or_else(|_| abs.clone());
1585 if rel.components().any(|c| is_hidden(c.as_str())) {
1586 continue;
1587 }
1588 assets.push(Asset { from: abs, rel });
1589 }
1590 Ok(assets)
1591}
1592
1447/// Source-relative directories that DISCOVER must not descend into: the template 1593/// Source-relative directories that DISCOVER must not descend into: the template
1448/// directory (build input, not content) and the output directory when it lives inside 1594/// directory (build input, not content) and the output directory when it lives inside
1449/// the source. 1595/// the source.
src/skeleton.rs +27 −26
@@ -63,47 +63,47 @@ pub fn skeleton(html: &str) -> Vec<String> {
63 i += 1; 63 i += 1;
64 continue; 64 continue;
65 } 65 }
66
67 // Comments and doctypes carry nothing.
68 if chars[i..].starts_with(&['<', '!']) {
69 i += match find_from(&chars, i, ">") {
70 Some(end) => end - i + 1,
71 None => break,
72 };
73 continue;
74 }
75 let Some(end) = find_from(&chars, i, ">") else { 66 let Some(end) = find_from(&chars, i, ">") else {
76 break; 67 break;
77 }; 68 };
69 // Comments and doctypes carry nothing.
70 let is_markup_declaration = chars[i..].starts_with(&['<', '!']);
78 let raw: String = chars[i + 1..end].iter().collect(); 71 let raw: String = chars[i + 1..end].iter().collect();
79 i = end + 1; 72 i = end + 1;
80 73 if is_markup_declaration {
81 let raw = raw.trim().trim_end_matches('/').trim().to_string(); 74 continue;
75 }
82 // Text is flushed only when a tag is actually *emitted*. Text either side of an 76 // Text is flushed only when a tag is actually *emitted*. Text either side of an
83 // ignored tag therefore merges into one run, which is what makes a highlighted 77 // ignored tag therefore merges into one run, which is what makes a highlighted
84 // source block compare as the one string of code it is, rather than as a 78 // source block compare as the one string of code it is, rather than as a
85 // token-by-token sequence that has to line up exactly. 79 // token-by-token sequence that has to line up exactly.
86 if let Some(name) = raw.strip_prefix('/') { 80 if let Some(event) = tag_event(&raw) {
87 let name = name.trim().to_ascii_lowercase(); 81 flush_text(&mut text, &mut out);
88 if !IGNORED.contains(&name.as_str()) && !VOID.contains(&name.as_str()) { 82 out.push(event);
89 flush_text(&mut text, &mut out);
90 out.push(format!("</{name}>"));
91 }
92 continue;
93 }
94 let mut parts = raw.splitn(2, char::is_whitespace);
95 let name = parts.next().unwrap_or("").to_ascii_lowercase();
96 if name.is_empty() || IGNORED.contains(&name.as_str()) {
97 continue;
98 } 83 }
99 let attrs = kept_attributes(parts.next().unwrap_or(""));
100 flush_text(&mut text, &mut out);
101 out.push(format!("<{name}{attrs}>"));
102 } 84 }
103 flush_text(&mut text, &mut out); 85 flush_text(&mut text, &mut out);
104 out 86 out
105} 87}
106 88
89/// The skeleton line for one tag, given the text between its `<` and `>`, or `None`
90/// for a tag the skeleton drops.
91fn tag_event(raw: &str) -> Option<String> {
92 let raw = raw.trim().trim_end_matches('/').trim();
93 if let Some(name) = raw.strip_prefix('/') {
94 let name = name.trim().to_ascii_lowercase();
95 let dropped = IGNORED.contains(&name.as_str()) || VOID.contains(&name.as_str());
96 return (!dropped).then(|| format!("</{name}>"));
97 }
98 let mut parts = raw.splitn(2, char::is_whitespace);
99 let name = parts.next().unwrap_or("").to_ascii_lowercase();
100 if name.is_empty() || IGNORED.contains(&name.as_str()) {
101 return None;
102 }
103 let attrs = kept_attributes(parts.next().unwrap_or(""));
104 Some(format!("<{name}{attrs}>"))
105}
106
107fn flush_text(text: &mut String, out: &mut Vec<String>) { 107fn flush_text(text: &mut String, out: &mut Vec<String>) {
108 let decoded = decode_entities(text); 108 let decoded = decode_entities(text);
109 let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" "); 109 let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" ");
@@ -207,3 +207,4 @@ fn numeric(name: &str) -> Option<char> {
207 }) 207 })
208 .and_then(char::from_u32) 208 .and_then(char::from_u32)
209} 209}
210
src/util.rs +34 −21
@@ -4,7 +4,7 @@
4 4
5use camino::{Utf8Path, Utf8PathBuf}; 5use camino::{Utf8Path, Utf8PathBuf};
6 6
7use crate::model::{Element, Heading, Keywords, Object, Section, TableRow}; 7use crate::model::{Element, Heading, Keywords, List, Object, Section, Table, TableRow};
8 8
9/// The output path for a document, relative to the site root. 9/// The output path for a document, relative to the site root.
10/// 10///
@@ -209,23 +209,8 @@ fn elements_text(elements: &[Element], out: &mut String) {
209 for element in elements { 209 for element in elements {
210 match element { 210 match element {
211 Element::Paragraph(objs) => push_words(&plain_text(objs), out), 211 Element::Paragraph(objs) => push_words(&plain_text(objs), out),
212 Element::List(list) => { 212 Element::List(list) => list_text(list, out),
213 for item in &list.items { 213 Element::Table(table) => table_text(table, out),
214 if let Some(term) = &item.term {
215 push_words(&plain_text(term), out);
216 }
217 elements_text(&item.content, out);
218 }
219 }
220 Element::Table(table) => {
221 for row in &table.rows {
222 if let TableRow::Cells(cells) = row {
223 for cell in cells {
224 push_words(&plain_text(cell), out);
225 }
226 }
227 }
228 }
229 Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out), 214 Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out),
230 Element::Figure { caption, .. } => push_words(&plain_text(caption), out), 215 Element::Figure { caption, .. } => push_words(&plain_text(caption), out),
231 Element::FootnoteDefinition { content, .. } => elements_text(content, out), 216 Element::FootnoteDefinition { content, .. } => elements_text(content, out),
@@ -235,6 +220,25 @@ fn elements_text(elements: &[Element], out: &mut String) {
235 } 220 }
236} 221}
237 222
223fn list_text(list: &List, out: &mut String) {
224 for item in &list.items {
225 if let Some(term) = &item.term {
226 push_words(&plain_text(term), out);
227 }
228 elements_text(&item.content, out);
229 }
230}
231
232fn table_text(table: &Table, out: &mut String) {
233 for row in &table.rows {
234 if let TableRow::Cells(cells) = row {
235 for cell in cells {
236 push_words(&plain_text(cell), out);
237 }
238 }
239 }
240}
241
238fn push_words(text: &str, out: &mut String) { 242fn push_words(text: &str, out: &mut String) {
239 let text = text.trim(); 243 let text = text.trim();
240 if text.is_empty() { 244 if text.is_empty() {
@@ -377,12 +381,21 @@ pub fn iso_time(raw: &str) -> Option<String> {
377 None 381 None
378} 382}
379 383
384/// Whether ten bytes have the shape `YYYY-MM-DD`. Says nothing about whether the date
385/// exists.
386pub fn is_iso_date_shape(window: &[u8]) -> bool {
387 match window {
388 [y1, y2, y3, y4, b'-', m1, m2, b'-', d1, d2] => {
389 [y1, y2, y3, y4, m1, m2, d1, d2].iter().all(|b| b.is_ascii_digit())
390 }
391 _ => false,
392 }
393}
394
380pub fn iso_date(raw: &str) -> Option<String> { 395pub fn iso_date(raw: &str) -> Option<String> {
381 let bytes = raw.as_bytes(); 396 let bytes = raw.as_bytes();
382 for i in 0..bytes.len().saturating_sub(9) { 397 for i in 0..bytes.len().saturating_sub(9) {
383 let window = &bytes[i..i + 10]; 398 if is_iso_date_shape(&bytes[i..i + 10]) {
384 let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit);
385 if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) {
386 // Must not be part of a longer number, or `123-45-6789` would parse. 399 // Must not be part of a longer number, or `123-45-6789` would parse.
387 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); 400 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
388 let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit(); 401 let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit();
src/watch.rs +1 −4
@@ -155,10 +155,7 @@ impl ChangeFilter {
155/// `file.org~` backups, which do not start with a dot and would otherwise look like a 155/// `file.org~` backups, which do not start with a dot and would otherwise look like a
156/// content change to a tool aimed squarely at Emacs users. 156/// content change to a tool aimed squarely at Emacs users.
157fn is_editor_scratch(name: &str) -> bool { 157fn is_editor_scratch(name: &str) -> bool {
158 name.ends_with('~') 158 ["~", ".swp", ".swx", ".tmp"].iter().any(|s| name.ends_with(s))
159 || name.ends_with(".swp")
160 || name.ends_with(".swx")
161 || name.ends_with(".tmp")
162 || (name.starts_with('#') && name.ends_with('#')) 159 || (name.starts_with('#') && name.ends_with('#'))
163} 160}
164 161
tests/constructs.rs +16
@@ -671,6 +671,22 @@ fn a_tables_special_column_and_marker_rows_are_dropped() {
671 assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}"); 671 assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}");
672} 672}
673 673
674/// Every row above the first rule is a header row, and all of them share one `<thead>`.
675#[test]
676fn header_rows_share_one_thead() {
677 let html = html_of(
678 "| a | b |\n\
679 | c | d |\n\
680 |---+---|\n\
681 | 1 | 2 |\n",
682 );
683 assert_eq!(html.matches("<thead>").count(), 1, "{html}");
684 assert!(
685 html.contains("<thead>\n<tr><th>a</th><th>b</th></tr>\n<tr><th>c</th><th>d</th></tr>\n</thead>"),
686 "{html}"
687 );
688}
689
674/// An affiliated keyword belongs to the element *immediately* below it. Someone who 690/// An affiliated keyword belongs to the element *immediately* below it. Someone who
675/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next 691/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next
676/// image instead would put the wrong words under the wrong picture. 692/// image instead would put the wrong words under the wrong picture.