Commit f4298e6300
Verified · cmc
Layout: unified · split
.qlty/qlty.toml +10
| @@ -41,3 +41,13 @@ name = "trufflehog" | ||
| 41 | 41 | match.plugins = ["osv-scanner"] |
| 42 | 42 | match.rules = ["osv-scanner:RUSTSEC-2025-0141", "osv-scanner:RUSTSEC-2024-0320"] |
| 43 | 43 | set.ignored = true |
| 44 | ||
| 45 | ||
| 46 | # Early returns are guard clauses, which keep the happy path unindented. | |
| 47 | [smells.return_statements] | |
| 48 | enabled = false | |
| 49 | ||
| 50 | # Total file complexity is the sum of its functions, which function_complexity | |
| 51 | # already checks. | |
| 52 | [smells.file_complexity] | |
| 53 | enabled = false | |
src/audit.rs +118 −106
| @@ -24,6 +24,8 @@ use anyhow::{Context, Result}; | ||
| 24 | 24 | use camino::{Utf8Path, Utf8PathBuf}; |
| 25 | 25 | use walkdir::WalkDir; |
| 26 | 26 | |
| 27 | use crate::util::is_iso_date_shape; | |
| 28 | ||
| 27 | 29 | /// Where a construct sits relative to the supported set, which |
| 28 | 30 | /// `docs/guide/05-org-support.org` defines. |
| 29 | 31 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| @@ -179,7 +181,31 @@ pub fn audit(root: &Utf8Path) -> Result<Audit> { | ||
| 179 | 181 | |
| 180 | 182 | impl Audit { |
| 181 | 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 | 209 | for tally in self.constructs.values_mut() { |
| 184 | 210 | tally.seen_in_current_file = false; |
| 185 | 211 | } |
| @@ -193,63 +219,21 @@ impl Audit { | ||
| 193 | 219 | tally.seen_in_current_file = false; |
| 194 | 220 | } |
| 195 | 221 | } |
| 222 | } | |
| 196 | 223 | |
| 197 | let mut in_block: Option<String> = None; | |
| 198 | for (idx, line) in source.lines().enumerate() { | |
| 199 | self.lines += 1; | |
| 200 | let at = format!("{path}:{}", idx + 1); | |
| 201 | let trimmed = line.trim_start(); | |
| 202 | ||
| 203 | // Inside a verbatim block only the terminator matters — a `*` in a source | |
| 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); | |
| 224 | /// `rest` is an uppercased `#+BEGIN_` line after that prefix. | |
| 225 | fn scan_block_begin(&mut self, rest: &str, at: &str) { | |
| 226 | let kind = rest.split_whitespace().next().unwrap_or(""); | |
| 227 | self.count_census(Census::Block, kind, at); | |
| 228 | self.count(Scope::In, block_construct(kind), at); | |
| 229 | if rest.contains(":RESULTS") { | |
| 230 | self.count(Scope::Out, "babel header args (:results)", at); | |
| 225 | 231 | } |
| 226 | 232 | } |
| 227 | 233 | |
| 228 | 234 | fn scan_line(&mut self, line: &str, trimmed: &str, at: &str) { |
| 229 | // --- headings and their metadata --- | |
| 230 | 235 | if let Some(stars) = heading_stars(line) { |
| 231 | self.count(Scope::In, "heading", 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 | } | |
| 236 | self.scan_heading(line[stars..].trim(), at); | |
| 253 | 237 | return; |
| 254 | 238 | } |
| 255 | 239 | |
| @@ -265,60 +249,19 @@ impl Audit { | ||
| 265 | 249 | |
| 266 | 250 | // --- keywords and drawers --- |
| 267 | 251 | if let Some(rest) = trimmed.strip_prefix("#+") { |
| 268 | if let Some(colon) = rest.find(':') { | |
| 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 | } | |
| 252 | self.scan_keyword(rest, at); | |
| 289 | 253 | } else if is_drawer(trimmed) { |
| 290 | let name = trimmed[1..trimmed.len() - 1].to_ascii_uppercase(); | |
| 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 | } | |
| 254 | self.scan_drawer(&trimmed[1..trimmed.len() - 1].to_ascii_uppercase(), at); | |
| 298 | 255 | } |
| 299 | 256 | |
| 300 | 257 | // --- lists, tables, rules --- |
| 301 | 258 | if let Some(bullet) = list_bullet(trimmed) { |
| 302 | self.count(Scope::In, "list item", 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 | } | |
| 259 | self.scan_list_item(bullet, line.len() - trimmed.len(), trimmed, at); | |
| 317 | 260 | } |
| 318 | 261 | if trimmed.starts_with('|') { |
| 319 | 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 | 265 | self.count(Scope::Out, "fixed-width line", at); |
| 323 | 266 | } |
| 324 | 267 | |
| @@ -333,6 +276,83 @@ impl Audit { | ||
| 333 | 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 | 356 | fn scan_inline(&mut self, line: &str, at: &str) { |
| 337 | 357 | // Links: count each `[[target]]`, censusing its scheme. |
| 338 | 358 | let mut rest = line; |
| @@ -460,7 +480,6 @@ fn is_drawer(trimmed: &str) -> bool { | ||
| 460 | 480 | && t[1..t.len() - 1] |
| 461 | 481 | .chars() |
| 462 | 482 | .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') |
| 463 | && t.len() > 2 | |
| 464 | 483 | } |
| 465 | 484 | |
| 466 | 485 | fn block_construct(kind: &str) -> &'static str { |
| @@ -504,14 +523,7 @@ fn has_timestamp(line: &str) -> bool { | ||
| 504 | 523 | if c != '<' && c != '[' { |
| 505 | 524 | continue; |
| 506 | 525 | } |
| 507 | let rest = &bytes[i + 1..]; | |
| 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 | { | |
| 526 | if bytes[i + 1..].get(..10).is_some_and(is_iso_date_shape) { | |
| 515 | 527 | return true; |
| 516 | 528 | } |
| 517 | 529 | } |
src/config.rs +97 −71
| @@ -459,79 +459,10 @@ impl Config { | ||
| 459 | 459 | /// Validate settings that only make sense in combination. Catching these up front |
| 460 | 460 | /// beats emitting a site with a silently empty nav. |
| 461 | 461 | pub fn validate(&self) -> Result<()> { |
| 462 | if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() { | |
| 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 | } | |
| 462 | self.validate_nav()?; | |
| 478 | 463 | let mut seen: Vec<&Utf8PathBuf> = Vec::new(); |
| 479 | 464 | for collection in &self.collections { |
| 480 | let grouped = !collection.group_by.is_empty(); | |
| 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 | } | |
| 465 | collection.validate()?; | |
| 535 | 466 | for path in [&collection.output, &collection.index_output] { |
| 536 | 467 | if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) { |
| 537 | 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 | 512 | if !self.site.theme.is_empty() && crate::theme::theme_css(&self.site.theme).is_none() { |
| 558 | 513 | anyhow::bail!( |
| 559 | 514 | "unknown site.theme {:?}. Available: {} — or leave it empty for no \ |
| @@ -573,6 +528,76 @@ impl Config { | ||
| 573 | 528 | } |
| 574 | 529 | } |
| 575 | 530 | |
| 531 | impl 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 | 601 | /// The starter config written by `orgo init`, and the documentation of record for |
| 577 | 602 | /// what is configurable. Every value shown is the default — except `site.theme`, which |
| 578 | 603 | /// picks a stylesheet so a new site looks like something on its first build — so |
| @@ -696,3 +721,4 @@ index_template = "tags.html" | ||
| 696 | 721 | index_title = "Tags" |
| 697 | 722 | nav = 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 | 338 | idx += 1; |
| 339 | 339 | } |
| 340 | 340 | if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") { |
| 341 | let opened_at = base + idx; | |
| 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 | } | |
| 341 | idx = parse_property_drawer(&mut heading, body, idx, base, diags); | |
| 369 | 342 | } |
| 370 | 343 | let content = parse_elements(&body[idx..], base + idx, diags); |
| 371 | 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. | |
| 349 | fn 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 | 381 | /// `:KEY: value` inside a drawer. |
| 375 | 382 | fn parse_property(line: &str) -> Option<(String, String)> { |
| 376 | 383 | let line = line.trim(); |
| @@ -410,30 +417,10 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V | ||
| 410 | 417 | continue; |
| 411 | 418 | } |
| 412 | 419 | if let Some((key, value)) = keyword_kv(line) { |
| 413 | if key.eq_ignore_ascii_case("INCLUDE") { | |
| 414 | // Never expanded (§"Not supported"). Expanding it means resolving paths, | |
| 415 | // recursion and `:lines`/`:only-contents`; dropping it silently means a | |
| 416 | // page missing content nobody was told about. Saying so is the honest | |
| 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 }); | |
| 420 | match keyword_role(&key, &value, base + i + 1, diags) { | |
| 421 | KeywordRole::DropNext => drop_next = true, | |
| 422 | KeywordRole::Affiliated => affiliated.push((key, value)), | |
| 423 | KeywordRole::Standalone => out.push(Element::Keyword { key, value }), | |
| 437 | 424 | } |
| 438 | 425 | i += 1; |
| 439 | 426 | continue; |
| @@ -451,6 +438,46 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V | ||
| 451 | 438 | out |
| 452 | 439 | } |
| 453 | 440 | |
| 441 | /// What a `#+KEY: value` line contributes to the elements around it. | |
| 442 | enum 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. | |
| 453 | fn 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 | 481 | /// Build the single element starting at `lines[start]`, returning it with the index of |
| 455 | 482 | /// the first line past it. `None` means the lines were consumed without producing an |
| 456 | 483 | /// element. `start` is guaranteed non-blank and not an affiliated keyword. |
| @@ -464,8 +491,8 @@ fn parse_one_element( | ||
| 464 | 491 | if let Some(text) = comment_text(line) { |
| 465 | 492 | return (Some(Element::Comment(text)), start + 1); |
| 466 | 493 | } |
| 467 | if let Some((kind, after)) = block_begin(line) { | |
| 468 | let (el, next) = parse_block(lines, start, &kind, &after, base, diags); | |
| 494 | if let Some(header) = block_begin(line) { | |
| 495 | let (el, next) = parse_block(lines, start, &header, base, diags); | |
| 469 | 496 | return (Some(el), next); |
| 470 | 497 | } |
| 471 | 498 | if let Some(name) = drawer_begin_name(line) { |
| @@ -517,19 +544,23 @@ fn parse_one_element( | ||
| 517 | 544 | (Some(Element::Paragraph(inline(¶.join(" ")))), i) |
| 518 | 545 | } |
| 519 | 546 | |
| 547 | /// Tests for each line that starts a non-paragraph construct. | |
| 548 | const 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 | 561 | /// Is this line the start of a non-paragraph construct? |
| 521 | 562 | fn is_structural(line: &str) -> bool { |
| 522 | let t = line.trim_start(); | |
| 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() | |
| 563 | STRUCTURAL.iter().any(|test| test(line)) | |
| 533 | 564 | } |
| 534 | 565 | |
| 535 | 566 | // --------------------------------------------------------------------------- |
| @@ -542,8 +573,7 @@ fn is_structural(line: &str) -> bool { | ||
| 542 | 573 | fn parse_block( |
| 543 | 574 | lines: &[&str], |
| 544 | 575 | start: usize, |
| 545 | kind: &str, | |
| 546 | after: &str, | |
| 576 | (kind, after): &(String, String), | |
| 547 | 577 | base: usize, |
| 548 | 578 | diags: &mut Vec<Diagnostic>, |
| 549 | 579 | ) -> (Element, usize) { |
| @@ -853,22 +883,7 @@ fn parse_list( | ||
| 853 | 883 | |
| 854 | 884 | let mut items = Vec::new(); |
| 855 | 885 | let mut i = start; |
| 856 | loop { | |
| 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 | ||
| 886 | while let Some((j, bullet)) = next_sibling(lines, i, base_indent, &family) { | |
| 872 | 887 | // Body = the text after the bullet, plus every following line indented past the |
| 873 | 888 | // bullet column (blank lines included, so an item can hold several paragraphs). |
| 874 | 889 | let rest = item_body(lines[j].trim_start(), &bullet); |
| @@ -883,28 +898,8 @@ fn parse_list( | ||
| 883 | 898 | _ => (None, rest), |
| 884 | 899 | }; |
| 885 | 900 | |
| 886 | let mut body: Vec<String> = vec![rest.trim().to_string()]; | |
| 887 | i = j + 1; | |
| 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 | } | |
| 901 | let body; | |
| 902 | (body, i) = item_body_lines(lines, j + 1, base_indent, rest.trim().to_string()); | |
| 908 | 903 | |
| 909 | 904 | items.push(ListItem { |
| 910 | 905 | bullet, |
| @@ -919,6 +914,60 @@ fn parse_list( | ||
| 919 | 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. | |
| 920 | fn 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. | |
| 940 | fn 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 | 971 | /// Ordered and unordered bullets cannot share a list; description items use unordered |
| 923 | 972 | /// bullets, so they are the same family. |
| 924 | 973 | fn bullet_family(bullet: &Bullet) -> ListKind { |
| @@ -1132,63 +1181,44 @@ fn is_list_item(t: &str) -> Option<Bullet> { | ||
| 1132 | 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. | |
| 1186 | type 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. | |
| 1193 | const 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 | ||
| 1135 | 1202 | fn parse_inline_run(chars: &[char]) -> Vec<Object> { |
| 1136 | 1203 | let mut out = Vec::new(); |
| 1137 | 1204 | let mut buf = String::new(); |
| 1138 | 1205 | let mut i = 0; |
| 1139 | let n = chars.len(); | |
| 1140 | while i < n { | |
| 1141 | let c = chars[i]; | |
| 1142 | if c == '[' && starts_with_at(chars, i, "[fn:") { | |
| 1143 | if let Some((obj, next)) = try_footnote_ref(chars, i) { | |
| 1144 | flush(&mut buf, &mut out); | |
| 1145 | out.push(obj); | |
| 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) { | |
| 1206 | while i < chars.len() { | |
| 1207 | let found = OBJECT_PARSERS | |
| 1208 | .iter() | |
| 1209 | .filter(|(applies, _)| applies(chars, i)) | |
| 1210 | .find_map(|(_, parse)| parse(chars, i)); | |
| 1211 | match found { | |
| 1212 | Some((obj, next)) => { | |
| 1176 | 1213 | flush(&mut buf, &mut out); |
| 1177 | 1214 | out.push(obj); |
| 1178 | 1215 | i = next; |
| 1179 | continue; | |
| 1180 | 1216 | } |
| 1181 | } | |
| 1182 | if is_marker(c) { | |
| 1183 | if let Some((obj, next)) = try_emphasis(chars, i) { | |
| 1184 | flush(&mut buf, &mut out); | |
| 1185 | out.push(obj); | |
| 1186 | i = next; | |
| 1187 | continue; | |
| 1217 | None => { | |
| 1218 | buf.push(chars[i]); | |
| 1219 | i += 1; | |
| 1188 | 1220 | } |
| 1189 | 1221 | } |
| 1190 | buf.push(c); | |
| 1191 | i += 1; | |
| 1192 | 1222 | } |
| 1193 | 1223 | flush(&mut buf, &mut out); |
| 1194 | 1224 | out |
src/render.rs +175 −151
| @@ -450,18 +450,10 @@ impl Renderer<'_> { | ||
| 450 | 450 | out.push_str(&format!("<pre>{}</pre>\n", escape_html(code))); |
| 451 | 451 | } |
| 452 | 452 | Element::QuoteBlock(inner) => { |
| 453 | out.push_str("<blockquote>\n"); | |
| 454 | for el in inner { | |
| 455 | self.render_element(el, out); | |
| 456 | } | |
| 457 | out.push_str("</blockquote>\n"); | |
| 453 | self.render_wrapped("<blockquote>\n", inner, "</blockquote>\n", out); | |
| 458 | 454 | } |
| 459 | 455 | Element::CenterBlock(inner) => { |
| 460 | out.push_str("<div class=\"center\">\n"); | |
| 461 | for el in inner { | |
| 462 | self.render_element(el, out); | |
| 463 | } | |
| 464 | out.push_str("</div>\n"); | |
| 456 | self.render_wrapped("<div class=\"center\">\n", inner, "</div>\n", out); | |
| 465 | 457 | } |
| 466 | 458 | // An `html` export block is verbatim output by definition; every other |
| 467 | 459 | // backend is out of scope and drops (§"Not supported"). |
| @@ -475,37 +467,12 @@ impl Renderer<'_> { | ||
| 475 | 467 | link, |
| 476 | 468 | caption, |
| 477 | 469 | attrs, |
| 478 | } => { | |
| 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 | } | |
| 470 | } => self.render_figure(link, caption, attrs, out), | |
| 492 | 471 | Element::SpecialBlock { name, content } => { |
| 493 | out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name))); | |
| 494 | for child in content { | |
| 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"); | |
| 472 | let open = format!("<div class=\"{}\">\n", escape_attr(name)); | |
| 473 | self.render_wrapped(&open, content, "</div>\n", out); | |
| 508 | 474 | } |
| 475 | Element::VerseBlock(lines) => self.render_verse(lines, out), | |
| 509 | 476 | Element::HorizontalRule => out.push_str("<hr>\n"), |
| 510 | 477 | // Definitions are emitted in the footnotes section, not inline. |
| 511 | 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 | 525 | fn render_list(&mut self, list: &crate::model::List, out: &mut String) { |
| 519 | 526 | if list.kind == ListKind::Description { |
| 520 | out.push_str("<dl>\n"); | |
| 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"); | |
| 527 | self.render_description_list(list, out); | |
| 531 | 528 | return; |
| 532 | 529 | } |
| 533 | 530 | let tag = if list.kind == ListKind::Ordered { |
| @@ -537,34 +534,52 @@ impl Renderer<'_> { | ||
| 537 | 534 | }; |
| 538 | 535 | out.push_str(&format!("<{}>\n", tag)); |
| 539 | 536 | for item in &list.items { |
| 540 | // Org writes a checkbox as literal text, which keeps the third state — `[-]`, | |
| 541 | // partially done — that a disabled <input> cannot express. The state goes on | |
| 542 | // the item, where it can style the whole line. | |
| 543 | let class = item.checkbox.as_ref().map(|cb| match cb { | |
| 544 | Checkbox::On => "on", | |
| 545 | Checkbox::Off => "off", | |
| 546 | Checkbox::Trans => "trans", | |
| 547 | }); | |
| 548 | out.push_str("<li"); | |
| 549 | if let Some(class) = class { | |
| 550 | out.push_str(&format!(" class=\"{class}\"")); | |
| 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>[ ]</code> ", | |
| 561 | Checkbox::Trans => "<code>[-]</code> ", | |
| 562 | }); | |
| 537 | self.render_list_item(item, out); | |
| 538 | } | |
| 539 | out.push_str(&format!("</{}>\n", tag)); | |
| 540 | } | |
| 541 | ||
| 542 | fn render_description_list(&mut self, list: &crate::model::List, out: &mut String) { | |
| 543 | out.push_str("<dl>\n"); | |
| 544 | for item in &list.items { | |
| 545 | out.push_str("<dt>"); | |
| 546 | if let Some(term) = &item.term { | |
| 547 | self.render_objects(term, out); | |
| 563 | 548 | } |
| 549 | out.push_str("</dt>\n<dd>"); | |
| 564 | 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>[ ]</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 | 585 | /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than |
| @@ -598,56 +613,48 @@ impl Renderer<'_> { | ||
| 598 | 613 | .position(|r| matches!(r, TableRow::Rule)); |
| 599 | 614 | out.push_str("<table>\n"); |
| 600 | 615 | if !table.caption.is_empty() { |
| 601 | self.tables += 1; | |
| 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"); | |
| 616 | self.render_table_caption(&table.caption, out); | |
| 609 | 617 | } |
| 610 | let mut wrote_body = false; | |
| 611 | let mut in_body = rule_at.is_none(); | |
| 612 | for (idx, row) in table.rows.iter().enumerate() { | |
| 613 | match row { | |
| 614 | TableRow::Rule => { | |
| 615 | if Some(idx) == rule_at { | |
| 616 | in_body = true; | |
| 617 | } | |
| 618 | continue; | |
| 619 | } | |
| 620 | TableRow::Cells(cells) => { | |
| 621 | let (open, cell_tag) = if in_body { | |
| 622 | if !wrote_body { | |
| 623 | wrote_body = true; | |
| 624 | ("<tbody>\n<tr>", "td") | |
| 625 | } else { | |
| 626 | ("<tr>", "td") | |
| 627 | } | |
| 628 | } else { | |
| 629 | ("<thead>\n<tr>", "th") | |
| 630 | }; | |
| 631 | out.push_str(open); | |
| 632 | for cell in cells { | |
| 633 | out.push_str(&format!("<{}>", cell_tag)); | |
| 634 | self.render_objects(cell, out); | |
| 635 | out.push_str(&format!("</{}>", cell_tag)); | |
| 636 | } | |
| 637 | out.push_str("</tr>\n"); | |
| 638 | // Close the header band right after its last row. | |
| 639 | if !in_body | |
| 640 | && rule_at.map(|r| idx + 1 == r).unwrap_or(false) | |
| 641 | { | |
| 642 | out.push_str("</thead>\n"); | |
| 643 | } | |
| 644 | } | |
| 618 | let (head, body) = match rule_at { | |
| 619 | Some(r) => (&table.rows[..r], &table.rows[r + 1..]), | |
| 620 | None => (&table.rows[..0], &table.rows[..]), | |
| 621 | }; | |
| 622 | self.render_band("thead", "th", head, out); | |
| 623 | self.render_band("tbody", "td", body, out); | |
| 624 | out.push_str("</table>\n"); | |
| 625 | } | |
| 626 | ||
| 627 | fn render_table_caption(&mut self, caption: &[Object], out: &mut String) { | |
| 628 | self.tables += 1; | |
| 629 | out.push_str(&format!( | |
| 630 | "<caption><span class=\"table-number\">Table {}: </span>", | |
| 631 | self.tables | |
| 632 | )); | |
| 633 | self.render_objects(caption, out); | |
| 634 | out.push_str("</caption>\n"); | |
| 635 | } | |
| 636 | ||
| 637 | /// One `<thead>` or `<tbody>` of `rows`, skipping rule rows. Nothing at all when | |
| 638 | /// there are no cell rows. | |
| 639 | fn render_band(&mut self, band: &str, cell_tag: &str, rows: &[TableRow], out: &mut String) { | |
| 640 | let mut opened = false; | |
| 641 | for row in rows { | |
| 642 | let TableRow::Cells(cells) = row else { continue }; | |
| 643 | if !opened { | |
| 644 | out.push_str(&format!("<{band}>\n")); | |
| 645 | opened = true; | |
| 646 | } | |
| 647 | out.push_str("<tr>"); | |
| 648 | for cell in cells { | |
| 649 | out.push_str(&format!("<{}>", cell_tag)); | |
| 650 | self.render_objects(cell, out); | |
| 651 | out.push_str(&format!("</{}>", cell_tag)); | |
| 645 | 652 | } |
| 653 | out.push_str("</tr>\n"); | |
| 646 | 654 | } |
| 647 | if wrote_body { | |
| 648 | out.push_str("</tbody>\n"); | |
| 655 | if opened { | |
| 656 | out.push_str(&format!("</{band}>\n")); | |
| 649 | 657 | } |
| 650 | out.push_str("</table>\n"); | |
| 651 | 658 | } |
| 652 | 659 | |
| 653 | 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 | 813 | /// the next `:key` token and may be double-quoted to include spaces. A malformed spec |
| 807 | 814 | /// contributes nothing rather than emitting broken markup. |
| 808 | 815 | fn attr_html(spec: &str) -> Vec<(String, String)> { |
| 809 | let mut out = Vec::new(); | |
| 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 | ||
| 816 | let mut parser = AttrParser::default(); | |
| 821 | 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)] | |
| 825 | struct 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 | ||
| 833 | impl<'a> AttrParser<'a> { | |
| 834 | fn token(&mut self, token: &'a str) { | |
| 822 | 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 | 837 | buf.push(' '); |
| 825 | 838 | buf.push_str(token.trim_end_matches('"')); |
| 826 | 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 | 844 | if let Some(k) = token.strip_prefix(':') { |
| 832 | flush(&mut out, &mut key, &mut value); | |
| 845 | self.flush(); | |
| 833 | 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() { | |
| 839 | continue; | |
| 851 | if self.key.is_none() { | |
| 852 | return; | |
| 840 | 853 | } |
| 841 | 854 | if let Some(rest) = token.strip_prefix('"') { |
| 842 | 855 | if let Some(inner) = rest.strip_suffix('"') { |
| 843 | value = inner.to_string(); | |
| 856 | self.value = inner.to_string(); | |
| 844 | 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() { | |
| 850 | value.push(' '); | |
| 862 | if !self.value.is_empty() { | |
| 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 { | |
| 855 | value = buf; | |
| 867 | ||
| 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 | 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 | 1182 | out = out.replace('"', """); |
| 1160 | 1183 | out |
| 1161 | 1184 | } |
| 1185 | ||
src/resolve.rs +25 −21
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | use camino::Utf8Path; |
| 15 | 15 | |
| 16 | 16 | use crate::index::{SymbolTable, TargetId}; |
| 17 | use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow}; | |
| 17 | use crate::model::{Document, Element, Link, LinkTarget, List, Object, Section, Table, TableRow}; | |
| 18 | 18 | use crate::util::{normalize_link_path, output_path, output_url}; |
| 19 | 19 | |
| 20 | 20 | /// A document whose links have been rewritten to concrete URLs. |
| @@ -95,26 +95,8 @@ impl Cx<'_> { | ||
| 95 | 95 | fn element(&mut self, el: &mut Element) { |
| 96 | 96 | match el { |
| 97 | 97 | Element::Paragraph(objs) => self.objects(objs), |
| 98 | Element::List(list) => { | |
| 99 | for item in &mut list.items { | |
| 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 | } | |
| 98 | Element::List(list) => self.list(list), | |
| 99 | Element::Table(table) => self.table(table), | |
| 118 | 100 | Element::Figure { caption, .. } => self.objects(caption), |
| 119 | 101 | Element::QuoteBlock(inner) |
| 120 | 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 | 136 | fn objects(&mut self, objs: &mut [Object]) { |
| 133 | 137 | for obj in objs { |
| 134 | 138 | match obj { |
src/site.rs +508 −362
| @@ -145,6 +145,17 @@ struct Listing { | ||
| 145 | 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. | |
| 150 | struct 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 | 159 | /// Split one listing's entries across numbered pages, appending each as its own |
| 149 | 160 | /// [`Listing`]. |
| 150 | 161 | /// |
| @@ -152,17 +163,15 @@ struct Listing { | ||
| 152 | 163 | /// changes — only pages 2..N are named by `paginate_output`. An empty listing still |
| 153 | 164 | /// emits page 1, because a section that exists but has nothing in it should be a page |
| 154 | 165 | /// saying so rather than a 404. |
| 155 | #[allow(clippy::too_many_arguments)] | |
| 156 | fn push_paginated( | |
| 157 | listings: &mut Vec<Listing>, | |
| 158 | collection: &config::Collection, | |
| 159 | output: Utf8PathBuf, | |
| 160 | title: String, | |
| 161 | entries: Vec<PageContext>, | |
| 162 | entry_hashes: Vec<ContentHash>, | |
| 163 | group: Option<GroupContext>, | |
| 164 | groups: Vec<GroupContext>, | |
| 165 | ) { | |
| 166 | fn push_paginated(listings: &mut Vec<Listing>, collection: &config::Collection, page: ListingPage) { | |
| 167 | let ListingPage { | |
| 168 | output, | |
| 169 | title, | |
| 170 | entries, | |
| 171 | entry_hashes, | |
| 172 | group, | |
| 173 | groups, | |
| 174 | } = page; | |
| 166 | 175 | let per_page = collection.paginate; |
| 167 | 176 | if per_page == 0 { |
| 168 | 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. | |
| 247 | fn 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. | |
| 271 | fn 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. | |
| 293 | fn 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. | |
| 314 | fn 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. | |
| 357 | fn 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 | 378 | /// Build the listing pages a config asks for, each with its entries sorted. |
| 238 | 379 | fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { |
| 239 | 380 | let mut listings = Vec::new(); |
| @@ -248,111 +389,29 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | ||
| 248 | 389 | .collect() |
| 249 | 390 | }; |
| 250 | 391 | for collection in &config.collections { |
| 251 | let mut entries: Vec<PageContext> = 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 | } | |
| 392 | let entries = collection_entries(collection, preps); | |
| 290 | 393 | |
| 291 | 394 | if collection.group_by.is_empty() { |
| 292 | 395 | let entry_hashes = hashes_of(&entries); |
| 293 | 396 | push_paginated( |
| 294 | 397 | &mut listings, |
| 295 | 398 | collection, |
| 296 | collection.output.clone(), | |
| 297 | collection.title.clone(), | |
| 298 | entries, | |
| 299 | entry_hashes, | |
| 300 | None, | |
| 301 | Vec::new(), | |
| 399 | ListingPage { | |
| 400 | output: collection.output.clone(), | |
| 401 | title: collection.title.clone(), | |
| 402 | entries, | |
| 403 | entry_hashes, | |
| 404 | group: None, | |
| 405 | groups: Vec::new(), | |
| 406 | }, | |
| 302 | 407 | ); |
| 303 | 408 | continue; |
| 304 | 409 | } |
| 305 | 410 | |
| 306 | 411 | // Grouped: one page per distinct term. `entries` is already sorted, and grouping |
| 307 | 412 | // preserves that order within each group. |
| 308 | let mut terms: Vec<String> = Vec::new(); | |
| 309 | let mut members: HashMap<String, Vec<PageContext>> = HashMap::new(); | |
| 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 | } | |
| 413 | let (terms, members) = group_members(&entries, &collection.group_by); | |
| 414 | let groups = group_contexts(collection, &terms, &members)?; | |
| 356 | 415 | |
| 357 | 416 | if !collection.output.as_str().is_empty() { |
| 358 | 417 | for group in &groups { |
| @@ -361,19 +420,21 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | ||
| 361 | 420 | push_paginated( |
| 362 | 421 | &mut listings, |
| 363 | 422 | collection, |
| 364 | Utf8PathBuf::from(&group.url), | |
| 365 | collection | |
| 366 | .title | |
| 367 | .replace(config::GROUP_PLACEHOLDER, &group.name), | |
| 368 | members_of, | |
| 369 | entry_hashes, | |
| 370 | Some(group.clone()), | |
| 371 | // Deliberately not the whole group list. A page that can see every | |
| 372 | // group depends on every group, so one new post would re-render every | |
| 373 | // tag page — cost that scales with tag count, to support a tag cloud | |
| 374 | // nobody has asked for. A tag page depends on its own posts, and the | |
| 375 | // group index is where the group list belongs. | |
| 376 | Vec::new(), | |
| 423 | ListingPage { | |
| 424 | output: Utf8PathBuf::from(&group.url), | |
| 425 | title: collection | |
| 426 | .title | |
| 427 | .replace(config::GROUP_PLACEHOLDER, &group.name), | |
| 428 | entries: members_of, | |
| 429 | entry_hashes, | |
| 430 | group: Some(group.clone()), | |
| 431 | // Deliberately not the whole group list. A page that can see every | |
| 432 | // group depends on every group, so one new post would re-render every | |
| 433 | // tag page — cost that scales with tag count, to support a tag cloud | |
| 434 | // nobody has asked for. A tag page depends on its own posts, and the | |
| 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 | |
| 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 | } | |
| 457 | check_listing_outputs(&listings, preps)?; | |
| 413 | 458 | Ok(listings) |
| 414 | 459 | } |
| 415 | 460 | |
| @@ -820,6 +865,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 820 | 865 | check_page_templates(&templater, &preps)?; |
| 821 | 866 | let site = site_context(&config); |
| 822 | 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 | 877 | let mut pages = Vec::new(); |
| 825 | 878 | let mut broken = Vec::new(); |
| @@ -827,7 +880,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 827 | 880 | for t in &p.broken { |
| 828 | 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 | 884 | pages.push(BuiltPage { |
| 832 | 885 | source: p.source.clone(), |
| 833 | 886 | output: p.output.clone(), |
| @@ -868,29 +921,64 @@ fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>> | ||
| 868 | 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. | |
| 872 | #[allow(clippy::too_many_arguments)] | |
| 873 | fn render_page( | |
| 874 | templater: &Templater, | |
| 875 | highlighter: &SyntectHighlighter, | |
| 876 | site: &SiteContext, | |
| 877 | pages: Option<&[PageContext]>, | |
| 878 | config: &Config, | |
| 879 | p: &PagePrep, | |
| 880 | ) -> Result<String> { | |
| 881 | let opts = render_options(config); | |
| 882 | let Html(fragment) = render_with(&p.resolved, highlighter, &opts); | |
| 883 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. | |
| 884 | let root = relative_root(&p.output); | |
| 885 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); | |
| 886 | let theme = theme_href(config, &root); | |
| 887 | let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root); | |
| 888 | ctx.body = &fragment; | |
| 889 | ctx.theme = &theme; | |
| 890 | ctx.pages = pages; | |
| 891 | templater | |
| 892 | .render(&p.template, &ctx) | |
| 893 | .with_context(|| format!("templating {} through {}", p.source, p.template)) | |
| 924 | /// What every page render in one build shares. | |
| 925 | struct PageRenderer<'a> { | |
| 926 | templater: &'a Templater, | |
| 927 | highlighter: &'a SyntectHighlighter, | |
| 928 | site: &'a SiteContext, | |
| 929 | /// The `pages` list templates see (see [`page_listing`]). | |
| 930 | pages: Option<&'a [PageContext]>, | |
| 931 | config: &'a Config, | |
| 932 | preps: &'a [PagePrep], | |
| 933 | } | |
| 934 | ||
| 935 | impl PageRenderer<'_> { | |
| 936 | /// RENDER + TEMPLATE one prepared page into its final HTML string. | |
| 937 | fn render_page(&self, p: &PagePrep) -> Result<String> { | |
| 938 | let opts = render_options(self.config); | |
| 939 | let Html(fragment) = render_with(&p.resolved, self.highlighter, &opts); | |
| 940 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. | |
| 941 | let root = relative_root(&p.output); | |
| 942 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); | |
| 943 | let theme = theme_href(self.config, &root); | |
| 944 | let mut ctx = RenderContext::new(self.site, &p.context, &p.nav, &stylesheet, &root); | |
| 945 | ctx.body = &fragment; | |
| 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 | 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 | 1046 | /// `render_key` changed or that link into a changed file's targets; reuses the on-disk |
| 959 | 1047 | /// output of everything else; persists an updated cache manifest. |
| 960 | 1048 | pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { |
| 961 | let mut cfg = match &opts.config_path { | |
| 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; | |
| 1049 | let cfg = build_config(src, opts)?; | |
| 968 | 1050 | |
| 969 | 1051 | // Create the output directory up front so it can be recognised and excluded when it |
| 970 | 1052 | // lives inside the source tree. |
| @@ -977,21 +1059,127 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | ||
| 977 | 1059 | check_page_templates(&templater, &preps)?; |
| 978 | 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 | |
| 981 | // config hash is combined with a site-structure hash covering the global chrome each | |
| 982 | // page carries, so a change to that chrome re-renders the pages showing it. | |
| 983 | // | |
| 984 | // Which pages belong in that hash depends on what a template can *see*. Normally it | |
| 985 | // is the nav only — a nested page cannot change another page's nav, so adding a blog | |
| 986 | // post should render one page, not the site. But `expose_page_list` hands every | |
| 987 | // template every page's metadata, and then any page's output really can depend on | |
| 988 | // any other page, so the hash has to widen to match. Keyed on output paths, since a | |
| 989 | // `#+SLUG:` change moves a page's URL without moving its source. | |
| 1062 | let cfg_hash = combine(config_hash(&cfg), structure_hash(&cfg, &preps)); | |
| 1063 | // Per template rather than per site: a page's render key covers the layout it uses | |
| 1064 | // and that layout's own includes, so editing `feed.xml` re-renders the feed. | |
| 1065 | let tmpl_hash_for = |name: &str| template_hash(&templater.sources_for(name)); | |
| 1066 | ||
| 1067 | let listings = build_listings(&cfg, &preps)?; | |
| 1068 | let mut keys = page_keys(&preps, &symbols, cfg_hash, &tmpl_hash_for); | |
| 1069 | ||
| 1070 | // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None | |
| 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. | |
| 1156 | fn 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. | |
| 1177 | fn structure_hash(cfg: &Config, preps: &[PagePrep]) -> Hash { | |
| 990 | 1178 | let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps |
| 991 | 1179 | .iter() |
| 992 | 1180 | .map(|p| (p.source.clone(), p.output.clone(), p.title.clone())) |
| 993 | 1181 | .collect(); |
| 994 | let structure_hash = if cfg.templates.expose_page_list { | |
| 1182 | if cfg.templates.expose_page_list { | |
| 995 | 1183 | let entries: Vec<(String, String)> = all_pages |
| 996 | 1184 | .iter() |
| 997 | 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 | 1188 | } else { |
| 1001 | 1189 | // The same selection the nav itself is built from, so the two can never drift. |
| 1002 | 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 | 1192 | .into_iter() |
| 1005 | 1193 | .map(|c| (c.output.to_string(), c.title.clone())) |
| 1006 | 1194 | .collect(); |
| 1007 | 1195 | site_structure_hash_ordered(&entries) |
| 1008 | }; | |
| 1009 | let cfg_hash = combine(config_hash(&cfg), structure_hash); | |
| 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)); | |
| 1196 | } | |
| 1197 | } | |
| 1013 | 1198 | |
| 1014 | // Compose each page's render key and record its dependency edges. | |
| 1015 | let mut new_graph = DepGraph::default(); | |
| 1016 | let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new(); | |
| 1017 | let listings = build_listings(&cfg, &preps)?; | |
| 1018 | for p in &preps { | |
| 1019 | let rlh = resolved_links_hash(&p.source, &p.used, &symbols); | |
| 1199 | /// Each page's composed render key and dependency edges. | |
| 1200 | struct PageKeys { | |
| 1201 | graph: DepGraph, | |
| 1202 | /// `(key, record, render key)`, where the key is a page's source path or a | |
| 1203 | /// listing's output path. | |
| 1204 | records: Vec<(Utf8PathBuf, PageRecord, Hash)>, | |
| 1205 | } | |
| 1206 | ||
| 1207 | fn 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 | 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()); | |
| 1022 | new_graph.uses.insert(p.source.clone(), p.used.clone()); | |
| 1023 | new_records.push(( | |
| 1220 | keys.graph.defines.insert(p.source.clone(), p.defines.clone()); | |
| 1221 | keys.graph.uses.insert(p.source.clone(), p.used.clone()); | |
| 1222 | keys.records.push(( | |
| 1024 | 1223 | p.source.clone(), |
| 1025 | 1224 | PageRecord { |
| 1026 | 1225 | content_hash: p.content_hash, |
| @@ -1030,55 +1229,54 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | ||
| 1030 | 1229 | key, |
| 1031 | 1230 | )); |
| 1032 | 1231 | } |
| 1232 | keys | |
| 1233 | } | |
| 1033 | 1234 | |
| 1034 | // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None | |
| 1035 | // ⇒ full rebuild (spec §4.5). | |
| 1036 | let prior = if opts.no_cache { | |
| 1037 | None | |
| 1038 | } else { | |
| 1039 | incremental::load_manifest(out) | |
| 1040 | }; | |
| 1041 | ||
| 1042 | let rebuild: HashSet<Utf8PathBuf> = compute_rebuild_set( | |
| 1043 | &preps, | |
| 1044 | &new_records, | |
| 1045 | &new_graph, | |
| 1046 | cfg_hash, | |
| 1047 | out, | |
| 1048 | prior.as_ref(), | |
| 1049 | ); | |
| 1050 | ||
| 1051 | // Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1: | |
| 1052 | // removed files). Their targets are already in the merged graph, so their linkers were | |
| 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 | } | |
| 1235 | /// Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1: | |
| 1236 | /// removed files). Their targets are already in the merged graph, so their linkers were | |
| 1237 | /// invalidated by [`compute_rebuild_set`]. | |
| 1238 | fn remove_deleted_outputs( | |
| 1239 | prior: &Manifest, | |
| 1240 | preps: &[PagePrep], | |
| 1241 | listings: &[Listing], | |
| 1242 | out: &Utf8Path, | |
| 1243 | ) { | |
| 1244 | // Keyed by source path for real pages and by output path for generated listings, | |
| 1245 | // which is also how each records itself in the manifest. Listings have to be in | |
| 1246 | // this set or the cleanup would delete the file it just decided to keep — and a | |
| 1247 | // removed collection genuinely should have its output deleted. | |
| 1248 | let mut current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect(); | |
| 1249 | current.extend(listings.iter().map(|l| &l.output)); | |
| 1250 | for (key, rec) in &prior.pages { | |
| 1251 | if !current.contains(key) { | |
| 1252 | let dest = out.join(&rec.output_path); | |
| 1253 | let _ = fs::remove_file(&dest); | |
| 1066 | 1254 | } |
| 1067 | 1255 | } |
| 1256 | } | |
| 1068 | 1257 | |
| 1069 | let highlighter = SyntectHighlighter::with_syntaxes(Some(&src.join(&cfg.highlight.syntaxes_dir))); | |
| 1070 | let site = site_context(&cfg); | |
| 1071 | let listing = page_listing(&cfg, &preps); | |
| 1072 | let mut report = SiteReport::default(); | |
| 1258 | fn create_parent(dest: &Utf8Path) -> Result<()> { | |
| 1259 | if let Some(parent) = dest.parent() { | |
| 1260 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | |
| 1261 | } | |
| 1262 | Ok(()) | |
| 1263 | } | |
| 1073 | 1264 | |
| 1074 | // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes | |
| 1075 | // (syntect highlighting and templating dominate), and each page writes only its own | |
| 1076 | // file, so the pages are independent. | |
| 1265 | /// RENDER + TEMPLATE + EMIT every page in `rebuild`, in parallel, and report on all of | |
| 1266 | /// them. | |
| 1267 | fn 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 | 1275 | // The parallel pass returns whether each page was written; the report is assembled |
| 1079 | 1276 | // sequentially afterwards from `preps` order. Pushing to the report from inside the |
| 1080 | 1277 | // parallel pass would make `rendered`/`skipped` ordering depend on thread scheduling, |
| 1081 | 1278 | // which would be a non-deterministic build report over a deterministic build. |
| 1279 | let preps = renderer.preps; | |
| 1082 | 1280 | let written: Vec<bool> = preps |
| 1083 | 1281 | .par_iter() |
| 1084 | 1282 | .map(|p| { |
| @@ -1087,15 +1285,14 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | ||
| 1087 | 1285 | return Ok(false); |
| 1088 | 1286 | } |
| 1089 | 1287 | let dest = out.join(&p.output); |
| 1090 | if let Some(parent) = dest.parent() { | |
| 1091 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | |
| 1092 | } | |
| 1093 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &cfg, p)?; | |
| 1288 | create_parent(&dest)?; | |
| 1289 | let html = renderer.render_page(p)?; | |
| 1094 | 1290 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; |
| 1095 | 1291 | Ok(true) |
| 1096 | 1292 | }) |
| 1097 | 1293 | .collect::<Result<Vec<_>>>()?; |
| 1098 | 1294 | |
| 1295 | let mut report = SiteReport::default(); | |
| 1099 | 1296 | for (p, was_written) in preps.iter().zip(&written) { |
| 1100 | 1297 | for t in &p.broken { |
| 1101 | 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 | 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 | |
| 1115 | // cached on the one thing it actually depends on: the entries it lists. Adding a post | |
| 1116 | // therefore re-renders that section's index and nothing else — the same precision the | |
| 1117 | // rest of the build gets from content hashing. | |
| 1118 | for listing in &listings { | |
| 1119 | let key = combine( | |
| 1120 | listing_entries_hash(listing), | |
| 1121 | combine(cfg_hash, tmpl_hash_for(&listing.template)), | |
| 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 | ||
| 1313 | /// The syntax stylesheet, the theme stylesheet, and the sitemap. | |
| 1314 | fn write_site_files( | |
| 1315 | cfg: &Config, | |
| 1316 | preps: &[PagePrep], | |
| 1317 | syntax_css: &str, | |
| 1318 | pages: &[Utf8PathBuf], | |
| 1319 | out: &Utf8Path, | |
| 1320 | ) -> Result<()> { | |
| 1178 | 1321 | // The syntax stylesheet the highlighter's CSS classes refer to. Written every build |
| 1179 | 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 | 1324 | .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; |
| 1182 | 1325 | |
| 1183 | 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 | 1338 | .iter() |
| 1196 | 1339 | .filter_map(|p| Some((p.output.as_path(), p.context.date_iso.as_deref()?))) |
| 1197 | 1340 | .collect(); |
| 1198 | let xml = sitemap(&cfg.site.base_url, &report.pages, &dated); | |
| 1341 | let xml = sitemap(&cfg.site.base_url, pages, &dated); | |
| 1199 | 1342 | fs::write(out.join(SITEMAP), xml) |
| 1200 | 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 | |
| 1204 | // full-vs-incremental byte equivalence trivially true for non-`.org` files. | |
| 1205 | for asset in &assets { | |
| 1348 | /// Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the | |
| 1349 | /// full-vs-incremental byte equivalence trivially true for non-`.org` files. | |
| 1350 | fn copy_assets(assets: &[Asset], out: &Utf8Path, report: &mut SiteReport) -> Result<()> { | |
| 1351 | for asset in assets { | |
| 1206 | 1352 | let dest = out.join(&asset.rel); |
| 1207 | if let Some(parent) = dest.parent() { | |
| 1208 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | |
| 1209 | } | |
| 1353 | create_parent(&dest)?; | |
| 1210 | 1354 | fs::copy(&asset.from, &dest) |
| 1211 | 1355 | .with_context(|| format!("copying {} -> {dest}", asset.from))?; |
| 1212 | 1356 | report.assets.push(asset.rel.clone()); |
| 1213 | 1357 | } |
| 1358 | Ok(()) | |
| 1359 | } | |
| 1214 | 1360 | |
| 1215 | // Persist the manifest for the next build. | |
| 1216 | let manifest = Manifest { | |
| 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 | ||
| 1361 | /// Print the build's warnings, or fail with them under `--strict`. | |
| 1362 | fn report_warnings(report: &SiteReport, strict: bool) -> Result<()> { | |
| 1228 | 1363 | let warnings = report.warnings(); |
| 1229 | if opts.strict && !warnings.is_empty() { | |
| 1364 | if strict && !warnings.is_empty() { | |
| 1230 | 1365 | for w in &warnings { |
| 1231 | 1366 | eprintln!("error: {w}"); |
| 1232 | 1367 | } |
| @@ -1240,8 +1375,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | ||
| 1240 | 1375 | for w in &warnings { |
| 1241 | 1376 | eprintln!("warning: {w}"); |
| 1242 | 1377 | } |
| 1243 | ||
| 1244 | Ok(report) | |
| 1378 | Ok(()) | |
| 1245 | 1379 | } |
| 1246 | 1380 | |
| 1247 | 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 | 1390 | /// - any page whose **output file is missing** on disk. |
| 1257 | 1391 | fn compute_rebuild_set( |
| 1258 | 1392 | preps: &[PagePrep], |
| 1259 | new_records: &[(Utf8PathBuf, PageRecord, Hash)], | |
| 1260 | new_graph: &DepGraph, | |
| 1393 | keys: &PageKeys, | |
| 1261 | 1394 | cfg_hash: Hash, |
| 1262 | 1395 | out: &Utf8Path, |
| 1263 | 1396 | prior: Option<&Manifest>, |
| @@ -1288,12 +1421,12 @@ fn compute_rebuild_set( | ||
| 1288 | 1421 | |
| 1289 | 1422 | // Graph expansion: changed files ∪ pages that link into a changed file's targets. |
| 1290 | 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 | 1425 | let mut rebuild = incremental::invalidation_set(&changed, &merged); |
| 1293 | 1426 | |
| 1294 | 1427 | // Precise render_key delta (catches a linked target's URL change; also a belt for the |
| 1295 | 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 | 1430 | let unchanged = prior |
| 1298 | 1431 | .pages |
| 1299 | 1432 | .get(src_path) |
| @@ -1392,42 +1525,8 @@ fn collect_assets( | ||
| 1392 | 1525 | |
| 1393 | 1526 | for root in &config.build.assets { |
| 1394 | 1527 | let base = src.join(root); |
| 1395 | if !base.is_dir() { | |
| 1396 | anyhow::bail!( | |
| 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 | } | |
| 1528 | check_asset_root(root, &base, out)?; | |
| 1529 | assets.extend(walk_asset_root(&base)?); | |
| 1431 | 1530 | } |
| 1432 | 1531 | |
| 1433 | 1532 | let mut seen: HashMap<&Utf8Path, &Utf8Path> = HashMap::new(); |
| @@ -1444,6 +1543,53 @@ fn collect_assets( | ||
| 1444 | 1543 | Ok(assets) |
| 1445 | 1544 | } |
| 1446 | 1545 | |
| 1546 | /// Reject an asset root that is missing or that would copy the site into itself. | |
| 1547 | fn 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. | |
| 1572 | fn 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 | 1593 | /// Source-relative directories that DISCOVER must not descend into: the template |
| 1448 | 1594 | /// directory (build input, not content) and the output directory when it lives inside |
| 1449 | 1595 | /// the source. |
src/skeleton.rs +27 −26
| @@ -63,47 +63,47 @@ pub fn skeleton(html: &str) -> Vec<String> { | ||
| 63 | 63 | i += 1; |
| 64 | 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 | 66 | let Some(end) = find_from(&chars, i, ">") else { |
| 76 | 67 | break; |
| 77 | 68 | }; |
| 69 | // Comments and doctypes carry nothing. | |
| 70 | let is_markup_declaration = chars[i..].starts_with(&['<', '!']); | |
| 78 | 71 | let raw: String = chars[i + 1..end].iter().collect(); |
| 79 | 72 | i = end + 1; |
| 80 | ||
| 81 | let raw = raw.trim().trim_end_matches('/').trim().to_string(); | |
| 73 | if is_markup_declaration { | |
| 74 | continue; | |
| 75 | } | |
| 82 | 76 | // Text is flushed only when a tag is actually *emitted*. Text either side of an |
| 83 | 77 | // ignored tag therefore merges into one run, which is what makes a highlighted |
| 84 | 78 | // source block compare as the one string of code it is, rather than as a |
| 85 | 79 | // token-by-token sequence that has to line up exactly. |
| 86 | if let Some(name) = raw.strip_prefix('/') { | |
| 87 | let name = name.trim().to_ascii_lowercase(); | |
| 88 | if !IGNORED.contains(&name.as_str()) && !VOID.contains(&name.as_str()) { | |
| 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; | |
| 80 | if let Some(event) = tag_event(&raw) { | |
| 81 | flush_text(&mut text, &mut out); | |
| 82 | out.push(event); | |
| 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 | 85 | flush_text(&mut text, &mut out); |
| 104 | 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. | |
| 91 | fn 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 | ||
| 107 | 107 | fn flush_text(text: &mut String, out: &mut Vec<String>) { |
| 108 | 108 | let decoded = decode_entities(text); |
| 109 | 109 | let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" "); |
| @@ -207,3 +207,4 @@ fn numeric(name: &str) -> Option<char> { | ||
| 207 | 207 | }) |
| 208 | 208 | .and_then(char::from_u32) |
| 209 | 209 | } |
| 210 | ||
src/util.rs +34 −21
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | |
| 5 | 5 | use camino::{Utf8Path, Utf8PathBuf}; |
| 6 | 6 | |
| 7 | use crate::model::{Element, Heading, Keywords, Object, Section, TableRow}; | |
| 7 | use crate::model::{Element, Heading, Keywords, List, Object, Section, Table, TableRow}; | |
| 8 | 8 | |
| 9 | 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 | 209 | for element in elements { |
| 210 | 210 | match element { |
| 211 | 211 | Element::Paragraph(objs) => push_words(&plain_text(objs), out), |
| 212 | Element::List(list) => { | |
| 213 | for item in &list.items { | |
| 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 | } | |
| 212 | Element::List(list) => list_text(list, out), | |
| 213 | Element::Table(table) => table_text(table, out), | |
| 229 | 214 | Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out), |
| 230 | 215 | Element::Figure { caption, .. } => push_words(&plain_text(caption), out), |
| 231 | 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 | |
| 223 | fn 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 | ||
| 232 | fn 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 | ||
| 238 | 242 | fn push_words(text: &str, out: &mut String) { |
| 239 | 243 | let text = text.trim(); |
| 240 | 244 | if text.is_empty() { |
| @@ -377,12 +381,21 @@ pub fn iso_time(raw: &str) -> Option<String> { | ||
| 377 | 381 | None |
| 378 | 382 | } |
| 379 | 383 | |
| 384 | /// Whether ten bytes have the shape `YYYY-MM-DD`. Says nothing about whether the date | |
| 385 | /// exists. | |
| 386 | pub 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 | ||
| 380 | 395 | pub fn iso_date(raw: &str) -> Option<String> { |
| 381 | 396 | let bytes = raw.as_bytes(); |
| 382 | 397 | for i in 0..bytes.len().saturating_sub(9) { |
| 383 | let window = &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) { | |
| 398 | if is_iso_date_shape(&bytes[i..i + 10]) { | |
| 386 | 399 | // Must not be part of a longer number, or `123-45-6789` would parse. |
| 387 | 400 | let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); |
| 388 | 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 | 155 | /// `file.org~` backups, which do not start with a dot and would otherwise look like a |
| 156 | 156 | /// content change to a tool aimed squarely at Emacs users. |
| 157 | 157 | fn is_editor_scratch(name: &str) -> bool { |
| 158 | name.ends_with('~') | |
| 159 | || name.ends_with(".swp") | |
| 160 | || name.ends_with(".swx") | |
| 161 | || name.ends_with(".tmp") | |
| 158 | ["~", ".swp", ".swx", ".tmp"].iter().any(|s| name.ends_with(s)) | |
| 162 | 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 | 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] | |
| 676 | fn 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 | 690 | /// An affiliated keyword belongs to the element *immediately* below it. Someone who |
| 675 | 691 | /// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next |
| 676 | 692 | /// image instead would put the wrong words under the wrong picture. |