Skip to content

Add content hash as query string to static file paths #758

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/Elastic.Markdown/IO/EmbeddedOrPhysicalFileProvider.cs
Original file line number Diff line number Diff line change
@@ -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<T>(string arg, Func<string, PhysicalFileProvider, T?> predicate) =>
Yield(arg, predicate, _staticWebFilesDuringDebug) ?? Yield(arg, predicate, _staticFilesInDocsFolder);

private static T? Yield<T>(string arg, Func<string, PhysicalFileProvider, T?> 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();
}
}
29 changes: 29 additions & 0 deletions src/Elastic.Markdown/IO/StaticFileContentHashProvider.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> _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];
}
}
5 changes: 4 additions & 1 deletion src/Elastic.Markdown/Slices/HtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> RenderNavigation(string topLevelGroupId, MarkdownFile markdown, Cancel ctx = default)
{
Expand Down Expand Up @@ -102,7 +104,8 @@ public async Task<string> 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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Elastic.Markdown/Slices/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
StaticUrlPathPrefix = Model.StaticUrlPathPrefix,
GithubEditUrl = Model.GithubEditUrl,
AllowIndexing = Model.AllowIndexing,
Features = Model.Features
Features = Model.Features,
StaticFileContentHashProvider = Model.StaticFileContentHashProvider
};
}
<section id="elastic-docs-v3">
Expand Down
10 changes: 8 additions & 2 deletions src/Elastic.Markdown/Slices/_ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
74 changes: 0 additions & 74 deletions src/docs-builder/Http/DocumentationWebHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -184,74 +181,3 @@ private static async Task<IResult> 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<T>(string arg, Func<string, PhysicalFileProvider, T?> predicate) =>
Yield(arg, predicate, _staticWebFilesDuringDebug) ?? Yield(arg, predicate, _staticFilesInDocsFolder);

private static T? Yield<T>(string arg, Func<string, PhysicalFileProvider, T?> 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();
}
}