Skip to main content

max / makenotwork

13.4 KB · 403 lines History Blame Raw
1 //! Documentation page routes: index and individual doc pages.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 response::IntoResponse,
7 };
8 use tower_sessions::Session;
9
10 use crate::{
11 auth::MaybeUserUnverified,
12 error::{AppError, Result},
13 helpers::get_csrf_token,
14 templates::{DocIndexTemplate, DocSection, DocSectionEntry, DocSubsection, DocTemplate},
15 };
16
17 /// The curated order of the Guide section on `/docs`.
18 ///
19 /// An allowlist, so it names slugs rather than deriving them, and a slug named
20 /// here that no longer exists is simply skipped. The unit test below is what
21 /// keeps that silent skip from accumulating; entries that were never valid
22 /// ("security", "pricing", which live in other sections) sat here unnoticed
23 /// until it existed.
24 const SUBCATEGORIES: &[(&str, &[&str])] = &[
25 (
26 "Getting Started",
27 &[
28 "getting-started",
29 "sandbox",
30 "profile",
31 "account-security",
32 "password-reset",
33 "account-lifecycle",
34 "creator-pause",
35 "best-practices",
36 ],
37 ),
38 (
39 "Content & Organization",
40 &[
41 "02-content",
42 "items",
43 "projects",
44 "audio",
45 "video",
46 "software",
47 "tags",
48 "metadata",
49 "collections",
50 "blog",
51 "media-library",
52 "dynamic-clips",
53 "custom-pages",
54 "import",
55 ],
56 ),
57 (
58 "Selling & Revenue",
59 &[
60 "03-selling",
61 "payouts",
62 "analytics",
63 "promo-codes",
64 "contact-sharing",
65 "fan-plus",
66 "bundles",
67 "tips",
68 "splits",
69 "cart",
70 "stripe",
71 ],
72 ),
73 (
74 "Fans & Distribution",
75 &[
76 "fan-guide",
77 "discovery",
78 "feed",
79 "rss",
80 "mailing-lists",
81 "email-you-cannot-turn-off",
82 "embeds",
83 "wishlist",
84 "forum-moderation",
85 "export",
86 ],
87 ),
88 ("Advanced", &["custom-domains", "git", "migration", "tiers"]),
89 ];
90
91 /// Bucket the Guide entries into [`SUBCATEGORIES`] order.
92 ///
93 /// Curated order first, then a "More" bucket holding everything the allowlist
94 /// does not name. That tail is the whole point: the allowlist used to be the
95 /// only way onto the index, so 17 of 82 published pages rendered fine on a
96 /// direct URL and could not be navigated to from anywhere (loose-wire g2-04).
97 /// Every entry in must appear exactly once out.
98 fn bucket_guide(entries: &[DocSectionEntry]) -> Vec<DocSubsection> {
99 let mut subsections = Vec::new();
100 let mut placed: Vec<&str> = Vec::new();
101
102 for &(label, slugs) in SUBCATEGORIES {
103 let sub_entries: Vec<DocSectionEntry> = slugs
104 .iter()
105 .filter_map(|&slug| entries.iter().find(|e| e.slug == slug))
106 .map(|e| {
107 placed.push(e.slug.as_str());
108 DocSectionEntry {
109 title: e.title.clone(),
110 slug: e.slug.clone(),
111 }
112 })
113 .collect();
114 if !sub_entries.is_empty() {
115 subsections.push(DocSubsection {
116 label: label.to_string(),
117 entries: sub_entries,
118 });
119 }
120 }
121
122 let uncategorized: Vec<DocSectionEntry> = entries
123 .iter()
124 .filter(|e| !placed.contains(&e.slug.as_str()))
125 .map(|e| DocSectionEntry {
126 title: e.title.clone(),
127 slug: e.slug.clone(),
128 })
129 .collect();
130 if !uncategorized.is_empty() {
131 subsections.push(DocSubsection {
132 label: "More".to_string(),
133 entries: uncategorized,
134 });
135 }
136
137 subsections
138 }
139
140 /// GET /docs: index page listing all docs grouped by section.
141 #[tracing::instrument(skip_all, name = "docs::docs_index")]
142 pub(super) async fn docs_index(
143 State(docs): State<std::sync::Arc<docengine::DocLoader>>,
144 session: Session,
145 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
146 ) -> Result<impl IntoResponse> {
147 let csrf_token = get_csrf_token(&session).await;
148
149 // Group index entries by section, preserving load order.
150 let mut sections: Vec<DocSection> = Vec::new();
151 for entry in docs.index() {
152 let section = sections.iter_mut().find(|s| s.name == entry.section);
153 match section {
154 Some(s) => {
155 s.entries.push(DocSectionEntry {
156 title: entry.title.clone(),
157 slug: entry.slug.clone(),
158 });
159 }
160 None => {
161 sections.push(DocSection {
162 name: entry.section.clone(),
163 entries: vec![DocSectionEntry {
164 title: entry.title.clone(),
165 slug: entry.slug.clone(),
166 }],
167 subsections: Vec::new(),
168 });
169 }
170 }
171 }
172
173 // Post-process the Guide section: bucket entries into subcategories.
174 if let Some(guide) = sections.iter_mut().find(|s| s.name == "Guide") {
175 let entries = std::mem::take(&mut guide.entries);
176 guide.subsections = bucket_guide(&entries);
177 }
178
179 Ok(DocIndexTemplate {
180 csrf_token,
181 session_user: maybe_user,
182 sections,
183 })
184 }
185
186 /// GET /docs/search.json: full-text search index for client-side filtering.
187 #[tracing::instrument(skip_all, name = "docs::docs_search_index")]
188 pub(super) async fn docs_search_index(
189 State(docs): State<std::sync::Arc<docengine::DocLoader>>,
190 ) -> Json<Vec<docengine::DocSearchEntry>> {
191 Json(docs.search_index())
192 }
193
194 /// GET /docs/{slug}: individual doc page.
195 #[tracing::instrument(skip_all, name = "docs::doc_page")]
196 pub(super) async fn doc_page(
197 State(docs): State<std::sync::Arc<docengine::DocLoader>>,
198 session: Session,
199 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
200 Path(slug): Path<String>,
201 ) -> Result<impl IntoResponse> {
202 let page = docs.get(&slug).ok_or(AppError::NotFound)?;
203 let csrf_token = get_csrf_token(&session).await;
204
205 // "What links here": resolve each source slug to its title off the link
206 // graph. A source is always a served page; filter_map skips any that ever
207 // stops resolving rather than rendering a titleless entry.
208 let backlinks: Vec<DocSectionEntry> = docs
209 .backlinks(&slug)
210 .iter()
211 .filter_map(|src| {
212 docs.get(src).map(|p| DocSectionEntry {
213 title: p.title.clone(),
214 slug: src.clone(),
215 })
216 })
217 .collect();
218
219 Ok(DocTemplate {
220 csrf_token,
221 session_user: maybe_user,
222 title: page.title.clone(),
223 section: page.section.clone(),
224 content: page.html_content.clone(),
225 backlinks,
226 })
227 }
228
229 #[cfg(test)]
230 mod subcategory_tests {
231 use super::{DocSectionEntry, SUBCATEGORIES, bucket_guide};
232
233 fn entry(slug: &str) -> DocSectionEntry {
234 DocSectionEntry {
235 title: slug.to_string(),
236 slug: slug.to_string(),
237 }
238 }
239
240 /// The g2-04 regression: a page the allowlist does not name must still be
241 /// reachable, because the index is the only way to find it.
242 #[test]
243 fn an_unlisted_page_lands_in_more_rather_than_vanishing() {
244 let subs = bucket_guide(&[entry("getting-started"), entry("brand-new-page")]);
245 let more = subs
246 .iter()
247 .find(|s| s.label == "More")
248 .expect("unlisted page needs a bucket");
249 assert_eq!(more.entries.len(), 1);
250 assert_eq!(more.entries[0].slug, "brand-new-page");
251 }
252
253 /// Nothing is dropped and nothing is duplicated, whatever the input.
254 #[test]
255 fn every_entry_appears_exactly_once() {
256 let entries: Vec<DocSectionEntry> = ["getting-started", "items", "tips", "unlisted-one"]
257 .iter()
258 .map(|s| entry(s))
259 .collect();
260
261 let subs = bucket_guide(&entries);
262 let mut out: Vec<&str> = subs
263 .iter()
264 .flat_map(|s| s.entries.iter().map(|e| e.slug.as_str()))
265 .collect();
266 out.sort_unstable();
267
268 let mut expected: Vec<&str> = entries.iter().map(|e| e.slug.as_str()).collect();
269 expected.sort_unstable();
270
271 assert_eq!(out, expected);
272 }
273
274 /// With every entry named, "More" does not appear at all.
275 #[test]
276 fn no_more_bucket_when_everything_is_categorized() {
277 let subs = bucket_guide(&[entry("getting-started"), entry("items")]);
278 assert!(subs.iter().all(|s| s.label != "More"));
279 }
280
281 /// Every slug the curated order names must still be a Guide page.
282 ///
283 /// The bucketing skips a slug it cannot resolve, so a renamed or moved page
284 /// leaves a dead entry that nothing complains about. Two of these ("security"
285 /// and "pricing", both of which live in other sections and so were never
286 /// resolvable here) rode along until the g2-04 sweep.
287 #[test]
288 fn every_listed_slug_is_a_guide_page() {
289 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/site-docs/public/guide");
290 let on_disk: Vec<String> = std::fs::read_dir(dir)
291 .expect("guide docs directory")
292 .filter_map(std::result::Result::ok)
293 .filter_map(|e| {
294 let p = e.path();
295 (p.extension()? == "md").then(|| p.file_stem()?.to_str().map(String::from))?
296 })
297 .collect();
298
299 let dead: Vec<&str> = SUBCATEGORIES
300 .iter()
301 .flat_map(|&(_, slugs)| slugs.iter().copied())
302 .filter(|slug| !on_disk.iter().any(|f| f == slug))
303 .collect();
304
305 assert!(
306 dead.is_empty(),
307 "SUBCATEGORIES names missing pages: {dead:?}"
308 );
309 }
310
311 /// A slug listed twice would render the same page under two headings.
312 #[test]
313 fn no_slug_is_listed_twice() {
314 let mut seen: Vec<&str> = SUBCATEGORIES
315 .iter()
316 .flat_map(|&(_, slugs)| slugs.iter().copied())
317 .collect();
318 let before = seen.len();
319 seen.sort_unstable();
320 seen.dedup();
321 assert_eq!(before, seen.len(), "a slug appears in two subcategories");
322 }
323 }
324
325 #[cfg(test)]
326 mod template_link_tests {
327 /// Every `/docs/...` href in a template must be served.
328 ///
329 /// `MNW_CHECK_DOCS` walks the link graph *inside* the doc corpus, so a doc
330 /// linking a moved page fails the pipeline. Nothing did the same for links
331 /// pointing *into* the corpus from a template, which is how the landing
332 /// page's own "Video" link came to be verified by hand rather than by the
333 /// build. Templates are the higher-traffic direction: `/docs` is one page,
334 /// the landing page is the front door.
335 ///
336 /// Two shapes fail here, and only the first is a typo:
337 /// - a single-segment slug the loader does not resolve;
338 /// - any two-segment path, e.g. `/docs/guide/tiers`. The route is
339 /// `/docs/{slug}` and axum matches exactly one segment, so a
340 /// section-qualified link never reaches `doc_page` at all. Docs are
341 /// addressed by bare slug; the section is a display grouping.
342 #[test]
343 fn every_template_docs_link_resolves() {
344 // Registered as exact routes before the `/docs/{slug}` catch-all, so
345 // they are reachable without being loader slugs.
346 const NON_SLUG_ROUTES: &[&str] = &[
347 "", // /docs, the index
348 "search.json", // the search payload
349 "economics", // 301 to /economics; the markdown source is gone
350 ];
351
352 let assumptions = crate::site_docs::load_assumptions().expect("assumptions load");
353 let docs = crate::site_docs::build_doc_loader(assumptions);
354
355 let re = regex::Regex::new(r"/docs(?:/([a-z0-9][a-z0-9./-]*))?").expect("valid regex");
356
357 let mut dead: Vec<String> = Vec::new();
358 for path in html_templates(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")) {
359 let body = std::fs::read_to_string(&path).expect("readable template");
360 for caps in re.captures_iter(&body) {
361 let target = caps.get(1).map_or("", |m| m.as_str());
362 if NON_SLUG_ROUTES.contains(&target) {
363 continue;
364 }
365 let ok = !target.contains('/') && docs.get(target).is_some();
366 if !ok {
367 let file = path.rsplit('/').next().unwrap_or(&path);
368 dead.push(format!("{file}: /docs/{target}"));
369 }
370 }
371 }
372 dead.sort_unstable();
373 dead.dedup();
374
375 assert!(
376 dead.is_empty(),
377 "templates link to docs that are not served:\n {}",
378 dead.join("\n ")
379 );
380 }
381
382 /// Recursive `.html` walk. No `walkdir` in the tree, and this is the only
383 /// caller.
384 fn html_templates(root: &str) -> Vec<String> {
385 let mut out = Vec::new();
386 let mut stack = vec![std::path::PathBuf::from(root)];
387 while let Some(dir) = stack.pop() {
388 let Ok(entries) = std::fs::read_dir(&dir) else {
389 continue;
390 };
391 for entry in entries.filter_map(std::result::Result::ok) {
392 let p = entry.path();
393 if p.is_dir() {
394 stack.push(p);
395 } else if p.extension().is_some_and(|e| e == "html") {
396 out.push(p.to_string_lossy().into_owned());
397 }
398 }
399 }
400 out
401 }
402 }
403