Skip to content

Commit 9ab7b01

Browse files
mohnjilesclaude
andcommitted
feat: fall back to a bundled docs snapshot when the live copy is unreachable
The listing needs the GitHub API, whose anonymous 60/hr budget the app already spends on package version checks — an install with many packages can exhaust it before the viewer is ever opened, so the docs page showed an error rather than documentation. Page content was already safe, coming from raw.githubusercontent, which is a CDN and not part of that budget. Embeds the docs tree at build time and consults it last, after the network and any cached copy, so the live version stays authoritative and pages added after a release still appear. The snapshot backs the listing as well as page content, otherwise a rate-limited cold start would still show an empty viewer. Adds 17 files / ~191 KB to the assembly. The glob excludes the VitePress site build and node_modules, whose bundled READMEs are several times the size of the documentation itself. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7aaf9c1 commit 9ab7b01

5 files changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Reflection;
6+
7+
namespace StabilityMatrix.Core.Models.Documentation;
8+
9+
/// <summary>
10+
/// The copy of the docs tree embedded in the app at build time. Read only when the live copy
11+
/// is unreachable — the network copy stays authoritative so pages added after a release still
12+
/// appear — but it guarantees the viewer is never empty, including on a first run that is
13+
/// offline or has exhausted the anonymous GitHub API budget.
14+
/// </summary>
15+
public static class BundledDocumentation
16+
{
17+
private const string ResourcePrefix = "BundledDocs/";
18+
19+
private static readonly Lazy<IReadOnlyDictionary<string, string>> ResourcesByPath = new(BuildIndex);
20+
21+
/// <summary>
22+
/// Docs-relative paths present in the bundle, e.g. <c>getting-started/overview.md</c>.
23+
/// Empty if the build produced no snapshot.
24+
/// </summary>
25+
public static IReadOnlyList<string> Paths => ResourcesByPath.Value.Keys.ToList();
26+
27+
/// <summary>
28+
/// Reads a bundled page, or null when the path is not part of the snapshot.
29+
/// </summary>
30+
public static string? TryReadPage(string docsRelativePath)
31+
{
32+
if (!ResourcesByPath.Value.TryGetValue(docsRelativePath, out var resourceName))
33+
return null;
34+
35+
using var stream = typeof(BundledDocumentation).Assembly.GetManifestResourceStream(resourceName);
36+
if (stream is null)
37+
return null;
38+
39+
using var reader = new StreamReader(stream);
40+
return reader.ReadToEnd();
41+
}
42+
43+
private static IReadOnlyDictionary<string, string> BuildIndex()
44+
{
45+
var assembly = typeof(BundledDocumentation).Assembly;
46+
47+
return assembly
48+
.GetManifestResourceNames()
49+
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
50+
.ToDictionary(
51+
name => name[ResourcePrefix.Length..],
52+
name => name,
53+
StringComparer.OrdinalIgnoreCase
54+
);
55+
}
56+
}

StabilityMatrix.Core/Services/DocumentationService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ public async Task<IReadOnlyList<DocumentationSection>> GetSectionsAsync(
6767
{
6868
logger.LogWarning(e, "Failed to fetch docs tree, attempting to use cached copy");
6969
paths = await ReadCachedPathsAsync(cacheFile, cancellationToken).ConfigureAwait(false);
70+
71+
if (paths is null && BundledDocumentation.Paths is { Count: > 0 } bundledPaths)
72+
{
73+
// The listing needs the GitHub API, whose anonymous budget the app also
74+
// spends on package version checks — so an install with many packages can
75+
// exhaust it before the viewer is ever opened. Falling back to the snapshot
76+
// keeps the nav usable; page content still comes from the network below.
77+
logger.LogInformation("Falling back to the bundled documentation listing");
78+
paths = bundledPaths.ToList();
79+
}
80+
7081
if (paths is null)
7182
throw;
7283
}
@@ -127,6 +138,12 @@ public async Task<string> GetPageMarkdownAsync(
127138
if (cacheFile.Exists)
128139
return await cacheFile.ReadAllTextAsync(cancellationToken).ConfigureAwait(false);
129140

141+
if (BundledDocumentation.TryReadPage(docsRelativePath) is { } bundled)
142+
{
143+
logger.LogInformation("Serving {Path} from the bundled documentation", docsRelativePath);
144+
return bundled;
145+
}
146+
130147
throw;
131148
}
132149
}

StabilityMatrix.Core/StabilityMatrix.Core.csproj

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,15 @@
8686
<ItemGroup>
8787
<Folder Include="Api\PromptGen\Generated\" />
8888
</ItemGroup>
89+
90+
<!--
91+
Snapshot of the docs tree, used by DocumentationService when the live copy is
92+
unreachable. Excludes the VitePress site build and its dependencies, whose bundled
93+
READMEs are several times the size of the documentation itself.
94+
-->
95+
<ItemGroup>
96+
<EmbeddedResource Include="..\docs\**\*.md" Exclude="..\docs\node_modules\**;..\docs\.vitepress\**">
97+
<LogicalName>BundledDocs/$([System.String]::Copy('%(RecursiveDir)').Replace('\','/'))%(Filename)%(Extension)</LogicalName>
98+
</EmbeddedResource>
99+
</ItemGroup>
89100
</Project>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using StabilityMatrix.Core.Models.Documentation;
2+
3+
namespace StabilityMatrix.Tests.Core;
4+
5+
[TestClass]
6+
public class BundledDocumentationTests
7+
{
8+
/// <summary>
9+
/// A broken embed glob or LogicalName still compiles and just yields an empty bundle,
10+
/// which would silently remove the offline fallback.
11+
/// </summary>
12+
[TestMethod]
13+
public void Paths_ContainsDocsTree()
14+
{
15+
var paths = BundledDocumentation.Paths;
16+
17+
Assert.IsTrue(paths.Count > 0, "No docs were embedded — check the EmbeddedResource glob.");
18+
CollectionAssert.Contains(paths.ToList(), "README.md");
19+
Assert.IsTrue(
20+
paths.Any(p => p.StartsWith("getting-started/", StringComparison.Ordinal)),
21+
"Nested docs should keep forward-slash relative paths, got: " + string.Join(", ", paths.Take(5))
22+
);
23+
}
24+
25+
/// <summary>
26+
/// The VitePress site build pulls in dependency READMEs several times the size of the
27+
/// docs themselves; they must not ride along into the shipped assembly.
28+
/// </summary>
29+
[TestMethod]
30+
public void Paths_ExcludesNodeModulesAndSiteBuild()
31+
{
32+
foreach (var path in BundledDocumentation.Paths)
33+
{
34+
Assert.IsFalse(path.Contains("node_modules", StringComparison.OrdinalIgnoreCase), path);
35+
Assert.IsFalse(path.Contains(".vitepress", StringComparison.OrdinalIgnoreCase), path);
36+
}
37+
}
38+
39+
[TestMethod]
40+
public void TryReadPage_ReturnsContentForBundledPage()
41+
{
42+
var markdown = BundledDocumentation.TryReadPage("README.md");
43+
44+
Assert.IsNotNull(markdown);
45+
Assert.IsTrue(markdown.Length > 0);
46+
}
47+
48+
[TestMethod]
49+
public void TryReadPage_ReturnsNullForUnknownPage()
50+
{
51+
Assert.IsNull(BundledDocumentation.TryReadPage("nope/not-a-real-page.md"));
52+
}
53+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Net;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using NSubstitute;
4+
using NSubstitute.ExceptionExtensions;
5+
using Octokit;
6+
using StabilityMatrix.Core.Services;
7+
8+
namespace StabilityMatrix.Tests.Core;
9+
10+
/// <summary>
11+
/// Covers the offline chain: with the network failing and no cache, both the listing and page
12+
/// content must still come back from the bundled snapshot rather than surfacing an error.
13+
/// </summary>
14+
[TestClass]
15+
public class DocumentationServiceFallbackTests
16+
{
17+
private sealed class FailingHandler : HttpMessageHandler
18+
{
19+
protected override Task<HttpResponseMessage> SendAsync(
20+
HttpRequestMessage request,
21+
CancellationToken cancellationToken
22+
) => throw new HttpRequestException("offline");
23+
}
24+
25+
private static DocumentationService CreateRateLimitedService()
26+
{
27+
var gitHub = Substitute.For<IGitHubClient>();
28+
gitHub
29+
.Git.Tree.GetRecursive(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
30+
.Throws(new ApiException("API rate limit exceeded", HttpStatusCode.Forbidden));
31+
32+
var factory = Substitute.For<IHttpClientFactory>();
33+
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(new FailingHandler()));
34+
35+
return new DocumentationService(NullLogger<DocumentationService>.Instance, gitHub, factory);
36+
}
37+
38+
[TestMethod]
39+
public async Task GetSectionsAsync_FallsBackToBundle_WhenApiRateLimited()
40+
{
41+
var service = CreateRateLimitedService();
42+
43+
var sections = await service.GetSectionsAsync(forceRefresh: true);
44+
45+
Assert.IsTrue(sections.Count > 0, "Expected the bundled listing to populate the nav tree.");
46+
Assert.IsTrue(
47+
sections.SelectMany(s => s.Pages).Any(p => p.Path == "README.md"),
48+
"Bundled listing should include the docs landing page."
49+
);
50+
}
51+
52+
[TestMethod]
53+
public async Task GetPageMarkdownAsync_FallsBackToBundle_WhenOffline()
54+
{
55+
var service = CreateRateLimitedService();
56+
57+
var markdown = await service.GetPageMarkdownAsync("README.md", forceRefresh: true);
58+
59+
Assert.IsFalse(string.IsNullOrWhiteSpace(markdown));
60+
}
61+
62+
[TestMethod]
63+
public async Task GetPageMarkdownAsync_StillThrows_ForPageNotInBundle()
64+
{
65+
var service = CreateRateLimitedService();
66+
67+
await Assert.ThrowsExceptionAsync<HttpRequestException>(() =>
68+
service.GetPageMarkdownAsync("nope/not-a-real-page.md", forceRefresh: true)
69+
);
70+
}
71+
}

0 commit comments

Comments
 (0)