Skip to content

Commit ec703e5

Browse files
NetdocsCopilot
andcommitted
Add offline build mode that self-hosts CDN assets
Adds optimize.offline: downloads external CDN assets (highlight.js, Mermaid, web fonts, emoji) into assets/external/ and rewrites pages to page-relative local paths so the built site runs from file://. - SelfHostAssets: tag-aware asset discovery (script/img/link/import), recursive CSS url() font fetching, injectable Fetcher for tests - Wire into BuildEngine after prune, gated on optimize.offline - OptimizeConfig.Offline + JsonConfigLoader binding - Docs: setup/building-for-offline-usage.md + nav, configuration + comparison + netdocs new template updates - 5 unit tests via fake fetcher Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 596c41f commit ec703e5

10 files changed

Lines changed: 431 additions & 4 deletions

File tree

docs-site/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@
101101
"children": [
102102
{ "path": "setup/publishing.md" },
103103
{ "path": "setup/docker.md" },
104-
{ "path": "setup/packaging.md" }
104+
{ "path": "setup/packaging.md" },
105+
{ "path": "setup/building-for-offline-usage.md" }
105106
]
106107
},
107108
{

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ If you're migrating, read this alongside the [migration guide](../setup/migratin
6363
- **Live-reload dev server.** `netdocs serve` runs a Kestrel dev server that watches your files,
6464
rebuilds incrementally, and pushes changes to the browser over a WebSocket.
6565
- **Config importer.** `netdocs import` gets you from `mkdocs.yml` to `appsettings.json` in one step.
66+
- **One-command offline builds.** `optimize.offline` self-hosts every CDN asset (highlight.js,
67+
Mermaid, fonts, emoji) into the output so the site runs from `file://` — see
68+
[building for offline usage](../setup/building-for-offline-usage.md).
69+
- **Build-time validation.** Optional broken-link/anchor/orphan checks that can fail CI under
70+
`--strict` — see [validation](../reference/validation.md).
6671

6772
## Known gaps / not implemented yet
6873

@@ -76,7 +81,6 @@ Notable things that are **not** implemented (or only partially) today:
7681
| Versioned docs (mike) | Not yet | No built-in multi-version switcher. |
7782
| Instant prefetch / progressive rendering knobs | Partial | Instant navigation works; not every Material `navigation.*` toggle is wired. |
7883
| i18n / static site search in non-Latin languages | Partial | Search supports configured `lang`; full CJK tokenization is limited. |
79-
| Offline bundle (fully CDN-free) | Partial | Most assets are vendored; Mermaid/Twemoji load from a CDN by default. |
8084

8185
If you rely on something that isn't here, it's usually straightforward to add as a plugin — the
8286
[writing a plugin](../development/events-and-callbacks.md) reference and

docs-site/docs/reference/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ builds stay fast and output stays debuggable; turn them on for production publis
162162
"minifyCss": true,
163163
"minifyJs": true,
164164
"convertImagesToWebp": true,
165-
"webpQuality": 80
165+
"webpQuality": 80,
166+
"offline": false
166167
}
167168
}
168169
}
@@ -175,6 +176,7 @@ builds stay fast and output stays debuggable; turn them on for production publis
175176
| `minifyJs` | bool | `false` | Collapse whitespace/comments in emitted JavaScript assets. |
176177
| `convertImagesToWebp` | bool | `false` | Generate a `.webp` sibling for each raster image and wrap `<img>` in `<picture>` (originals are kept as fallback). |
177178
| `webpQuality` | int | `80` | Quality (1–100) for generated webp images. |
179+
| `offline` | bool | `false` | Self-host every CDN asset into the output so the site runs from `file://`. Requires network access at build time. See [building for offline usage](../setup/building-for-offline-usage.md). |
178180

179181
## Environment overrides
180182

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
title: Building for offline usage
3+
---
4+
5+
# Building for offline usage
6+
7+
Sometimes a site can't be hosted on the web — it ships on a USB stick, lives on an air-gapped
8+
network, or is opened straight from disk with `file://`. By default Netdocs loads a few assets
9+
from CDNs (syntax highlighting, Mermaid, web fonts, emoji). Offline mode **self-hosts all of
10+
them** so the built site is fully self-contained.
11+
12+
## Enable it
13+
14+
Set `optimize.offline` in `appsettings.json`:
15+
16+
```json
17+
"optimize": {
18+
"offline": true
19+
}
20+
```
21+
22+
Then build as usual:
23+
24+
```bash
25+
netdocs build
26+
```
27+
28+
```
29+
info: Offline: self-hosted 27 external asset(s) into assets/external across 45 page(s).
30+
```
31+
32+
## What it does
33+
34+
During the build (after pages and assets are written) Netdocs:
35+
36+
1. Scans every emitted page for external assets — `<script src>`, `<link rel="stylesheet">`,
37+
`<img src>`, and the Mermaid dynamic `import()`.
38+
2. Downloads each one **once** into `assets/external/`.
39+
3. Follows `url(...)` references inside downloaded CSS (e.g. web-font `.woff2`/`.ttf` files) and
40+
self-hosts those too, rewriting the stylesheet to the local copies.
41+
4. Rewrites every page to point at the local files using **page-relative** paths, so the site
42+
works from a sub-folder *and* from `file://`.
43+
44+
Only asset tags are rewritten — ordinary `<a href="https://…">` links and `rel="preconnect"`
45+
hints are left untouched.
46+
47+
!!! note "Network is required **at build time**"
48+
Offline mode downloads the CDN assets while building, so the build machine needs internet
49+
access. The *output* is then fully offline. Any asset that fails to download keeps its CDN
50+
URL and logs a warning; combine with [`--strict`](../reference/validation.md) to fail the
51+
build if the site can't be made fully self-contained.
52+
53+
## Verifying
54+
55+
Open the built `site/` from disk with your browser (double-click `site/index.html`) with the
56+
network disabled. Code highlighting, Mermaid diagrams, fonts, and emoji should all still render.
57+
You can also confirm there are no remaining CDN references:
58+
59+
```bash
60+
# Should print nothing
61+
grep -R "cdn.jsdelivr.net\|fonts.googleapis.com" site/ --include="*.html"
62+
```
63+
64+
## Trade-offs
65+
66+
- The output directory grows by the size of the vendored assets (fonts dominate).
67+
- First build is slower because of the downloads; subsequent incremental builds reuse the
68+
already-written files.
69+
- Because assets are copied verbatim, you get the exact pinned versions the theme references.

src/Netdocs.Abstractions/SiteConfig.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,16 @@ public sealed class OptimizeConfig
183183

184184
/// <summary>Quality (1-100) for generated webp images. Default 80.</summary>
185185
public int WebpQuality { get; set; } = 80;
186+
187+
/// <summary>
188+
/// Self-host external CDN assets for offline use. When enabled, the build downloads every
189+
/// external <c>&lt;script&gt;</c>/<c>&lt;link rel=stylesheet&gt;</c>/<c>&lt;img&gt;</c> asset
190+
/// (highlight.js, Mermaid, web fonts, Twemoji, …), stores them under
191+
/// <c>assets/external/</c>, and rewrites the pages to reference the local copies so the site
192+
/// works without internet (including from <c>file://</c>). Requires network access at build
193+
/// time; assets that fail to download keep their CDN URL and log a warning.
194+
/// </summary>
195+
public bool Offline { get; set; }
186196
}
187197

188198
/// <summary>

src/Netdocs.Cli/NewCommand.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ public static async Task<int> RunAsync(string[] args)
155155
"minifyCss": false,
156156
"minifyJs": false,
157157
"convertImagesToWebp": false,
158-
"webpQuality": 80
158+
"webpQuality": 80,
159+
// Self-host every CDN asset (highlight.js, Mermaid, fonts, emoji) so the
160+
// built site runs from file:// — requires network access at build time.
161+
"offline": false
159162
},
160163
161164
// --- Build-time validation -------------------------------------------------------

src/Netdocs.Core/BuildEngine.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ public async Task<SiteContext> BuildAsync(CancellationToken ct = default)
186186
var pruned = OutputWriter.PruneStale(site, config.AbsoluteSiteDir);
187187
if (pruned > 0) _log.LogInformation("Pruned {Count} stale output files", pruned);
188188

189+
// 13b. Offline self-hosting: download external CDN assets and rewrite pages to local copies.
190+
if (config.Optimize.Offline)
191+
await Optimization.SelfHostAssets.RunAsync(site, _log, ct);
192+
189193
// 14. Optional build-time validation (links, anchors, unused images, orphan pages).
190194
// Runs last so every page/asset/plugin output is materialized on disk. Problems are
191195
// logged as warnings; `--strict` (or MKDOCS_STRICT) turns them into a failing build.

src/Netdocs.Core/Configuration/JsonConfigLoader.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public static SiteConfig Load(string appSettingsPath)
7676
MinifyJs = m.Get("minifyJs").AsBool(false),
7777
ConvertImagesToWebp = m.Get("convertImagesToWebp").AsBool(false),
7878
WebpQuality = m.Get("webpQuality").AsInt(80),
79+
Offline = m.Get("offline").AsBool(false),
7980
};
8081

8182
private static SlugifyConfig ParseSlugify(IReadOnlyDictionary<string, object?> m) => new()
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using System.Text.RegularExpressions;
4+
using Microsoft.Extensions.Logging;
5+
using Netdocs.Abstractions;
6+
7+
namespace Netdocs.Core.Optimization;
8+
9+
/// <summary>
10+
/// Self-hosts external CDN assets for offline usage. Scans emitted HTML for external
11+
/// <c>&lt;script src&gt;</c>, <c>&lt;link href&gt;</c>, <c>&lt;img src&gt;</c>, and the Mermaid
12+
/// dynamic <c>import()</c>, downloads each once into <c>assets/external/</c> (recursively fetching
13+
/// <c>url(...)</c> references inside downloaded CSS, e.g. web-font files), and rewrites every page
14+
/// to point at the local copies with page-relative paths so the site works from <c>file://</c>.
15+
/// </summary>
16+
public static partial class SelfHostAssets
17+
{
18+
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
19+
private const string ExternalDir = "assets/external";
20+
21+
/// <summary>Fetches a URL, returning its bytes and response media type (or null on failure).</summary>
22+
public delegate Task<(byte[] Bytes, string? MediaType)?> Fetcher(string url, CancellationToken ct);
23+
24+
public static Task RunAsync(SiteContext site, ILogger log, CancellationToken ct) =>
25+
RunAsync(site, log, DefaultFetch, ct);
26+
27+
private static async Task<(byte[], string?)?> DefaultFetch(string url, CancellationToken ct)
28+
{
29+
var resp = await Http.GetAsync(url, ct);
30+
resp.EnsureSuccessStatusCode();
31+
var bytes = await resp.Content.ReadAsByteArrayAsync(ct);
32+
return (bytes, resp.Content.Headers.ContentType?.MediaType);
33+
}
34+
35+
public static async Task RunAsync(SiteContext site, ILogger log, Fetcher fetch, CancellationToken ct)
36+
{
37+
var siteDir = Path.GetFullPath(site.Config.AbsoluteSiteDir);
38+
var htmlFiles = Directory.EnumerateFiles(siteDir, "*.html", SearchOption.AllDirectories).ToList();
39+
if (htmlFiles.Count == 0) return;
40+
41+
// 1. Collect every external URL referenced by an asset tag across all pages.
42+
var urls = new HashSet<string>(StringComparer.Ordinal);
43+
var fileText = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
44+
foreach (var f in htmlFiles)
45+
{
46+
var text = await File.ReadAllTextAsync(f, ct);
47+
fileText[f] = text;
48+
foreach (var u in ExtractAssetUrls(text)) urls.Add(u);
49+
}
50+
if (urls.Count == 0) return;
51+
52+
// 2. Download each URL once (plus CSS-referenced assets), mapping url -> local filename.
53+
var absExternalDir = Path.Combine(siteDir, ExternalDir);
54+
Directory.CreateDirectory(absExternalDir);
55+
var map = new Dictionary<string, string>(StringComparer.Ordinal);
56+
var failures = 0;
57+
foreach (var url in urls)
58+
{
59+
var local = await DownloadAsync(url, absExternalDir, site, fetch, log, ct);
60+
if (local is null) { failures++; continue; }
61+
map[url] = local;
62+
}
63+
64+
// 3. Rewrite each HTML file, replacing external URLs with page-relative local paths.
65+
var rewritten = 0;
66+
foreach (var (path, original) in fileText)
67+
{
68+
var pageDir = Path.GetDirectoryName(path)!;
69+
var updated = original;
70+
foreach (var (url, local) in map)
71+
{
72+
var rel = RelativePath(pageDir, Path.Combine(absExternalDir, local));
73+
updated = updated.Replace(url, rel, StringComparison.Ordinal);
74+
}
75+
if (updated != original)
76+
{
77+
await File.WriteAllTextAsync(path, updated, ct);
78+
rewritten++;
79+
}
80+
}
81+
82+
if (map.Count > 0)
83+
log.LogInformation("Offline: self-hosted {Count} external asset(s) into {Dir} across {Files} page(s).",
84+
map.Count, ExternalDir, rewritten);
85+
if (failures > 0)
86+
log.LogWarning("Offline: {Count} external asset(s) could not be downloaded and still point at their CDN.", failures);
87+
}
88+
89+
/// <summary>Downloads one URL into <paramref name="dir"/>, returning the local filename, and
90+
/// recursively self-hosts <c>url(...)</c> references inside downloaded CSS.</summary>
91+
private static async Task<string?> DownloadAsync(string url, string dir, SiteContext site, Fetcher fetch, ILogger log, CancellationToken ct)
92+
{
93+
try
94+
{
95+
var result = await fetch(url, ct);
96+
if (result is not { } r) { log.LogWarning("Offline: failed to download {Url}.", url); return null; }
97+
var (bytes, mediaType) = r;
98+
var name = LocalNameFor(url, mediaType);
99+
100+
if (name.EndsWith(".css", StringComparison.OrdinalIgnoreCase))
101+
{
102+
var css = Encoding.UTF8.GetString(bytes);
103+
css = await InlineCssReferencesAsync(css, url, dir, site, fetch, log, ct);
104+
bytes = Encoding.UTF8.GetBytes(css);
105+
}
106+
107+
var outPath = Path.Combine(dir, name);
108+
await File.WriteAllBytesAsync(outPath, bytes, ct);
109+
site.TrackOutput(outPath);
110+
return name;
111+
}
112+
catch (Exception ex)
113+
{
114+
log.LogWarning("Offline: failed to download {Url}: {Message}", url, ex.Message);
115+
return null;
116+
}
117+
}
118+
119+
/// <summary>Rewrites absolute <c>url(...)</c> targets in a downloaded stylesheet to local
120+
/// filenames (same directory), downloading each referenced file (fonts, images).</summary>
121+
private static async Task<string> InlineCssReferencesAsync(string css, string cssUrl, string dir,
122+
SiteContext site, Fetcher fetch, ILogger log, CancellationToken ct)
123+
{
124+
var matches = CssUrlRegex().Matches(css).Select(m => m.Groups[1].Value).Distinct(StringComparer.Ordinal).ToList();
125+
foreach (var raw in matches)
126+
{
127+
var abs = ToAbsolute(raw, cssUrl);
128+
if (abs is null) continue; // data: or unresolvable
129+
var local = await DownloadAsync(abs, dir, site, fetch, log, ct);
130+
if (local is not null) css = css.Replace(raw, local, StringComparison.Ordinal);
131+
}
132+
return css;
133+
}
134+
135+
private static IEnumerable<string> ExtractAssetUrls(string html)
136+
{
137+
foreach (Match m in ScriptImgRegex().Matches(html)) yield return m.Groups[1].Value;
138+
foreach (Match m in LinkRegex().Matches(html))
139+
{
140+
var tag = m.Value;
141+
// Preconnect / dns-prefetch hints don't fetch an asset — skip them.
142+
if (tag.Contains("preconnect", StringComparison.OrdinalIgnoreCase)
143+
|| tag.Contains("dns-prefetch", StringComparison.OrdinalIgnoreCase)) continue;
144+
yield return m.Groups[1].Value;
145+
}
146+
foreach (Match m in ImportRegex().Matches(html)) yield return m.Groups[1].Value;
147+
}
148+
149+
private static string LocalNameFor(string url, string? mediaType)
150+
{
151+
var uri = new Uri(url);
152+
var last = Path.GetFileName(uri.AbsolutePath);
153+
if (string.IsNullOrEmpty(last) || !last.Contains('.'))
154+
{
155+
var ext = ExtensionFor(mediaType) ?? ".bin";
156+
last = (string.IsNullOrEmpty(last) ? "asset" : last) + ext;
157+
}
158+
// Prefix a short hash of the full URL to avoid collisions between same-named files.
159+
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url)))[..8].ToLowerInvariant();
160+
return $"{hash}-{Sanitize(last)}";
161+
}
162+
163+
private static string? ExtensionFor(string? mediaType) => mediaType switch
164+
{
165+
"text/css" => ".css",
166+
"application/javascript" or "text/javascript" => ".js",
167+
"font/woff2" => ".woff2",
168+
"font/woff" => ".woff",
169+
"image/png" => ".png",
170+
"image/svg+xml" => ".svg",
171+
_ => null,
172+
};
173+
174+
private static string? ToAbsolute(string reference, string baseUrl)
175+
{
176+
if (reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return null;
177+
return Uri.TryCreate(new Uri(baseUrl), reference, out var abs) ? abs.ToString() : null;
178+
}
179+
180+
private static string RelativePath(string fromDir, string toFile) =>
181+
Path.GetRelativePath(fromDir, toFile).Replace('\\', '/');
182+
183+
private static string Sanitize(string name) =>
184+
InvalidCharRegex().Replace(name, "_");
185+
186+
[GeneratedRegex("<(?:script|img)\\b[^>]*?\\ssrc=[\"'](https?://[^\"']+)[\"'][^>]*>", RegexOptions.IgnoreCase)]
187+
private static partial Regex ScriptImgRegex();
188+
189+
[GeneratedRegex("<link\\b[^>]*?\\shref=[\"'](https?://[^\"']+)[\"'][^>]*>", RegexOptions.IgnoreCase)]
190+
private static partial Regex LinkRegex();
191+
192+
[GeneratedRegex("import\\(\\s*[\"'](https?://[^\"']+)[\"']\\s*\\)", RegexOptions.IgnoreCase)]
193+
private static partial Regex ImportRegex();
194+
195+
[GeneratedRegex("url\\(\\s*[\"']?([^\"')]+)[\"']?\\s*\\)", RegexOptions.IgnoreCase)]
196+
private static partial Regex CssUrlRegex();
197+
198+
[GeneratedRegex("[^A-Za-z0-9._-]")]
199+
private static partial Regex InvalidCharRegex();
200+
}

0 commit comments

Comments
 (0)