Skip to content
Open
2 changes: 2 additions & 0 deletions build/CommandLine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Build =

| [<CliPrefix(CliPrefix.None);SubCommand>] Format of ParseResults<FormatArgs>
| [<CliPrefix(CliPrefix.None);SubCommand>] Watch
| [<CliPrefix(CliPrefix.None);SubCommand>] Watch_Full

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

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

// steps
| Lint _ -> "runs dotnet format --verify-no-changes"
Expand Down
3 changes: 3 additions & 0 deletions build/Targets.fs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ let private format (formatArgs: ParseResults<FormatArgs>) =

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

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

let private lint (lintArgs: ParseResults<LintArgs>) =
let includeFiles = lintArgs.TryGetResult LintArgs.Include |> Option.defaultValue []
let includeArgs =
Expand Down Expand Up @@ -256,6 +258,7 @@ let Setup (parsed:ParseResults<Build>) =

| Format formatArgs -> Build.Step (fun _ -> format formatArgs)
| Watch -> Build.Step watch
| Watch_Full -> Build.Step watchFull

// steps
| Lint lintArgs -> Build.Step (fun _ -> lint lintArgs)
Expand Down
8 changes: 7 additions & 1 deletion src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

namespace Elastic.Documentation.LinkIndex;

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

// <summary>
Expand Down Expand Up @@ -52,4 +52,10 @@ public async Task<RepositoryLinks> GetRepositoryLinks(string key, Cancel cancell
}

public string RegistryUrl { get; } = $"https://{bucketName}.s3.{s3Client.Config.RegionEndpoint.SystemName}.amazonaws.com/{registryKey}";

public void Dispose()
{
s3Client.Dispose();
GC.SuppressFinalize(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public record FetchedCrossLinks
public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable
{
protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher));
protected ILinkIndexReader LinkIndexProvider => linkIndexProvider;
private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.AppData;
private LinkRegistry? _linkIndex;

Expand Down Expand Up @@ -192,7 +193,10 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR

public void Dispose()
{
logFactory.Dispose();
// Dispose the reader only when this fetcher created it (e.g. Aws3LinkIndexReader.CreateAnonymous()).
// logFactory is injected and owned by the caller — do not dispose it here.
if (linkIndexProvider is IDisposable disposableReader)
disposableReader.Dispose();
GC.SuppressFinalize(this);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
var codexRepositories = new HashSet<string>();
var declaredRepositories = new HashSet<string>();

var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous();
var publicReader = LinkIndexProvider;
var useDualRegistry = configuration.Registry != DocSetRegistry.Public && _codexReader is not null;

foreach (var entry in configuration.CrossLinkEntries)
Expand Down
14 changes: 13 additions & 1 deletion src/Elastic.Markdown/IO/DocumentationSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,26 @@ public INavigationItem FindNavigationByMarkdown(MarkdownFile markdown)
}

private bool _resolved;
private long _version;

public void InvalidateResolved()
{
_ = Interlocked.Increment(ref _version);
_resolved = false;
}

public async Task ResolveDirectoryTree(Cancel ctx)
{
if (_resolved)
return;

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

_resolved = true;
if (Interlocked.Read(ref _version) == capturedVersion)
_resolved = true;
}

public RepositoryLinks CreateLinkReference()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ Cancel ctx
{
var logger = logFactory.CreateLogger<AssembleSources>();

var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous();
var navigationTocMappings = GetTocMappings(context);
var uriResolver = new PublishEnvironmentUriResolver(navigationTocMappings, context.Environment);

var sw = System.Diagnostics.Stopwatch.StartNew();
var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexProvider);
var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx);
FetchedCrossLinks crossLinks;
using (var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, Aws3LinkIndexReader.CreateAnonymous()))
crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx);
var crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver);
logger.LogInformation(" AssembleAsync: FetchCrossLinks in {Elapsed:mm\\:ss\\.fff}", sw.Elapsed);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,7 @@ public async Task BuildAllAsync(FrozenDictionary<string, AssemblerDocumentationS
continue;
}

// Create inferrer per-repository with git context
var documentInferrer = new DocumentInferrerService(
context.ProductsConfiguration,
context.VersionsConfiguration,
context.LegacyUrlMappings,
set.DocumentationSet.Configuration,
set.DocumentationSet.Context.Git
);
var documentInferrer = CreateInferrer(set);

var stopwatch = Stopwatch.StartNew();
try
Expand Down Expand Up @@ -157,6 +150,33 @@ string Resolve(string path)
}
}

private DocumentInferrerService CreateInferrer(AssemblerDocumentationSet set) =>
new(
context.ProductsConfiguration,
context.VersionsConfiguration,
context.LegacyUrlMappings,
set.DocumentationSet.Configuration,
set.DocumentationSet.Context.Git
);

public DocumentationGenerator CreateGenerator(AssemblerDocumentationSet set)
{
SetFeatureFlags(set);
return new DocumentationGenerator(
set.DocumentationSet,
logFactory, NavigationTraversable, HtmlWriter,
pathProvider,
legacyUrlMapper: LegacyUrlMapper,
documentInferrer: CreateInferrer(set)
);
}

public async Task BuildOneAsync(AssemblerDocumentationSet set, Cancel ctx)
{
await set.DocumentationSet.ResolveDirectoryTree(ctx);
_ = await BuildAsync(set, null, CreateInferrer(set), ctx);
}

private async Task<GenerationResult> BuildAsync(AssemblerDocumentationSet set, IMarkdownExporter[]? markdownExporters, IDocumentInferrerService documentInferrer, Cancel ctx)
{
SetFeatureFlags(set);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@
using Documentation.Builder.Arguments;
using Documentation.Builder.Http;
using Elastic.Documentation;
using Elastic.Documentation.Assembler;
using Elastic.Documentation.Assembler.Building;
using Elastic.Documentation.Assembler.Navigation;
using Elastic.Documentation.Assembler.Sourcing;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Assembler;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.LegacyDocs;
using Elastic.Documentation.Navigation.Assembler;
using Elastic.Documentation.Services;
using Microsoft.Extensions.Logging;
using Nullean.ScopedFileSystem;

namespace Documentation.Builder.Commands.Assembler;

Expand Down Expand Up @@ -153,17 +159,71 @@ await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly,
return await serviceInvoker.InvokeAsync(ctx);
}

/// <summary> Serve the output of an assembler build</summary>
/// <summary>
/// Serve assembled documentation with live reload and on-demand per-request rendering.
/// Requires 'assembler clone' to have been run first. No prior build needed.
/// Pages are rendered on demand; file changes invalidate the repo and trigger a live reload.
/// </summary>
/// <param name="port">Port to serve the documentation.</param>
/// <param name="environment">The environment configuration to use.</param>
/// <param name="noWatchMd">Disable watching checkout directories for markdown changes. Static asset live reload still works. Useful when doing frontend (CSS/JS) work.</param>
/// <param name="ctx"></param>
[Command("serve")]
public async Task ServeAssemblerBuild(int port = 4000, string? path = null, Cancel ctx = default)
public async Task ServeAssemblerOnDemand(
int port = 4000,
string? environment = null,
bool noWatchMd = false,
Cancel ctx = default
)
{
environment ??= "dev";
var readFs = FileSystemFactory.RealRead;
var writeFs = FileSystemFactory.RealWrite;

var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null);

var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext);
var checkoutResult = cloner.GetAll();
var checkouts = checkoutResult.Checkouts.ToArray();

if (checkouts.Length == 0)
throw new Exception("No checkouts found. Run 'assembler clone' first.");

var exporters = ExportOptions.Default
.Except([Exporter.DocumentationState])
.ToHashSet();

var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx);

var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile;
var siteNavigationFile = SiteNavigationFile.Deserialize(await readFs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx));
var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray();
var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix);

var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext);
using var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector);
var legacyPageChecker = new LegacyPageService(logFactory);
var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings);
var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper);

var host = new AssemblerServeWebHost(port, assembleSources, builder, logFactory, watchMarkdown: !noWatchMd);
await host.RunAsync(ctx);
await host.StopAsync(ctx);
// since this command does not use ServiceInvoker, we stop the collector manually.
await collector.StopAsync(ctx);
}

/// <summary>Serve the static output of a prior 'assembler build' run.</summary>
/// <param name="port">Port to serve the documentation.</param>
/// <param name="path">Optional path to serve from, defaults to .artifacts/assembly.</param>
/// <param name="ctx"></param>
[Command("serve-static")]
public async Task ServeStaticAssemblerBuild(int port = 4000, string? path = null, Cancel ctx = default)
{
var host = new StaticWebHost(port, path);
await host.RunAsync(ctx);
await host.StopAsync(ctx);
// since this command does not use ServiceInvoker, we stop the collector manually.
// this should be an exception to the regular command pattern.
await collector.StopAsync(ctx);
}

Expand Down
Loading
Loading