diff --git a/src/Elastic.Markdown/IO/EmbeddedOrPhysicalFileProvider.cs b/src/Elastic.Markdown/IO/EmbeddedOrPhysicalFileProvider.cs new file mode 100644 index 000000000..993dc1652 --- /dev/null +++ b/src/Elastic.Markdown/IO/EmbeddedOrPhysicalFileProvider.cs @@ -0,0 +1,78 @@ +// 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 Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Elastic.Markdown.IO; + +public sealed class EmbeddedOrPhysicalFileProvider : IFileProvider, IDisposable +{ + private readonly EmbeddedFileProvider _embeddedProvider = new(typeof(BuildContext).Assembly, "Elastic.Markdown._static"); + private readonly PhysicalFileProvider? _staticFilesInDocsFolder; + + private readonly PhysicalFileProvider? _staticWebFilesDuringDebug; + + public EmbeddedOrPhysicalFileProvider(BuildContext context) + { + var documentationStaticFiles = Path.Combine(context.DocumentationSourceDirectory.FullName, "_static"); +#if DEBUG + // this attempts to serve files directly from their source rather than the embedded resources during development. + // this allows us to change js/css files without restarting the webserver + var solutionRoot = Paths.GetSolutionDirectory(); + if (solutionRoot != null) + { + + var debugWebFiles = Path.Combine(solutionRoot.FullName, "src", "Elastic.Markdown", "_static"); + _staticWebFilesDuringDebug = new PhysicalFileProvider(debugWebFiles); + } +#else + _staticWebFilesDuringDebug = null; +#endif + if (context.ReadFileSystem.Directory.Exists(documentationStaticFiles)) + _staticFilesInDocsFolder = new PhysicalFileProvider(documentationStaticFiles); + } + + private T? FirstYielding(string arg, Func predicate) => + Yield(arg, predicate, _staticWebFilesDuringDebug) ?? Yield(arg, predicate, _staticFilesInDocsFolder); + + private static T? Yield(string arg, Func predicate, PhysicalFileProvider? provider) + { + if (provider is null) + return default; + var result = predicate(arg, provider); + return result ?? default; + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + var contents = FirstYielding(subpath, static (a, p) => p.GetDirectoryContents(a)); + if (contents is null || !contents.Exists) + contents = _embeddedProvider.GetDirectoryContents(subpath); + return contents; + } + + public IFileInfo GetFileInfo(string subpath) + { + var path = subpath.Replace($"{Path.DirectorySeparatorChar}_static", ""); + var fileInfo = FirstYielding(path, static (a, p) => p.GetFileInfo(a)); + if (fileInfo is null || !fileInfo.Exists) + fileInfo = _embeddedProvider.GetFileInfo(subpath); + return fileInfo; + } + + public IChangeToken Watch(string filter) + { + var changeToken = FirstYielding(filter, static (f, p) => p.Watch(f)); + if (changeToken is null or NullChangeToken) + changeToken = _embeddedProvider.Watch(filter); + return changeToken; + } + + public void Dispose() + { + _staticFilesInDocsFolder?.Dispose(); + _staticWebFilesDuringDebug?.Dispose(); + } +} diff --git a/src/Elastic.Markdown/IO/StaticFileContentHashProvider.cs b/src/Elastic.Markdown/IO/StaticFileContentHashProvider.cs new file mode 100644 index 000000000..1cfe0eb3d --- /dev/null +++ b/src/Elastic.Markdown/IO/StaticFileContentHashProvider.cs @@ -0,0 +1,29 @@ +// 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.Collections.Concurrent; + +namespace Elastic.Markdown.IO; + +public class StaticFileContentHashProvider(EmbeddedOrPhysicalFileProvider fileProvider) +{ + private readonly ConcurrentDictionary _contentHashes = []; + + public string GetContentHash(string path) + { + if (_contentHashes.TryGetValue(path, out var contentHash)) + return contentHash; + + var fileInfo = fileProvider.GetFileInfo(path); + + if (!fileInfo.Exists) + return string.Empty; + + using var stream = fileInfo.CreateReadStream(); + using var sha = System.Security.Cryptography.SHA256.Create(); + var fullHash = sha.ComputeHash(stream); + _contentHashes[path] = Convert.ToHexString(fullHash).ToLowerInvariant()[..16]; + return _contentHashes[path]; + } +} diff --git a/src/Elastic.Markdown/Slices/HtmlWriter.cs b/src/Elastic.Markdown/Slices/HtmlWriter.cs index edf61aeb0..2b48a1786 100644 --- a/src/Elastic.Markdown/Slices/HtmlWriter.cs +++ b/src/Elastic.Markdown/Slices/HtmlWriter.cs @@ -8,12 +8,14 @@ using Elastic.Markdown.IO.Navigation; using Markdig.Syntax; using RazorSlices; +using IFileInfo = System.IO.Abstractions.IFileInfo; namespace Elastic.Markdown.Slices; public class HtmlWriter(DocumentationSet documentationSet, IFileSystem writeFileSystem) { private DocumentationSet DocumentationSet { get; } = documentationSet; + private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new(new EmbeddedOrPhysicalFileProvider(documentationSet.Build)); private async Task RenderNavigation(string topLevelGroupId, MarkdownFile markdown, Cancel ctx = default) { @@ -102,7 +104,8 @@ public async Task RenderLayout(MarkdownFile markdown, MarkdownDocument d Applies = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, AllowIndexing = DocumentationSet.Build.AllowIndexing && !markdown.Hidden, - Features = DocumentationSet.Configuration.Features + Features = DocumentationSet.Configuration.Features, + StaticFileContentHashProvider = StaticFileContentHashProvider }); return await slice.RenderAsync(cancellationToken: ctx); } diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml index e4b84c90a..07ef547f9 100644 --- a/src/Elastic.Markdown/Slices/Index.cshtml +++ b/src/Elastic.Markdown/Slices/Index.cshtml @@ -16,7 +16,8 @@ StaticUrlPathPrefix = Model.StaticUrlPathPrefix, GithubEditUrl = Model.GithubEditUrl, AllowIndexing = Model.AllowIndexing, - Features = Model.Features + Features = Model.Features, + StaticFileContentHashProvider = Model.StaticFileContentHashProvider }; }
diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index 503855b82..57ded354c 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -28,6 +28,7 @@ public class IndexViewModel public required ApplicableTo? Applies { get; init; } public required bool AllowIndexing { get; init; } public required FeatureFlags Features { get; init; } + public required StaticFileContentHashProvider StaticFileContentHashProvider { get; init; } } public class LayoutViewModel @@ -68,10 +69,15 @@ public MarkdownFile[] Parents public string Static(string path) { - path = $"_static/{path.TrimStart('/')}"; - return $"{StaticUrlPathPrefix}/{path}"; + var staticPath = $"_static/{path.TrimStart('/')}"; + var contentHash = StaticFileContentHashProvider.GetContentHash(path.TrimStart('/')); + return string.IsNullOrEmpty(contentHash) + ? $"{StaticUrlPathPrefix}/{staticPath}" + : $"{StaticUrlPathPrefix}/{staticPath}?v={contentHash}"; } + public required StaticFileContentHashProvider StaticFileContentHashProvider { get; init; } + public string Link(string path) { path = path.AsSpan().TrimStart('/').ToString(); diff --git a/src/docs-builder/Http/DocumentationWebHost.cs b/src/docs-builder/Http/DocumentationWebHost.cs index c257a676c..f08fdef7b 100644 --- a/src/docs-builder/Http/DocumentationWebHost.cs +++ b/src/docs-builder/Http/DocumentationWebHost.cs @@ -13,12 +13,9 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; using Westwind.AspNetCore.LiveReload; -using IFileInfo = Microsoft.Extensions.FileProviders.IFileInfo; namespace Documentation.Builder.Http; @@ -184,74 +181,3 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta } } } - - -public sealed class EmbeddedOrPhysicalFileProvider : IFileProvider, IDisposable -{ - private readonly EmbeddedFileProvider _embeddedProvider = new(typeof(BuildContext).Assembly, "Elastic.Markdown._static"); - private readonly PhysicalFileProvider? _staticFilesInDocsFolder; - - private readonly PhysicalFileProvider? _staticWebFilesDuringDebug; - - public EmbeddedOrPhysicalFileProvider(BuildContext context) - { - var documentationStaticFiles = Path.Combine(context.DocumentationSourceDirectory.FullName, "_static"); -#if DEBUG - // this attempts to serve files directly from their source rather than the embedded resources during development. - // this allows us to change js/css files without restarting the webserver - var solutionRoot = Paths.GetSolutionDirectory(); - if (solutionRoot != null) - { - - var debugWebFiles = Path.Combine(solutionRoot.FullName, "src", "Elastic.Markdown", "_static"); - _staticWebFilesDuringDebug = new PhysicalFileProvider(debugWebFiles); - } -#else - _staticWebFilesDuringDebug = null; -#endif - if (context.ReadFileSystem.Directory.Exists(documentationStaticFiles)) - _staticFilesInDocsFolder = new PhysicalFileProvider(documentationStaticFiles); - } - - private T? FirstYielding(string arg, Func predicate) => - Yield(arg, predicate, _staticWebFilesDuringDebug) ?? Yield(arg, predicate, _staticFilesInDocsFolder); - - private static T? Yield(string arg, Func predicate, PhysicalFileProvider? provider) - { - if (provider is null) - return default; - var result = predicate(arg, provider); - return result ?? default; - } - - public IDirectoryContents GetDirectoryContents(string subpath) - { - var contents = FirstYielding(subpath, static (a, p) => p.GetDirectoryContents(a)); - if (contents is null || !contents.Exists) - contents = _embeddedProvider.GetDirectoryContents(subpath); - return contents; - } - - public IFileInfo GetFileInfo(string subpath) - { - var path = subpath.Replace($"{Path.DirectorySeparatorChar}_static", ""); - var fileInfo = FirstYielding(path, static (a, p) => p.GetFileInfo(a)); - if (fileInfo is null || !fileInfo.Exists) - fileInfo = _embeddedProvider.GetFileInfo(subpath); - return fileInfo; - } - - public IChangeToken Watch(string filter) - { - var changeToken = FirstYielding(filter, static (f, p) => p.Watch(f)); - if (changeToken is null or NullChangeToken) - changeToken = _embeddedProvider.Watch(filter); - return changeToken; - } - - public void Dispose() - { - _staticFilesInDocsFolder?.Dispose(); - _staticWebFilesDuringDebug?.Dispose(); - } -}