krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.22.0: tests/site.rs · raw

  1//! Multi-file site build + new-construct snapshots for v0.2 (spec §5, Phase 4/5).
  2//!
  3//! Layers, per the spec's testing philosophy: rendered-HTML snapshots (resolved links,
  4//! templated pages, tables, footnotes) plus explicit assertions that a cross-file link
  5//! resolves to the right URL and that unresolved links are reported, not fatal.
  6
  7use camino::Utf8PathBuf;
  8
  9use orgo::index::{SymbolTable, TargetId};
 10use orgo::parser::parse;
 11use orgo::render::{render, Html, SyntectHighlighter};
 12use orgo::resolve::{resolve, ResolvedDoc};
 13use orgo::site::{render_site, BuiltPage};
 14
 15fn fixtures() -> Utf8PathBuf {
 16    Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
 17}
 18
 19fn build_fixture_site() -> Vec<BuiltPage> {
 20    let (pages, broken) = render_site(&fixtures().join("site")).expect("build site");
 21    assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}");
 22    pages
 23}
 24
 25fn page<'a>(pages: &'a [BuiltPage], source: &str) -> &'a BuiltPage {
 26    pages
 27        .iter()
 28        .find(|p| p.source == source)
 29        .unwrap_or_else(|| panic!("no page for {source}"))
 30}
 31
 32fn render_fragment(name: &str) -> String {
 33    let path = fixtures().join(name);
 34    let source = std::fs::read_to_string(&path).expect("read fixture");
 35    let document = parse(Utf8PathBuf::from(name).as_path(), &source).expect("parse");
 36    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
 37    html
 38}
 39
 40#[test]
 41fn site_index_html() {
 42    let pages = build_fixture_site();
 43    insta::assert_snapshot!(page(&pages, "index.org").html);
 44}
 45
 46#[test]
 47fn site_guide_html() {
 48    let pages = build_fixture_site();
 49    insta::assert_snapshot!(page(&pages, "guide.org").html);
 50}
 51
 52/// The invariant the whole RESOLVE stage exists for: a cross-file `[[#setup]]` link on
 53/// the home page must resolve to the guide page's URL plus the target anchor.
 54#[test]
 55fn cross_file_link_resolves() {
 56    let pages = build_fixture_site();
 57    let index = &page(&pages, "index.org").html;
 58    assert!(
 59        index.contains("href=\"guide.html#setup\""),
 60        "cross-file custom-id link should resolve to guide.html#setup, got:\n{index}"
 61    );
 62    // The `file:` link resolves to the bare output path.
 63    assert!(
 64        index.contains("href=\"guide.html\""),
 65        "file: link should resolve to guide.html"
 66    );
 67    // A same-page `[[*Overview]]` link stays a local fragment.
 68    assert!(
 69        index.contains("href=\"#overview\""),
 70        "same-page heading link should be a local fragment"
 71    );
 72}
 73
 74/// Unresolved internal links are reported as warnings, never a crash (spec §4.3.4).
 75#[test]
 76fn unresolved_link_is_reported_not_fatal() {
 77    let source = "Broken [[#does-not-exist][link]] here.\n";
 78    let doc = parse(Utf8PathBuf::from("orphan.org").as_path(), source).expect("parse");
 79    let mut symbols = SymbolTable::new();
 80    symbols.index_document(&doc);
 81    let out = resolve(&doc, &symbols);
 82    assert_eq!(out.broken.len(), 1, "one unresolved link expected");
 83    assert_eq!(
 84        out.broken[0].target,
 85        TargetId::CustomId("does-not-exist".into())
 86    );
 87    assert!(out.used_targets.is_empty(), "nothing resolved, nothing used");
 88}
 89
 90/// RESOLVE records the `uses` edges (spec §4.3, R2) even though incrementality does
 91/// not consume them yet.
 92#[test]
 93fn resolve_records_used_targets() {
 94    let pages = build_fixture_site();
 95    // index.org uses: guide's #setup, guide.org (file), and its own *Overview → 3.
 96    let _ = pages; // pages already assert no broken links; check the edge count directly.
 97    let src = fixtures().join("site").join("index.org");
 98    let source = std::fs::read_to_string(&src).unwrap();
 99    let doc = parse(Utf8PathBuf::from("index.org").as_path(), &source).unwrap();
100
101    let guide_src = fixtures().join("site").join("guide.org");
102    let guide = parse(
103        Utf8PathBuf::from("guide.org").as_path(),
104        &std::fs::read_to_string(&guide_src).unwrap(),
105    )
106    .unwrap();
107
108    let mut symbols = SymbolTable::new();
109    symbols.index_document(&doc);
110    symbols.index_document(&guide);
111    let out = resolve(&doc, &symbols);
112    assert_eq!(out.used_targets.len(), 3, "three internal links resolved");
113    assert!(out.broken.is_empty());
114}
115
116#[test]
117fn table_render() {
118    insta::assert_snapshot!(render_fragment("table.org"));
119}
120
121#[test]
122fn footnote_render() {
123    insta::assert_snapshot!(render_fragment("footnote.org"));
124}
125
126// ---------------------------------------------------------------------------
127// `#+SLUG:` output paths (Phase 0 corpus-audit finding)
128// ---------------------------------------------------------------------------
129
130/// The audit found `#+SLUG:` in 178 of the target corpus's 179 files, and the live site
131/// derives every URL from it — `2018-11-28-aes-encryption.org` publishes as
132/// `aes-encryption.html`. Deriving output paths from source filenames would therefore
133/// have rewritten every URL on the site.
134#[test]
135fn slug_renames_the_output_page() {
136    let (pages, broken) = render_site(&fixtures().join("slugsite")).expect("build site");
137    assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}");
138    let post = pages
139        .iter()
140        .find(|p| p.source == "2024-02-11-long-source-name.org")
141        .expect("post page");
142    assert_eq!(
143        post.output, "short-url.html",
144        "the slug names the output file, not the source stem"
145    );
146}
147
148/// A link's URL has to follow the target's slug. If resolution kept using source paths,
149/// every cross-page link would point at a file that was never written.
150#[test]
151fn links_resolve_through_the_slug() {
152    let (pages, _) = render_site(&fixtures().join("slugsite")).expect("build site");
153    let index = &page(&pages, "index.org").html;
154    assert!(
155        index.contains("href=\"short-url.html\""),
156        "a file: link must target the slugged page:\n{index}"
157    );
158    assert!(
159        index.contains("href=\"short-url.html#setup\""),
160        "a custom-id link must target the slugged page plus the anchor:\n{index}"
161    );
162    assert!(
163        !index.contains("long-source-name"),
164        "no URL may mention the source filename:\n{index}"
165    );
166}
167
168/// A slug is author-controlled text that becomes a path we write to, so traversal has to
169/// be impossible by construction rather than by convention.
170#[test]
171fn slugs_cannot_escape_the_output_directory() {
172    use orgo::model::Keywords;
173    let source = Utf8PathBuf::from("blog/post.org");
174    let slugged = |value: &str| {
175        let keywords = Keywords {
176            entries: vec![("SLUG".to_string(), value.to_string())],
177        };
178        orgo::util::output_path(&source, &keywords).to_string()
179    };
180    assert_eq!(slugged("../../etc/passwd"), "blog/etc-passwd.html");
181    assert_eq!(slugged("/absolute"), "blog/absolute.html");
182    assert_eq!(slugged(".hidden"), "blog/hidden.html");
183    assert_eq!(slugged("Mixed Case Slug"), "blog/mixed-case-slug.html");
184    // An empty or punctuation-only slug falls back to the source stem rather than
185    // producing `.html` with no name at all.
186    assert_eq!(slugged("///"), "blog/post.html");
187}
188
189/// Two pages claiming one URL silently drops a page. With slugs that is a typo away and
190/// invisible in the source filenames, so the build refuses rather than picking a winner.
191#[test]
192fn colliding_slugs_are_a_build_error() {
193    let dir = std::env::temp_dir().join(format!("orgo-slug-{}", std::process::id()));
194    let dir = Utf8PathBuf::from_path_buf(dir).expect("utf-8 temp dir");
195    let _ = std::fs::remove_dir_all(&dir);
196    std::fs::create_dir_all(&dir).unwrap();
197    std::fs::write(dir.join("a.org"), "#+TITLE: A\n#+SLUG: same\n").unwrap();
198    std::fs::write(dir.join("b.org"), "#+TITLE: B\n#+SLUG: same\n").unwrap();
199
200    let err = render_site(&dir).expect_err("colliding slugs must fail the build");
201    let message = format!("{err:#}");
202    assert!(
203        message.contains("collision") && message.contains("same.html"),
204        "the error must name the collision: {message}"
205    );
206    std::fs::remove_dir_all(&dir).unwrap();
207}