Skip to content

Commit 5c04bd6

Browse files
authored
Improve docs-builder serve reliability and startup speed (#2982)
The serve command had several regressions that made the local authoring workflow frustrating: editing docset.yml no longer reloaded properly, adding/removing/renaming files required a full restart, OpenAPI generation blocked startup, CTRL+C was slow to cancel, and the diagnostics HUD disappeared after the first file change. This fixes all of those issues and adds a quality-of-life improvement that preserves navigation tree state across live reloads. Configuration reload on file changes: - Add BuildContext.ReloadConfiguration() to re-read docset.yml from disk when config files or file structure changes, while skipping the expensive re-parse for simple markdown content edits. - Watch _docset.yml and toc.yml in addition to docset.yml and *.md so navigation structure changes are detected without a restart. - Preserve serve-mode feature flags (e.g. diagnostics panel) across configuration reloads. Lazy OpenAPI generation: - Move OpenAPI document generation out of the startup path so the server reaches "Now listening on" immediately instead of blocking on spec parsing. - Generate API references on-demand when the first /api/ request arrives, with change detection so specs are only regenerated when modified. Faster CTRL+C cancellation: - Wire a service-scoped CancellationTokenSource through the reload pipeline so in-flight reloads are cancelled promptly on shutdown, replacing the previous Cancel.None that caused hangs. Navigation state preservation (dev mode only): - Persist expanded/collapsed navigation tree state to sessionStorage so sections you had open stay open after a live reload, instead of collapsing back to only the current page's ancestors. - Gated behind the diagnostics panel presence check so it has zero impact on production builds. Made-with: Cursor
1 parent 80f260a commit 5c04bd6

6 files changed

Lines changed: 115 additions & 22 deletions

File tree

docs/_docset.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,4 @@ toc:
266266
- file: known-issues.md
267267
- folder: release-notes-all
268268
children:
269-
- file: index.md
269+
- file: index.md

src/Elastic.Documentation.Configuration/BuildContext.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati
2929
public IDirectoryInfo DocumentationSourceDirectory { get; }
3030
public IDirectoryInfo OutputDirectory { get; }
3131

32-
public ConfigurationFile Configuration { get; }
32+
public ConfigurationFile Configuration { get; private set; }
3333

3434
public DocumentationSetFile ConfigurationYaml { get; set; }
3535

@@ -134,4 +134,14 @@ public BuildContext(
134134
};
135135
}
136136

137+
/// <summary>Re-reads docset.yml from disk and rebuilds the configuration. Used by the serve command on file changes.</summary>
138+
public void ReloadConfiguration()
139+
{
140+
var previousFeatures = Configuration.Features;
141+
ConfigurationYaml = ConfigurationPath.Exists
142+
? DocumentationSetFile.LoadAndResolve(Collector, ConfigurationPath, ReadFileSystem)
143+
: new DocumentationSetFile();
144+
Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration);
145+
Configuration.Features.DiagnosticsPanelEnabled = previousFeatures.DiagnosticsPanelEnabled;
146+
}
137147
}

src/Elastic.Documentation.Site/Assets/pages-nav.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
import { throttle } from 'lodash'
22
import { $, $$ } from 'select-dom'
33

4+
const NAV_STATE_KEY = 'nav-expanded'
5+
6+
function isDevMode() {
7+
return !!document.querySelector('diagnostics-panel')
8+
}
9+
10+
function saveNavState(nav: HTMLElement) {
11+
const expanded = $$('input[type="checkbox"]:checked', nav)
12+
.map((el) => el.id)
13+
.filter(Boolean)
14+
sessionStorage.setItem(NAV_STATE_KEY, JSON.stringify(expanded))
15+
}
16+
17+
function restoreNavState(nav: HTMLElement) {
18+
const raw = sessionStorage.getItem(NAV_STATE_KEY)
19+
if (!raw) return
20+
try {
21+
const ids: string[] = JSON.parse(raw)
22+
for (const id of ids) {
23+
const input = $(`#${CSS.escape(id)}`, nav)
24+
if (input instanceof HTMLInputElement) {
25+
input.checked = true
26+
}
27+
}
28+
} catch {
29+
/* ignore corrupt storage */
30+
}
31+
}
32+
433
function expandAllParents(navItem: HTMLElement) {
534
let parent: HTMLLIElement | null | undefined = navItem?.closest('li')
635
while (parent) {
@@ -93,6 +122,10 @@ export function initNav() {
93122
preventFocusLossOnLinkClick(dropdownActiveAnchor)
94123
}
95124

125+
if (isDevMode()) {
126+
restoreNavState(pagesNav)
127+
}
128+
96129
// Remove current class from all nav items before marking new ones
97130
const currentNavItems = $$('.current', pagesNav)
98131
currentNavItems.forEach((el) => {
@@ -109,4 +142,11 @@ export function initNav() {
109142
el.classList.add('current')
110143
})
111144
scrollCurrentNaviItemIntoView(pagesNav)
145+
146+
if (isDevMode()) {
147+
saveNavState(pagesNav)
148+
for (const cb of $$('input[type="checkbox"]', pagesNav)) {
149+
cb.addEventListener('change', () => saveNavState(pagesNav))
150+
}
151+
}
112152
}

src/tooling/docs-builder/Http/DocumentationWebHost.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ private static async Task WriteSSEEvent(HttpResponse response, string eventType,
211211

212212
private async Task<IResult> ServeApiFile(ReloadableGeneratorState holder, string slug, Cancel ctx)
213213
{
214+
await holder.EnsureApiReferencesAsync(ctx);
215+
214216
var path = Path.Combine(holder.ApiPath.FullName, slug.Trim('/'), "index.html");
215217
var info = _writeFileSystem.FileInfo.New(path);
216218
if (info.Exists)

src/tooling/docs-builder/Http/ReloadGeneratorService.cs

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ ILogger<ReloadGeneratorService> logger
2929
) : IHostedService, IDisposable
3030
{
3131
private FileSystemWatcher? _watcher;
32+
private CancellationTokenSource? _serviceCts;
3233
private ReloadableGeneratorState ReloadableGenerator { get; } = reloadableGenerator;
3334
private InMemoryBuildState InMemoryBuildState { get; } = inMemoryBuildState;
3435
private ILogger Logger { get; } = logger;
@@ -38,6 +39,8 @@ ILogger<ReloadGeneratorService> logger
3839

3940
public async Task StartAsync(Cancel cancellationToken)
4041
{
42+
_serviceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
43+
4144
// Run live reload and in-memory validation build in parallel
4245
var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName;
4346
await Task.WhenAll(
@@ -73,12 +76,16 @@ await Task.WhenAll(
7376
#endif
7477
watcher.Filters.Add("*.md");
7578
watcher.Filters.Add("docset.yml");
79+
watcher.Filters.Add("_docset.yml");
80+
watcher.Filters.Add("toc.yml");
7681
watcher.IncludeSubdirectories = true;
7782
watcher.EnableRaisingEvents = true;
7883
_watcher = watcher;
7984
}
8085

81-
private void Reload() =>
86+
private void Reload(bool reloadConfiguration = false)
87+
{
88+
var token = _serviceCts?.Token ?? Cancel.None;
8289
_ = _debouncer.ExecuteAsync(async ctx =>
8390
{
8491
var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName;
@@ -87,18 +94,24 @@ private void Reload() =>
8794
var validationTask = InMemoryBuildState.StartBuildAsync(sourcePath, ctx);
8895

8996
// Wait for live reload to complete, then refresh the browser immediately
90-
await ReloadableGenerator.ReloadAsync(ctx);
97+
await ReloadableGenerator.ReloadAsync(ctx, reloadConfiguration);
9198
Logger.LogInformation("Reload complete!");
9299
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
93100

94101
// Wait for validation build to complete
95102
await validationTask;
96-
}, Cancel.None);
103+
}, token);
104+
}
97105

98-
public Task StopAsync(Cancel cancellationToken)
106+
public async Task StopAsync(Cancel cancellationToken)
99107
{
108+
if (_serviceCts is not null)
109+
{
110+
await _serviceCts.CancelAsync();
111+
_serviceCts.Dispose();
112+
_serviceCts = null;
113+
}
100114
_watcher?.Dispose();
101-
return Task.CompletedTask;
102115
}
103116

104117
// Check if a path should be ignored (output directories, hidden folders, etc.)
@@ -108,6 +121,9 @@ private static bool ShouldIgnorePath(string path) =>
108121
path.Contains("/node_modules/") || path.Contains("\\node_modules\\") ||
109122
path.Contains("/.git/") || path.Contains("\\.git\\");
110123

124+
private static bool IsConfigFile(string path) =>
125+
path.EndsWith("docset.yml") || path.EndsWith("toc.yml");
126+
111127
private void OnChanged(object sender, FileSystemEventArgs e)
112128
{
113129
if (e.ChangeType != WatcherChangeTypes.Changed)
@@ -118,15 +134,14 @@ private void OnChanged(object sender, FileSystemEventArgs e)
118134

119135
Logger.LogInformation("Changed: {FullPath}", e.FullPath);
120136

121-
if (e.FullPath.EndsWith("docset.yml"))
122-
Reload();
123-
if (e.FullPath.EndsWith(".md"))
137+
if (IsConfigFile(e.FullPath))
138+
Reload(reloadConfiguration: true);
139+
else if (e.FullPath.EndsWith(".md"))
124140
Reload();
125141
#if DEBUG
126142
if (e.FullPath.EndsWith(".cshtml"))
127143
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
128144
#endif
129-
130145
}
131146

132147
private void OnCreated(object sender, FileSystemEventArgs e)
@@ -135,8 +150,8 @@ private void OnCreated(object sender, FileSystemEventArgs e)
135150
return;
136151

137152
Logger.LogInformation("Created: {FullPath}", e.FullPath);
138-
if (e.FullPath.EndsWith(".md"))
139-
Reload();
153+
if (e.FullPath.EndsWith(".md") || IsConfigFile(e.FullPath))
154+
Reload(reloadConfiguration: true);
140155
}
141156

142157
private void OnDeleted(object sender, FileSystemEventArgs e)
@@ -145,8 +160,8 @@ private void OnDeleted(object sender, FileSystemEventArgs e)
145160
return;
146161

147162
Logger.LogInformation("Deleted: {FullPath}", e.FullPath);
148-
if (e.FullPath.EndsWith(".md"))
149-
Reload();
163+
if (e.FullPath.EndsWith(".md") || IsConfigFile(e.FullPath))
164+
Reload(reloadConfiguration: true);
150165
}
151166

152167
private void OnRenamed(object sender, RenamedEventArgs e)
@@ -157,8 +172,8 @@ private void OnRenamed(object sender, RenamedEventArgs e)
157172
Logger.LogInformation("Renamed:");
158173
Logger.LogInformation(" Old: {OldFullPath}", e.OldFullPath);
159174
Logger.LogInformation(" New: {NewFullPath}", e.FullPath);
160-
if (e.FullPath.EndsWith(".md"))
161-
Reload();
175+
if (e.FullPath.EndsWith(".md") || e.OldFullPath.EndsWith(".md") || IsConfigFile(e.FullPath) || IsConfigFile(e.OldFullPath))
176+
Reload(reloadConfiguration: true);
162177
#if DEBUG
163178
if (e.FullPath.EndsWith(".cshtml"))
164179
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
@@ -180,6 +195,7 @@ private void PrintException(Exception? ex)
180195

181196
public void Dispose()
182197
{
198+
_serviceCts?.Dispose();
183199
_watcher?.Dispose();
184200
_debouncer.Dispose();
185201
}

src/tooling/docs-builder/Http/ReloadableGeneratorState.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@ bool isWatchBuild
5656

5757
// Track OpenAPI spec file modification times to detect changes
5858
private readonly Dictionary<string, DateTimeOffset> _openApiSpecLastModified = [];
59+
private volatile bool _apiReferencesStale = true;
60+
private readonly SemaphoreSlim _apiSemaphore = new(1, 1);
5961

60-
public async Task ReloadAsync(Cancel ctx)
62+
public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true)
6163
{
6264
SourcePath.Refresh();
6365
OutputPath.Refresh();
66+
if (reloadConfiguration)
67+
_context.ReloadConfiguration();
6468
var crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx);
6569
IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null
6670
? new CodexAwareUriResolver(crossLinks.CodexRepositories)
@@ -76,12 +80,32 @@ public async Task ReloadAsync(Cancel ctx)
7680
var generator = new DocumentationGenerator(docSet, _logFactory, markdownExporters: markdownExporters.ToArray());
7781
await generator.ResolveDirectoryTree(ctx);
7882
_ = Interlocked.Exchange(ref _generator, generator);
83+
_apiReferencesStale = true;
84+
}
85+
86+
/// <summary>Lazily generates OpenAPI references on the first /api/ request, and regenerates when spec files change.</summary>
87+
public async Task EnsureApiReferencesAsync(Cancel ctx)
88+
{
89+
if (!_apiReferencesStale)
90+
return;
7991

80-
// Only regenerate OpenAPI if spec files have changed
81-
if (HaveOpenApiSpecsChanged(docSet.Configuration))
92+
await _apiSemaphore.WaitAsync(ctx);
93+
try
94+
{
95+
if (!_apiReferencesStale)
96+
return;
97+
98+
var config = _generator.DocumentationSet.Configuration;
99+
if (HaveOpenApiSpecsChanged(config))
100+
{
101+
await ReloadApiReferences(_generator.MarkdownStringRenderer, ctx);
102+
UpdateOpenApiSpecTimestamps(config);
103+
}
104+
_apiReferencesStale = false;
105+
}
106+
finally
82107
{
83-
await ReloadApiReferences(generator.MarkdownStringRenderer, ctx);
84-
UpdateOpenApiSpecTimestamps(docSet.Configuration);
108+
_ = _apiSemaphore.Release();
85109
}
86110
}
87111

@@ -137,6 +161,7 @@ private async Task ReloadApiReferences(IMarkdownStringRenderer markdownStringRen
137161

138162
public void Dispose()
139163
{
164+
_apiSemaphore.Dispose();
140165
(_codexReader as IDisposable)?.Dispose();
141166
GC.SuppressFinalize(this);
142167
}

0 commit comments

Comments
 (0)