Skip to content

Commit 5cab154

Browse files
Mpdreamzclaude
andauthored
feat(assembler): on-demand assembler serve with live reload and improved frontend DX (#3179)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent e3168d0 commit 5cab154

15 files changed

Lines changed: 644 additions & 24 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler config init --local
6262
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler clone -c local --skip-private-repositories
6363
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler build -c local --skip-private-repositories
64-
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler serve &
64+
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler serve-static &
6565
6666
- name: Wait for docs
6767
working-directory: src/Elastic.Documentation.Site

aspire/AppHost.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ internal static async Task Run(
129129
.WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl)
130130
.WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath)
131131
.WithHttpEndpoint(port: 4000, isProxied: false)
132-
.WithArgs(["assembler", "serve", .. GlobalArguments])
132+
.WithArgs(["assembler", "serve-static", .. GlobalArguments])
133133
.WithHttpHealthCheck("/", 200)
134134
.WaitForCompletion(buildAll)
135135
.WithParentRelationship(cloneAll);

build/CommandLine.fs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type Build =
4343

4444
| [<CliPrefix(CliPrefix.None);SubCommand>] Format of ParseResults<FormatArgs>
4545
| [<CliPrefix(CliPrefix.None);SubCommand>] Watch
46+
| [<CliPrefix(CliPrefix.None);SubCommand>] Watch_Full
4647

4748
| [<CliPrefix(CliPrefix.None);Hidden;SubCommand>] Lint of ParseResults<LintArgs>
4849
| [<CliPrefix(CliPrefix.None);Hidden;SubCommand>] PristineCheck
@@ -84,6 +85,7 @@ with
8485
| Format _ -> "runs dotnet format"
8586

8687
| Watch -> "runs dotnet watch to continuous build code/templates and web assets on the fly"
88+
| Watch_Full -> "runs assembler serve with dotnet watch — watches checkout dirs and live-reloads assembled docs"
8789

8890
// steps
8991
| Lint _ -> "runs dotnet format --verify-no-changes"

build/Targets.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ let private format (formatArgs: ParseResults<FormatArgs>) =
4242

4343
let private watch _ = exec { run "dotnet" "watch" "--project" "src/tooling/docs-builder" "--configuration" "debug" "--" "serve" "--watch" }
4444

45+
let private watchFull _ = exec { run "dotnet" "watch" "--project" "src/tooling/docs-builder" "--configuration" "debug" "--" "assembler" "serve" }
46+
4547
let private lint (lintArgs: ParseResults<LintArgs>) =
4648
let includeFiles = lintArgs.TryGetResult LintArgs.Include |> Option.defaultValue []
4749
let includeArgs =
@@ -256,6 +258,7 @@ let Setup (parsed:ParseResults<Build>) =
256258

257259
| Format formatArgs -> Build.Step (fun _ -> format formatArgs)
258260
| Watch -> Build.Step watch
261+
| Watch_Full -> Build.Step watchFull
259262

260263
// steps
261264
| Lint lintArgs -> Build.Step (fun _ -> lint lintArgs)

docs/cli-schema.json

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,9 +1329,85 @@
13291329
"assembler"
13301330
],
13311331
"name": "serve",
1332+
"summary": "Serve assembled documentation with live reload and on-demand per-request rendering. Requires assembler clone to have been run first. No prior build is needed. Pages are rendered on demand; file changes invalidate the repo and trigger a live reload.",
1333+
"notes": null,
1334+
"usage": "docs-builder assembler serve [options]",
1335+
"examples": [],
1336+
"parameters": [
1337+
{
1338+
"role": "flag",
1339+
"name": "port",
1340+
"shortName": null,
1341+
"type": "integer",
1342+
"required": false,
1343+
"summary": "Port to listen on. Default: 4000.",
1344+
"defaultValue": "4000"
1345+
},
1346+
{
1347+
"role": "flag",
1348+
"name": "environment",
1349+
"shortName": null,
1350+
"type": "string",
1351+
"required": false,
1352+
"summary": "Named deployment target. Determines which repositories are used."
1353+
},
1354+
{
1355+
"role": "flag",
1356+
"name": "no-watch-md",
1357+
"shortName": null,
1358+
"type": "boolean",
1359+
"required": false,
1360+
"summary": "Disable watching checkout directories for markdown changes. Static asset live reload still works. Useful when doing frontend (CSS/JS) work.",
1361+
"defaultValue": "false"
1362+
},
1363+
{
1364+
"role": "flag",
1365+
"name": "log-level",
1366+
"shortName": "l",
1367+
"type": "enum",
1368+
"required": false,
1369+
"summary": "Minimum log level. Default: information",
1370+
"enumValues": [
1371+
"trace",
1372+
"debug",
1373+
"information",
1374+
"warning",
1375+
"error",
1376+
"critical",
1377+
"none"
1378+
]
1379+
},
1380+
{
1381+
"role": "flag",
1382+
"name": "config-source",
1383+
"shortName": "c",
1384+
"type": "enum",
1385+
"required": false,
1386+
"summary": "Override the configuration source: local, remote",
1387+
"enumValues": [
1388+
"local",
1389+
"remote",
1390+
"embedded"
1391+
]
1392+
},
1393+
{
1394+
"role": "flag",
1395+
"name": "skip-private-repositories",
1396+
"shortName": null,
1397+
"type": "boolean",
1398+
"required": false,
1399+
"summary": "Skip cloning private repositories"
1400+
}
1401+
]
1402+
},
1403+
{
1404+
"path": [
1405+
"assembler"
1406+
],
1407+
"name": "serve-static",
13321408
"summary": "Serve the output of a completed assembler build at http://localhost:4000.",
13331409
"notes": "Run after assembler build. Does not watch for file changes.",
1334-
"usage": "docs-builder assembler serve [options]",
1410+
"usage": "docs-builder assembler serve-static [options]",
13351411
"examples": [],
13361412
"parameters": [
13371413
{
@@ -1347,7 +1423,7 @@
13471423
"name": "path",
13481424
"type": "string",
13491425
"required": false,
1350-
"summary": "Path to the built site. Defaults to .artifacts/docs/.",
1426+
"summary": "Path to the built site. Defaults to .artifacts/assembly/.",
13511427
"validations": [
13521428
{
13531429
"kind": "rejectSymbolicLinks"

src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
namespace Elastic.Documentation.LinkIndex;
1111

12-
public class Aws3LinkIndexReader(IAmazonS3 s3Client, string bucketName = "elastic-docs-link-index", string registryKey = "link-index.json") : ILinkIndexReader
12+
public class Aws3LinkIndexReader(IAmazonS3 s3Client, string bucketName = "elastic-docs-link-index", string registryKey = "link-index.json") : ILinkIndexReader, IDisposable
1313
{
1414

1515
// <summary>
@@ -52,4 +52,10 @@ public async Task<RepositoryLinks> GetRepositoryLinks(string key, Cancel cancell
5252
}
5353

5454
public string RegistryUrl { get; } = $"https://{bucketName}.s3.{s3Client.Config.RegionEndpoint.SystemName}.amazonaws.com/{registryKey}";
55+
56+
public void Dispose()
57+
{
58+
s3Client.Dispose();
59+
GC.SuppressFinalize(this);
60+
}
5561
}

src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ public record FetchedCrossLinks
5858
};
5959
}
6060

61-
public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable
61+
public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null, bool ownsReader = false) : IDisposable
6262
{
6363
protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher));
64+
protected ILinkIndexReader LinkIndexProvider => linkIndexProvider;
6465
private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.AppData;
6566
private LinkRegistry? _linkIndex;
6667

@@ -206,7 +207,11 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR
206207

207208
public void Dispose()
208209
{
209-
logFactory.Dispose();
210+
// Only dispose linkIndexProvider when this fetcher created it (ownsReader = true).
211+
// When the reader was injected by the caller, the caller retains ownership and must dispose it.
212+
// logFactory is always injected — never disposed here.
213+
if (ownsReader && linkIndexProvider is IDisposable disposableReader)
214+
disposableReader.Dispose();
210215
GC.SuppressFinalize(this);
211216
}
212217
}

src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ public class DocSetConfigurationCrossLinkFetcher(
1616
ConfigurationFile configuration,
1717
ILinkIndexReader? linkIndexProvider = null,
1818
ILinkIndexReader? codexLinkIndexReader = null)
19-
: CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous())
19+
: CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous(), ownsReader: linkIndexProvider is null)
2020
{
2121
private readonly ILogger _logger = logFactory.CreateLogger(nameof(DocSetConfigurationCrossLinkFetcher));
22+
// _codexReader is injected by the caller who retains ownership and is responsible for disposal.
23+
// ReloadableGeneratorState, the primary caller, disposes it directly in its own Dispose().
2224
private readonly ILinkIndexReader? _codexReader = codexLinkIndexReader;
2325

2426
public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
@@ -31,7 +33,7 @@ public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
3133
var codexRepositories = new HashSet<string>();
3234
var declaredRepositories = new HashSet<string>();
3335

34-
var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous();
36+
var publicReader = LinkIndexProvider;
3537
var useDualRegistry = configuration.Registry != DocSetRegistry.Public && _codexReader is not null;
3638

3739
// Fetch each registry once up front so per-repository lookups don't trigger N S3 round-trips.

src/Elastic.Markdown/IO/DocumentationSet.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,14 +217,26 @@ public INavigationItem FindNavigationByMarkdown(MarkdownFile markdown)
217217
}
218218

219219
private bool _resolved;
220+
private long _version;
221+
222+
public void InvalidateResolved()
223+
{
224+
_ = Interlocked.Increment(ref _version);
225+
_resolved = false;
226+
}
227+
220228
public async Task ResolveDirectoryTree(Cancel ctx)
221229
{
222230
if (_resolved)
223231
return;
224232

233+
// Capture the version before parsing so that if InvalidateResolved() fires
234+
// mid-flight we do not incorrectly mark the (now stale) result as resolved.
235+
var capturedVersion = Interlocked.Read(ref _version);
225236
await Parallel.ForEachAsync(MarkdownFiles, ctx, async (file, token) => await file.MinimalParseAsync(TryFindDocumentByRelativePath, token));
226237

227-
_resolved = true;
238+
if (Interlocked.Read(ref _version) == capturedVersion)
239+
_resolved = true;
228240
}
229241

230242
public RepositoryLinks CreateLinkReference()

src/services/Elastic.Documentation.Assembler/AssembleSources.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@ Cancel ctx
4242
{
4343
var logger = logFactory.CreateLogger<AssembleSources>();
4444

45-
var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous();
4645
var navigationTocMappings = GetTocMappings(context);
4746
var uriResolver = new PublishEnvironmentUriResolver(navigationTocMappings, context.Environment);
4847

4948
var sw = System.Diagnostics.Stopwatch.StartNew();
50-
var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexProvider);
51-
var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx);
49+
FetchedCrossLinks crossLinks;
50+
// Use a separate using for the reader so ownership is explicit: the caller (this method)
51+
// disposes it, not the fetcher (ownsReader stays false/default on AssemblerCrossLinkFetcher).
52+
using var linkIndexReader = Aws3LinkIndexReader.CreateAnonymous();
53+
using (var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexReader))
54+
crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx);
5255
var crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver);
5356
logger.LogInformation(" AssembleAsync: FetchCrossLinks in {Elapsed:mm\\:ss\\.fff}", sw.Elapsed);
5457

0 commit comments

Comments
 (0)