Skip to main content

max / makenotwork

Check template docs links in the build, and fix the six that were dead MNW_CHECK_DOCS walks the link graph inside the doc corpus, so a doc pointing at a moved page reds the pipeline. Nothing did the same for links pointing into the corpus from a template, which is why verifying the landing page's own /docs/video link was a manual step on the video copy task rather than something the build already knew. every_template_docs_link_resolves resolves every /docs/... href under templates/ against the production DocLoader, built through site_docs so it cannot drift from what is served. It failed on first run with six links, all pre-existing: user_creator.html /docs/guide/tiers (x3) user_profile.html /docs/guide/custom-domains user_synckit.html /docs/developer/synckit project_synckit.html /docs/developer/synckit user_account.html /docs/account item_details.html /docs/ai The first four are the same mistake. The route is /docs/{slug} and axum matches exactly one segment, so a section-qualified path never reaches doc_page at all. Docs are addressed by bare slug; the section is a display grouping on the index. Those drop their prefix. The last two named slugs that do not exist. /docs/account goes to account-lifecycle, and /docs/ai to generative-ai, which is the page defining the handmade/assisted/generated tiers the selector beside that link offers. All six sit on settings tabs or the item editor, so they were reachable by any signed-in user and served a 404.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 20:42 UTC
Signed with PGP, not checked
Commit: 6e8900a7cb72df2d065365b90a7d9e3e2a742057
Parent: d889638
7 files changed, +88 insertions, -9 deletions
@@ -100,7 +100,7 @@
100 100 </div>
101 101
102 102 <div class="form-group">
103 - <label for="ai_tier">AI Classification <a href="/docs/ai" class="text-xs dimmed">What's this?</a></label>
103 + <label for="ai_tier">AI Classification <a href="/docs/generative-ai" class="text-xs dimmed">What's this?</a></label>
104 104 <select id="ai_tier" name="ai_tier">
105 105 <option value="handmade" {% if item.ai_tier == "handmade" %}selected{% endif %}>Handmade: no AI tools used</option>
106 106 <option value="assisted" {% if item.ai_tier == "assisted" %}selected{% endif %}>AI-Assisted: AI tools with human creation</option>
@@ -1,11 +1,11 @@
1 - <div class="tab-docs"><a href="/docs/developer/synckit">Docs: Cloud Sync &rarr;</a></div>
1 + <div class="tab-docs"><a href="/docs/synckit">Docs: Cloud Sync &rarr;</a></div>
2 2
3 3 <div class="content-section">
4 4 <div class="section-header">
5 5 <h2 class="subsection-title">Cloud Sync</h2>
6 6 </div>
7 7
8 - <p class="form-hint proj-synckit-intro">Cloud Sync lets your desktop or mobile apps sync data with Makenotwork using end-to-end encryption. If you're building an app that stores user data, create a sync app here to get an API key your app uses to connect. <a href="/docs/developer/synckit">Learn more</a></p>
8 + <p class="form-hint proj-synckit-intro">Cloud Sync lets your desktop or mobile apps sync data with Makenotwork using end-to-end encryption. If you're building an app that stores user data, create a sync app here to get an API key your app uses to connect. <a href="/docs/synckit">Learn more</a></p>
9 9 <p class="form-hint proj-synckit-intro-secondary">Most creators don't need this: it's for developers who ship their own software through Makenotwork.</p>
10 10
11 11 <!-- Create new app form -->
@@ -1,4 +1,4 @@
1 - <div class="tab-docs"><a href="/docs/account">Docs: Account &rarr;</a></div>
1 + <div class="tab-docs"><a href="/docs/account-lifecycle">Docs: Account &rarr;</a></div>
2 2
3 3 {% if !moderation_active.is_empty() || !moderation_history.is_empty() %}
4 4 <div class="form-section">
@@ -35,7 +35,7 @@
35 35 {% if let Some(period_end) = creator_period_end %}
36 36 <p class="muted text-sm">Current period ends {{ period_end }}</p>
37 37 {% endif %}
38 - <p class="muted text-sm mt-peer">{{ storage_total }} of {{ storage_max }} storage used ({{ storage_pct }}%). <a href="/docs/guide/tiers" class="muted">Plan limits &rarr;</a></p>
38 + <p class="muted text-sm mt-peer">{{ storage_total }} of {{ storage_max }} storage used ({{ storage_pct }}%). <a href="/docs/tiers" class="muted">Plan limits &rarr;</a></p>
39 39 </div>
40 40 {% else %}
41 41 <p class="muted text-sm mb-section">
@@ -55,7 +55,7 @@
55 55 <div class="creator-tier-grid mb-section">
56 56 {% for card in tier_cards %}
57 57 <div class="creator-tier-card">
58 - <div class="creator-tier-name"><a href="/docs/guide/tiers" class="creator-tier-link">{{ card.label }}</a></div>
58 + <div class="creator-tier-name"><a href="/docs/tiers" class="creator-tier-link">{{ card.label }}</a></div>
59 59 {% if show_founder_rate %}
60 60 <div class="meta mb-peer"><strong>${{ card.founder_monthly }}/mo</strong> <span class="strike-dim">${{ card.standard_monthly }}</span></div>
61 61 <div class="meta creator-tier-sub mb-peer">or ${{ card.founder_annual }}/yr (10% off)</div>
@@ -81,7 +81,7 @@
81 81 </div>
82 82 {% endfor %}
83 83 </div>
84 - <p class="text-center mb-section"><a href="/docs/guide/tiers" class="text-sm">Full tier details &rarr;</a></p>
84 + <p class="text-center mb-section"><a href="/docs/tiers" class="text-sm">Full tier details &rarr;</a></p>
85 85 <p class="text-center text-sm muted mb-section">
86 86 Upgrades take effect immediately and are prorated. Downgrades take effect at the next billing period; if you're over the new tier's storage cap, new uploads stay blocked until you're under it, but your existing content stays published either way.
87 87 </p>
@@ -155,7 +155,7 @@
155 155 <button type="button" class="btn-secondary dns-row-copy"
156 156 data-action="copyElementText" data-arg="dns-value">Copy</button>
157 157 </div>
158 - <p class="callout-hint">DNS changes can take up to 24 hours to propagate. <a href="/docs/guide/custom-domains">Setup guide</a></p>
158 + <p class="callout-hint">DNS changes can take up to 24 hours to propagate. <a href="/docs/custom-domains">Setup guide</a></p>
159 159 </div>
160 160 <div class="profile-domain-actions">
161 161 <button class="btn-primary"
@@ -3,7 +3,7 @@
3 3 <h2 class="subsection-title">Cloud Sync</h2>
4 4 </div>
5 5
6 - <p class="form-hint mb-section">Cloud Sync lets your desktop or mobile apps sync data with Makenotwork using end-to-end encryption. Each app gets an API key your app uses to connect. <a href="/docs/developer/synckit">Learn more</a></p>
6 + <p class="form-hint mb-section">Cloud Sync lets your desktop or mobile apps sync data with Makenotwork using end-to-end encryption. Each app gets an API key your app uses to connect. <a href="/docs/synckit">Learn more</a></p>
7 7 <p class="form-hint mb-pane">Most creators don't need this: it's for developers who ship their own software through Makenotwork.</p>
8 8
9 9 <!-- Create new app form -->
@@ -320,3 +320,82 @@
320 320 assert_eq!(before, seen.len(), "a slug appears in two subcategories");
321 321 }
322 322 }
323 +
324 + #[cfg(test)]
325 + mod template_link_tests {
326 + /// Every `/docs/...` href in a template must be served.
327 + ///
328 + /// `MNW_CHECK_DOCS` walks the link graph *inside* the doc corpus, so a doc
329 + /// linking a moved page fails the pipeline. Nothing did the same for links
330 + /// pointing *into* the corpus from a template, which is how the landing
331 + /// page's own "Video" link came to be verified by hand rather than by the
332 + /// build. Templates are the higher-traffic direction: `/docs` is one page,
333 + /// the landing page is the front door.
334 + ///
335 + /// Two shapes fail here, and only the first is a typo:
336 + /// - a single-segment slug the loader does not resolve;
337 + /// - any two-segment path, e.g. `/docs/guide/tiers`. The route is
338 + /// `/docs/{slug}` and axum matches exactly one segment, so a
339 + /// section-qualified link never reaches `doc_page` at all. Docs are
340 + /// addressed by bare slug; the section is a display grouping.
341 + #[test]
342 + fn every_template_docs_link_resolves() {
343 + // Registered as exact routes before the `/docs/{slug}` catch-all, so
344 + // they are reachable without being loader slugs.
345 + const NON_SLUG_ROUTES: &[&str] = &[
346 + "", // /docs, the index
347 + "search.json", // the search payload
348 + "economics", // 301 to /economics; the markdown source is gone
349 + ];
350 +
351 + let assumptions = crate::site_docs::load_assumptions().expect("assumptions load");
352 + let docs = crate::site_docs::build_doc_loader(assumptions);
353 +
354 + let re = regex::Regex::new(r"/docs(?:/([a-z0-9][a-z0-9./-]*))?").expect("valid regex");
355 +
356 + let mut dead: Vec<String> = Vec::new();
357 + for path in html_templates(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")) {
358 + let body = std::fs::read_to_string(&path).expect("readable template");
359 + for caps in re.captures_iter(&body) {
360 + let target = caps.get(1).map_or("", |m| m.as_str());
361 + if NON_SLUG_ROUTES.contains(&target) {
362 + continue;
363 + }
364 + let ok = !target.contains('/') && docs.get(target).is_some();
365 + if !ok {
366 + let file = path.rsplit('/').next().unwrap_or(&path);
367 + dead.push(format!("{file}: /docs/{target}"));
368 + }
369 + }
370 + }
371 + dead.sort_unstable();
372 + dead.dedup();
373 +
374 + assert!(
375 + dead.is_empty(),
376 + "templates link to docs that are not served:\n {}",
377 + dead.join("\n ")
378 + );
379 + }
380 +
381 + /// Recursive `.html` walk. No `walkdir` in the tree, and this is the only
382 + /// caller.
383 + fn html_templates(root: &str) -> Vec<String> {
384 + let mut out = Vec::new();
385 + let mut stack = vec![std::path::PathBuf::from(root)];
386 + while let Some(dir) = stack.pop() {
387 + let Ok(entries) = std::fs::read_dir(&dir) else {
388 + continue;
389 + };
390 + for entry in entries.filter_map(std::result::Result::ok) {
391 + let p = entry.path();
392 + if p.is_dir() {
393 + stack.push(p);
394 + } else if p.extension().is_some_and(|e| e == "html") {
395 + out.push(p.to_string_lossy().into_owned());
396 + }
397 + }
398 + }
399 + out
400 + }
401 + }