diff --git a/.editorconfig b/.editorconfig index 08879dff7..885c8f7a2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -244,6 +244,10 @@ dotnet_diagnostic.IDE0072.severity = none dotnet_diagnostic.IL3050.severity = none dotnet_diagnostic.IL2026.severity = none +[StaticWebHost.cs] +dotnet_diagnostic.IL3050.severity = none +dotnet_diagnostic.IL2026.severity = none + [tests/**/*.cs] dotnet_diagnostic.IDE0058.severity = none dotnet_diagnostic.IDE0022.severity = none diff --git a/src/Elastic.Markdown/BuildContext.cs b/src/Elastic.Markdown/BuildContext.cs index df225fe40..444cd4302 100644 --- a/src/Elastic.Markdown/BuildContext.cs +++ b/src/Elastic.Markdown/BuildContext.cs @@ -39,6 +39,15 @@ public string? UrlPathPrefix init => _urlPathPrefix = value; } + private readonly string? _staticUrlPathPrefix; + public string? StaticUrlPathPrefix + { + get => !string.IsNullOrWhiteSpace(_staticUrlPathPrefix) + ? $"/{_staticUrlPathPrefix.Trim('/')}" + : UrlPathPrefix; + init => _staticUrlPathPrefix = value; + } + public BuildContext(IFileSystem fileSystem) : this(new DiagnosticsCollector([]), fileSystem, fileSystem, null, null) { } diff --git a/src/Elastic.Markdown/Slices/HtmlWriter.cs b/src/Elastic.Markdown/Slices/HtmlWriter.cs index b960617ca..edf61aeb0 100644 --- a/src/Elastic.Markdown/Slices/HtmlWriter.cs +++ b/src/Elastic.Markdown/Slices/HtmlWriter.cs @@ -98,6 +98,7 @@ public async Task RenderLayout(MarkdownFile markdown, MarkdownDocument d TopLevelNavigationItems = [.. topLevelNavigationItems], NavigationHtml = navigationHtml, UrlPathPrefix = markdown.UrlPathPrefix, + StaticUrlPathPrefix = DocumentationSet.Build.StaticUrlPathPrefix, Applies = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, AllowIndexing = DocumentationSet.Build.AllowIndexing && !markdown.Hidden, diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml index ec51e5529..e4b84c90a 100644 --- a/src/Elastic.Markdown/Slices/Index.cshtml +++ b/src/Elastic.Markdown/Slices/Index.cshtml @@ -13,6 +13,7 @@ NavigationHtml = Model.NavigationHtml, TopLevelNavigationItems = Model.TopLevelNavigationItems, UrlPathPrefix = Model.UrlPathPrefix, + StaticUrlPathPrefix = Model.StaticUrlPathPrefix, GithubEditUrl = Model.GithubEditUrl, AllowIndexing = Model.AllowIndexing, Features = Model.Features diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index 0f40e530d..503855b82 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -23,6 +23,7 @@ public class IndexViewModel public required string NavigationHtml { get; init; } public required string? UrlPathPrefix { get; init; } + public required string? StaticUrlPathPrefix { get; init; } public required string? GithubEditUrl { get; init; } public required ApplicableTo? Applies { get; init; } public required bool AllowIndexing { get; init; } @@ -43,9 +44,10 @@ public class LayoutViewModel public required MarkdownFile CurrentDocument { get; init; } public required MarkdownFile? Previous { get; init; } public required MarkdownFile? Next { get; init; } - public required string NavigationHtml { get; set; } - public required string? UrlPathPrefix { get; set; } - public required string? GithubEditUrl { get; set; } + public required string NavigationHtml { get; init; } + public required string? StaticUrlPathPrefix { get; init; } + public required string? UrlPathPrefix { get; init; } + public required string? GithubEditUrl { get; init; } public required bool AllowIndexing { get; init; } public required FeatureFlags Features { get; init; } @@ -67,7 +69,7 @@ public MarkdownFile[] Parents public string Static(string path) { path = $"_static/{path.TrimStart('/')}"; - return $"{UrlPathPrefix}/{path}"; + return $"{StaticUrlPathPrefix}/{path}"; } public string Link(string path) diff --git a/src/docs-assembler/AssembleContext.cs b/src/docs-assembler/AssembleContext.cs index d03bf81d0..a262bf5c2 100644 --- a/src/docs-assembler/AssembleContext.cs +++ b/src/docs-assembler/AssembleContext.cs @@ -30,13 +30,19 @@ public class AssembleContext // This property is used to determine if the site should be indexed by search engines public bool AllowIndexing { get; init; } - public AssembleContext(DiagnosticsCollector collector, IFileSystem readFileSystem, IFileSystem writeFileSystem, string? checkoutDirectory, string? output) + public AssembleContext( + DiagnosticsCollector collector, + IFileSystem readFileSystem, + IFileSystem writeFileSystem, + string? checkoutDirectory, + string? output + ) { Collector = collector; ReadFileSystem = readFileSystem; WriteFileSystem = writeFileSystem; - var configPath = Path.Combine(Paths.Root.FullName, "src/docs-assembler/assembler.yml"); + var configPath = Path.Combine(Paths.Root.FullName, "src", "docs-assembler", "assembler.yml"); // temporarily fallback to embedded assembler.yml // This will live in docs-content soon if (!ReadFileSystem.File.Exists(configPath)) diff --git a/src/docs-assembler/Building/AssemblerBuilder.cs b/src/docs-assembler/Building/AssemblerBuilder.cs index e4f883604..89b473d6e 100644 --- a/src/docs-assembler/Building/AssemblerBuilder.cs +++ b/src/docs-assembler/Building/AssemblerBuilder.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Documentation.Assembler.Configuration; using Documentation.Assembler.Sourcing; using Elastic.Markdown; using Elastic.Markdown.CrossLinks; @@ -14,7 +15,7 @@ public class AssemblerBuilder(ILoggerFactory logger, AssembleContext context) { private readonly ILogger _logger = logger.CreateLogger(); - public async Task BuildAllAsync(IReadOnlyCollection checkouts, string environment, Cancel ctx) + public async Task BuildAllAsync(IReadOnlyCollection checkouts, PublishEnvironment environment, Cancel ctx) { var crossLinkFetcher = new AssemblerCrossLinkFetcher(logger, context.Configuration); var uriResolver = new PublishEnvironmentUriResolver(context.Configuration, environment); @@ -24,7 +25,7 @@ public async Task BuildAllAsync(IReadOnlyCollection checkouts, string { try { - await BuildAsync(checkout, crossLinkResolver, ctx); + await BuildAsync(checkout, environment, crossLinkResolver, ctx); } catch (Exception e) when (e.Message.Contains("Can not locate docset.yml file in")) { @@ -39,14 +40,22 @@ public async Task BuildAllAsync(IReadOnlyCollection checkouts, string } } - private async Task BuildAsync(Checkout checkout, CrossLinkResolver crossLinkResolver, Cancel ctx) + private async Task BuildAsync(Checkout checkout, PublishEnvironment environment, CrossLinkResolver crossLinkResolver, Cancel ctx) { var path = checkout.Directory.FullName; - var pathPrefix = checkout.Repository.PathPrefix; - var output = pathPrefix != null ? Path.Combine(context.OutputDirectory.FullName, pathPrefix) : context.OutputDirectory.FullName; + var localPathPrefix = checkout.Repository.PathPrefix; + var pathPrefix = (environment.PathPrefix, localPathPrefix) switch + { + (null or "", null or "") => null, + (null or "", _) => localPathPrefix, + (_, null or "") => environment.PathPrefix, + var (globalPrefix, docsetPrefix) => $"{globalPrefix}/{docsetPrefix}" + }; + var output = localPathPrefix != null ? Path.Combine(context.OutputDirectory.FullName, localPathPrefix) : context.OutputDirectory.FullName; var buildContext = new BuildContext(context.Collector, context.ReadFileSystem, context.WriteFileSystem, path, output) { + StaticUrlPathPrefix = environment.PathPrefix, UrlPathPrefix = pathPrefix, Force = true, AllowIndexing = true diff --git a/src/docs-assembler/Building/PublishEnvironmentUriResolver.cs b/src/docs-assembler/Building/PublishEnvironmentUriResolver.cs index 4049973dc..4d0595842 100644 --- a/src/docs-assembler/Building/PublishEnvironmentUriResolver.cs +++ b/src/docs-assembler/Building/PublishEnvironmentUriResolver.cs @@ -18,15 +18,13 @@ public class PublishEnvironmentUriResolver : IUriEnvironmentResolver private FrozenDictionary AllRepositories { get; } - public PublishEnvironmentUriResolver(AssemblyConfiguration configuration, string environment) + public PublishEnvironmentUriResolver(AssemblyConfiguration configuration, PublishEnvironment environment) { - if (!configuration.Environments.TryGetValue(environment, out var e)) - throw new Exception($"Could not find environment {environment}"); - if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) - throw new Exception($"Could not parse uri {e.Uri} in environment {environment}"); + if (!Uri.TryCreate(environment.Uri, UriKind.Absolute, out var uri)) + throw new Exception($"Could not parse uri {environment.Uri} in environment {environment}"); BaseUri = uri; - PublishEnvironment = e; + PublishEnvironment = environment; PreviewResolver = new PreviewEnvironmentUriResolver(); AllRepositories = configuration.ReferenceRepositories.Values.Concat([configuration.Narrative]) .ToFrozenDictionary(e => e.Name, e => e); diff --git a/src/docs-assembler/Cli/RepositoryCommands.cs b/src/docs-assembler/Cli/RepositoryCommands.cs index 172267ff5..59d6c3e86 100644 --- a/src/docs-assembler/Cli/RepositoryCommands.cs +++ b/src/docs-assembler/Cli/RepositoryCommands.cs @@ -60,7 +60,7 @@ public async Task BuildAll( { AssignOutputLogger(); var githubEnvironmentInput = githubActionsService.GetInput("environment"); - environment ??= !string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "production"; + environment ??= !string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"; await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService); _ = collector.StartAsync(ctx); @@ -70,13 +70,17 @@ public async Task BuildAll( Force = force ?? false, AllowIndexing = allowIndexing ?? false, }; + + if (!assembleContext.Configuration.Environments.TryGetValue(environment, out var env)) + throw new Exception($"Could not find environment {environment}"); + var cloner = new RepositoryCheckoutProvider(logger, assembleContext); var checkouts = cloner.GetAll().ToArray(); if (checkouts.Length == 0) throw new Exception("No checkouts found"); var builder = new AssemblerBuilder(logger, assembleContext); - await builder.BuildAllAsync(checkouts, environment, ctx); + await builder.BuildAllAsync(checkouts, env, ctx); if (strict ?? false) return collector.Errors + collector.Warnings; diff --git a/src/docs-assembler/Sourcing/RepositorySourcesFetcher.cs b/src/docs-assembler/Sourcing/RepositorySourcesFetcher.cs index dec5fb08f..f7fc5d827 100644 --- a/src/docs-assembler/Sourcing/RepositorySourcesFetcher.cs +++ b/src/docs-assembler/Sourcing/RepositorySourcesFetcher.cs @@ -143,7 +143,7 @@ private string Capture(IDirectoryInfo? workingDirectory, string binary, params s var arguments = new StartArguments(binary, args) { WorkingDirectory = workingDirectory?.FullName, - WaitForStreamReadersTimeout = TimeSpan.FromSeconds(3), + //WaitForStreamReadersTimeout = TimeSpan.FromSeconds(3), Timeout = TimeSpan.FromSeconds(3), WaitForExit = TimeSpan.FromSeconds(3), ConsoleOutWriter = NoopConsoleWriter.Instance diff --git a/src/docs-assembler/assembler.yml b/src/docs-assembler/assembler.yml index c6de6a7cb..101afd011 100644 --- a/src/docs-assembler/assembler.yml +++ b/src/docs-assembler/assembler.yml @@ -8,6 +8,9 @@ environments: preview: uri: https://docs-v3-preview.elastic.dev path_prefix: + dev: + uri: http://localhost:4000 + path_prefix: docs narrative: checkout_strategy: full references: diff --git a/src/docs-builder/Cli/Commands.cs b/src/docs-builder/Cli/Commands.cs index 4b92209ee..c7e87c5a7 100644 --- a/src/docs-builder/Cli/Commands.cs +++ b/src/docs-builder/Cli/Commands.cs @@ -46,6 +46,24 @@ public async Task Serve(string? path = null, int port = 3000, Cancel ctx = defau await host.StopAsync(ctx); } + /// + /// Serve html files directly + /// + /// -p, Path to serve the documentation. + /// Defaults to the`{pwd}/docs` folder + /// + /// Port to serve the documentation. + /// + [Command("serve-static")] + [ConsoleAppFilter] + public async Task ServeStatic(string? path = null, int port = 4000, Cancel ctx = default) + { + AssignOutputLogger(); + var host = new StaticWebHost(path, port, new FileSystem()); + await host.RunAsync(ctx); + await host.StopAsync(ctx); + } + /// /// Converts a source markdown folder or file to an output folder /// global options: diff --git a/src/docs-builder/Http/StaticWebHost.cs b/src/docs-builder/Http/StaticWebHost.cs new file mode 100644 index 000000000..e9a38d04f --- /dev/null +++ b/src/docs-builder/Http/StaticWebHost.cs @@ -0,0 +1,98 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Tooling; +using Elastic.Markdown; +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.IO; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Documentation.Builder.Http; + +public class StaticWebHost +{ + private readonly WebApplication _webApplication; + + private readonly BuildContext _context; + + public StaticWebHost(string? path, int port, IFileSystem fileSystem) + { + var builder = WebApplication.CreateSlimBuilder(); + DocumentationTooling.CreateServiceCollection(builder.Services, LogLevel.Warning); + + _ = builder.Logging + .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) + .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) + .AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Information); + _ = builder.WebHost.UseUrls($"http://localhost:{port}"); + + _webApplication = builder.Build(); + _context = new BuildContext(new DiagnosticsCollector([]), fileSystem, fileSystem, path, null); + SetUpRoutes(); + } + + public async Task RunAsync(Cancel ctx) => await _webApplication.RunAsync(ctx); + + public async Task StopAsync(Cancel ctx) => await _context.Collector.StopAsync(ctx); + + private void SetUpRoutes() + { + _ = + _webApplication + .UseRouting(); + + _ = _webApplication.MapGet("/", (Cancel _) => Results.Redirect("docs")); + + _ = _webApplication.MapGet("docs/", (Cancel ctx) => + ServeDocumentationFile("index.html", ctx)); + + _ = _webApplication.MapGet("docs/{**slug}", (string slug, Cancel ctx) => + ServeDocumentationFile(slug, ctx)); + } + + private static async Task ServeDocumentationFile(string slug, Cancel _) + { + // from the injected top level navigation which expects us to run on elastic.co + if (slug.StartsWith("static-res/")) + return Results.NotFound(); + + await Task.CompletedTask; + var path = Path.Combine(Paths.Root.FullName, ".artifacts", "assembly"); + var localPath = Path.Combine(path, slug.Replace('/', Path.DirectorySeparatorChar)); + var fileInfo = new FileInfo(localPath); + var directoryInfo = new DirectoryInfo(localPath); + if (directoryInfo.Exists) + fileInfo = new FileInfo(Path.Combine(directoryInfo.FullName, "index.html")); + + if (fileInfo.Exists) + { + var mimetype = fileInfo.Extension switch + { + ".js" => "text/javascript", + ".css" => "text/css", + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".gif" => "image/gif", + ".svg" => "image/svg+xml", + ".ico" => "image/x-icon", + ".json" => "application/json", + ".map" => "application/json", + ".txt" => "text/plain", + ".xml" => "text/xml", + ".yml" => "text/yaml", + ".md" => "text/markdown", + _ => "text/html" + }; + return Results.File(fileInfo.FullName, mimetype); + } + + + return Results.NotFound(); + } +}