krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
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}