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