Skip to content

Commit 599c1cd

Browse files
NetdocsCopilot
andcommitted
feat(build): optional link/anchor/orphan/unused-image validation
Add a post-build validation pass (BuildValidator) that runs after all pages, assets, and plugin outputs are on disk: - links: internal href/src targets must resolve to an output file - anchors: #fragment must match an element id on the target page - unusedImages: docs images never referenced by a page - orphanPages: source pages not reachable from the nav Each problem is logged as a warning; the existing --strict / MKDOCS_STRICT path turns warnings into a failing build. All checks are opt-in via a new "validation" config block (SiteConfig.Validation + JsonConfigLoader). Enable links+anchors on the dogfood site — which surfaced 3 real doc bugs, now fixed: two stale affiliate-links.md links (plugin renamed to link-notes) and a wrong #5-verify-parity anchor (slug is #verify-parity). Docs: new reference/validation.md (+ nav + cli.md cross-link). 8 unit tests covering each check (219 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent de1bb89 commit 599c1cd

10 files changed

Lines changed: 533 additions & 4 deletions

File tree

docs-site/appsettings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
{ "path": "reference/configuration.md" },
5959
{ "path": "reference/theme.md" },
6060
{ "path": "reference/cli.md" },
61+
{ "path": "reference/validation.md" },
6162
{ "path": "setup/migrating-from-mkdocs.md" }
6263
]
6364
},
@@ -132,6 +133,10 @@
132133
{ "name": "social" }
133134
],
134135

136+
"validation": { "links": true, "anchors": true },
137+
138+
139+
135140
"markdownExtensions": [
136141
{ "name": "admonition" },
137142
{ "name": "pymdownx.details" },

docs-site/docs/about/netdocs-vs-mkdocs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,4 @@ Choose **Netdocs** if you want a fast, single-binary/container build, typed exte
8989
built-in deploy/optimization, and your Markdown + plugin needs are covered above. Stay on
9090
**Material for MkDocs** if you depend on a niche PyMdown extension, the Python plugin ecosystem, or
9191
mike-based doc versioning that isn't implemented yet — or run both and
92-
[diff the output](../setup/migrating-from-mkdocs.md#5-verify-parity) before switching.
92+
[diff the output](../setup/migrating-from-mkdocs.md#verify-parity) before switching.

docs-site/docs/plugins/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ annotate outbound links (e.g. affiliate disclosures). A few plugins are intentio
5353
| [blog](blog.md) | Only meaningful for sites with a `blog/` posts directory. |
5454
| [social](social.md) | Generates images (slower); best gated behind `netdocs build --prod`. |
5555
| [file-filter](file-filter.md) | Needs a `.file-filter.yml` and env vars to do anything. |
56-
| [affiliate-links](affiliate-links.md) | Requires you to declare your affiliate programs. |
56+
| [link-notes](link-notes.md) | Requires you to declare your affiliate programs / link rules. |
5757
| [arithmatex](arithmatex.md) / [b64](b64.md) | Enable when you actually use math or inline-image embedding. |
5858

5959
Ordering only matters for Markdown preprocessors (they transform source text in sequence);
@@ -99,7 +99,7 @@ content generators — are not page-scoped and always apply.
9999
| [file-filter](file-filter.md) | Env-driven label include/exclude. |
100100
| [macros](macros.md) | `fileuri()` / `button()` Markdown macros (mkdocs-macros subset). |
101101
| [table-reader](table-reader.md) | Expand `read_csv()` / `read_table()` into Markdown tables. |
102-
| [affiliate-links](affiliate-links.md) | Auto tooltip + footer disclosure for affiliate links. |
102+
| [link-notes](link-notes.md) | Auto tooltip + footer disclosure for affiliate links. |
103103
| [b64](b64.md) | Embed local images as inline `data:` URIs (pymdownx.b64). |
104104
| [arithmatex](arithmatex.md) | LaTeX math typeset with MathJax (pymdownx.arithmatex). |
105105
| [typeset](typeset.md) | Smart typography (curly quotes, en/em dashes, ellipses) via SmartyPants. |

docs-site/docs/reference/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ If no command is given, `build` is assumed.
3636
| `--port <port>` | `-p` | Dev server port for `serve` (default `8000`). |
3737
| `--clean` | | Remove existing output before building. |
3838
| `--no-cache` | | Ignore the incremental render cache and re-render every page. |
39-
| `--strict` | | Fail on plugin/template errors. |
39+
| `--strict` | | Treat build warnings (including [validation](validation.md) problems, plugin/template errors) as failures. |
4040
| `--prod` | `--production` | Production build (enables prod-only plugins such as social cards). |
4141
| `--verbose` | `-v` | Verbose (Trace) logging. |
4242
| `--remote <name>` | | `watch`: git remote to poll (default `origin`). |
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
title: Build validation
3+
---
4+
5+
# Build validation
6+
7+
Netdocs can validate your site as part of the build and report problems as **warnings**. Pair it
8+
with [`--strict`](cli.md#options) (or the `MKDOCS_STRICT` environment variable) to turn those
9+
warnings into a non-zero exit code so CI fails on a broken site.
10+
11+
All checks are **opt-in** and default to `false`. Enable the ones you want under a `validation`
12+
block in `appsettings.json`:
13+
14+
```json
15+
"validation": {
16+
"links": true,
17+
"anchors": true,
18+
"unusedImages": false,
19+
"orphanPages": false
20+
}
21+
```
22+
23+
## Checks
24+
25+
| Option | What it checks | Emits a warning when… |
26+
|---|---|---|
27+
| `links` | Internal `href`/`src` targets in the rendered pages | a link resolves to a file that isn't in the output |
28+
| `anchors` | `#fragment` anchors on internal links (requires `links`) | the target page has no element with that `id` |
29+
| `unusedImages` | Image files under your `docs/` directory | an image is never referenced by any page |
30+
| `orphanPages` | Source Markdown pages | a page isn't reachable from the navigation tree |
31+
32+
What is **not** flagged:
33+
34+
- **External URLs** (`http://`, `https://`, protocol-relative `//host`), `mailto:`, `tel:`,
35+
`data:` URIs, and unresolved template tokens are skipped — link checking is offline and never
36+
makes network requests.
37+
- Generated pages (blog indexes, tag pages, etc.) are ignored by the orphan-page check.
38+
39+
Validation runs **last**, after every page, asset, and plugin output is written, so link targets
40+
are resolved against the real files on disk.
41+
42+
## Failing the build in CI
43+
44+
Warnings alone don't change the exit code — they're informational. Add `--strict` to abort:
45+
46+
```bash
47+
netdocs build --strict
48+
```
49+
50+
```
51+
warn: Broken link in guide/setup.md: '../missing/' does not resolve to an output file.
52+
info: Validation found 1 problem(s).
53+
error: Aborting build: 1 warning(s) treated as errors (strict mode).
54+
```
55+
56+
The same applies to `netdocs deploy --strict`, which refuses to publish when validation fails.
57+
`MKDOCS_STRICT=1` is honored as an alias so existing MkDocs CI pipelines keep working.
58+
59+
## Example: strict links in CI, lenient locally
60+
61+
Keep the checks enabled in config, but only fail the build in CI:
62+
63+
```json
64+
"validation": { "links": true, "anchors": true }
65+
```
66+
67+
```bash
68+
# Local authoring — see warnings, but keep building
69+
netdocs serve
70+
71+
# CI — fail on any broken link or anchor
72+
netdocs build --prod --strict
73+
```

src/Netdocs.Abstractions/SiteConfig.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ public sealed class SiteConfig
4949
/// <summary>Output optimization toggles (HTML minification, etc.).</summary>
5050
public OptimizeConfig Optimize { get; set; } = new();
5151

52+
/// <summary>Optional build-time validation (internal links, anchors, orphaned files).</summary>
53+
public ValidationConfig Validation { get; set; } = new();
54+
5255
/// <summary>Absolute path to the project root (folder containing mkdocs.yml).</summary>
5356
public string ProjectRoot { get; set; } = "";
5457

@@ -182,6 +185,29 @@ public sealed class OptimizeConfig
182185
public int WebpQuality { get; set; } = 80;
183186
}
184187

188+
/// <summary>
189+
/// Build-time validation. Every enabled check emits a <c>warning</c> per problem; combine with
190+
/// <c>--strict</c> (or <c>MKDOCS_STRICT=1</c>) to turn those warnings into a non-zero build exit.
191+
/// All checks are opt-in and default to <c>false</c>.
192+
/// </summary>
193+
public sealed class ValidationConfig
194+
{
195+
/// <summary>Verify that internal links and asset references (<c>href</c>/<c>src</c>) in the
196+
/// rendered pages resolve to a file that exists in the output. External URLs, anchors, and
197+
/// <c>mailto:</c>/<c>tel:</c>/<c>data:</c> links are skipped.</summary>
198+
public bool Links { get; set; }
199+
200+
/// <summary>Verify that <c>#fragment</c> anchors in internal links point at an element that
201+
/// actually has that <c>id</c> on the target page. Requires <see cref="Links"/>.</summary>
202+
public bool Anchors { get; set; }
203+
204+
/// <summary>Warn about image assets under the docs directory that no rendered page references.</summary>
205+
public bool UnusedImages { get; set; }
206+
207+
/// <summary>Warn about markdown pages that are not reachable from the navigation tree.</summary>
208+
public bool OrphanPages { get; set; }
209+
}
210+
185211
/// <summary>An authored navigation entry: either a link to a page or a titled section.</summary>
186212
public sealed class NavItem
187213
{

src/Netdocs.Core/BuildEngine.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ public async Task<SiteContext> BuildAsync(CancellationToken ct = default)
125125
site.State["asset_versioner"] = new AssetVersioner(ThemePaths.AssetsDir, config.AbsoluteDocsDir);
126126

127127
var rendered = new ConcurrentBag<(string Path, string Html)>();
128+
var renderedPages = new ConcurrentBag<Validation.RenderedPage>();
128129
var minify = config.Optimize.MinifyHtml;
129130
var webpWrap = config.Optimize.ConvertImagesToWebp;
130131
Parallel.ForEach(site.Pages, new ParallelOptions { CancellationToken = ct }, page =>
@@ -133,6 +134,7 @@ public async Task<SiteContext> BuildAsync(CancellationToken ct = default)
133134
if (webpWrap) html = Optimization.WebpHtmlRewriter.Rewrite(html);
134135
if (minify) html = Optimization.HtmlMinifier.Minify(html);
135136
rendered.Add((page.OutputPath, html));
137+
renderedPages.Add(new Validation.RenderedPage(page, html));
136138
});
137139
var changed = 0;
138140
foreach (var (path, html) in rendered)
@@ -184,6 +186,11 @@ public async Task<SiteContext> BuildAsync(CancellationToken ct = default)
184186
var pruned = OutputWriter.PruneStale(site, config.AbsoluteSiteDir);
185187
if (pruned > 0) _log.LogInformation("Pruned {Count} stale output files", pruned);
186188

189+
// 14. Optional build-time validation (links, anchors, unused images, orphan pages).
190+
// Runs last so every page/asset/plugin output is materialized on disk. Problems are
191+
// logged as warnings; `--strict` (or MKDOCS_STRICT) turns them into a failing build.
192+
Validation.BuildValidator.Validate(site, renderedPages.ToList(), _log);
193+
187194
sw.Stop();
188195
_log.LogInformation("Built {Count} pages in {Ms} ms", site.Pages.Count, sw.ElapsedMilliseconds);
189196
return site;

src/Netdocs.Core/Configuration/JsonConfigLoader.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,18 @@ public static SiteConfig Load(string appSettingsPath)
4343
Slugify = ParseSlugify(root.Get("slugify").AsMap()),
4444
Deploy = ParseDeploy(root.Get("deploy").AsMap()),
4545
Optimize = ParseOptimize(root.Get("optimize").AsMap()),
46+
Validation = ParseValidation(root.Get("validation").AsMap()),
4647
};
4748
}
4849

50+
private static ValidationConfig ParseValidation(IReadOnlyDictionary<string, object?> m) => new()
51+
{
52+
Links = m.Get("links").AsBool(false),
53+
Anchors = m.Get("anchors").AsBool(false),
54+
UnusedImages = m.Get("unusedImages").AsBool(false),
55+
OrphanPages = m.Get("orphanPages").AsBool(false),
56+
};
57+
4958
private static DeployConfig ParseDeploy(IReadOnlyDictionary<string, object?> m) => new()
5059
{
5160
Target = m.Get("target").AsString() ?? "none",

0 commit comments

Comments
 (0)