Skip to content

Commit ae99836

Browse files
authored
Add content hash as query string to static file paths (#758)
* Add content hash as query string to static file paths * Fix class name * Fix concurrency * Use instance directly instead of passing the function as lambda
1 parent 000ddd1 commit ae99836

File tree

6 files changed

+121
-78
lines changed

6 files changed

+121
-78
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Microsoft.Extensions.FileProviders;
6+
using Microsoft.Extensions.Primitives;
7+
8+
namespace Elastic.Markdown.IO;
9+
10+
public sealed class EmbeddedOrPhysicalFileProvider : IFileProvider, IDisposable
11+
{
12+
private readonly EmbeddedFileProvider _embeddedProvider = new(typeof(BuildContext).Assembly, "Elastic.Markdown._static");
13+
private readonly PhysicalFileProvider? _staticFilesInDocsFolder;
14+
15+
private readonly PhysicalFileProvider? _staticWebFilesDuringDebug;
16+
17+
public EmbeddedOrPhysicalFileProvider(BuildContext context)
18+
{
19+
var documentationStaticFiles = Path.Combine(context.DocumentationSourceDirectory.FullName, "_static");
20+
#if DEBUG
21+
// this attempts to serve files directly from their source rather than the embedded resources during development.
22+
// this allows us to change js/css files without restarting the webserver
23+
var solutionRoot = Paths.GetSolutionDirectory();
24+
if (solutionRoot != null)
25+
{
26+
27+
var debugWebFiles = Path.Combine(solutionRoot.FullName, "src", "Elastic.Markdown", "_static");
28+
_staticWebFilesDuringDebug = new PhysicalFileProvider(debugWebFiles);
29+
}
30+
#else
31+
_staticWebFilesDuringDebug = null;
32+
#endif
33+
if (context.ReadFileSystem.Directory.Exists(documentationStaticFiles))
34+
_staticFilesInDocsFolder = new PhysicalFileProvider(documentationStaticFiles);
35+
}
36+
37+
private T? FirstYielding<T>(string arg, Func<string, PhysicalFileProvider, T?> predicate) =>
38+
Yield(arg, predicate, _staticWebFilesDuringDebug) ?? Yield(arg, predicate, _staticFilesInDocsFolder);
39+
40+
private static T? Yield<T>(string arg, Func<string, PhysicalFileProvider, T?> predicate, PhysicalFileProvider? provider)
41+
{
42+
if (provider is null)
43+
return default;
44+
var result = predicate(arg, provider);
45+
return result ?? default;
46+
}
47+
48+
public IDirectoryContents GetDirectoryContents(string subpath)
49+
{
50+
var contents = FirstYielding(subpath, static (a, p) => p.GetDirectoryContents(a));
51+
if (contents is null || !contents.Exists)
52+
contents = _embeddedProvider.GetDirectoryContents(subpath);
53+
return contents;
54+
}
55+
56+
public IFileInfo GetFileInfo(string subpath)
57+
{
58+
var path = subpath.Replace($"{Path.DirectorySeparatorChar}_static", "");
59+
var fileInfo = FirstYielding(path, static (a, p) => p.GetFileInfo(a));
60+
if (fileInfo is null || !fileInfo.Exists)
61+
fileInfo = _embeddedProvider.GetFileInfo(subpath);
62+
return fileInfo;
63+
}
64+
65+
public IChangeToken Watch(string filter)
66+
{
67+
var changeToken = FirstYielding(filter, static (f, p) => p.Watch(f));
68+
if (changeToken is null or NullChangeToken)
69+
changeToken = _embeddedProvider.Watch(filter);
70+
return changeToken;
71+
}
72+
73+
public void Dispose()
74+
{
75+
_staticFilesInDocsFolder?.Dispose();
76+
_staticWebFilesDuringDebug?.Dispose();
77+
}
78+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Collections.Concurrent;
6+
7+
namespace Elastic.Markdown.IO;
8+
9+
public class StaticFileContentHashProvider(EmbeddedOrPhysicalFileProvider fileProvider)
10+
{
11+
private readonly ConcurrentDictionary<string, string> _contentHashes = [];
12+
13+
public string GetContentHash(string path)
14+
{
15+
if (_contentHashes.TryGetValue(path, out var contentHash))
16+
return contentHash;
17+
18+
var fileInfo = fileProvider.GetFileInfo(path);
19+
20+
if (!fileInfo.Exists)
21+
return string.Empty;
22+
23+
using var stream = fileInfo.CreateReadStream();
24+
using var sha = System.Security.Cryptography.SHA256.Create();
25+
var fullHash = sha.ComputeHash(stream);
26+
_contentHashes[path] = Convert.ToHexString(fullHash).ToLowerInvariant()[..16];
27+
return _contentHashes[path];
28+
}
29+
}

src/Elastic.Markdown/Slices/HtmlWriter.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
using Elastic.Markdown.IO.Navigation;
99
using Markdig.Syntax;
1010
using RazorSlices;
11+
using IFileInfo = System.IO.Abstractions.IFileInfo;
1112

1213
namespace Elastic.Markdown.Slices;
1314

1415
public class HtmlWriter(DocumentationSet documentationSet, IFileSystem writeFileSystem)
1516
{
1617
private DocumentationSet DocumentationSet { get; } = documentationSet;
18+
private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new(new EmbeddedOrPhysicalFileProvider(documentationSet.Build));
1719

1820
private async Task<string> RenderNavigation(string topLevelGroupId, MarkdownFile markdown, Cancel ctx = default)
1921
{
@@ -102,7 +104,8 @@ public async Task<string> RenderLayout(MarkdownFile markdown, MarkdownDocument d
102104
Applies = markdown.YamlFrontMatter?.AppliesTo,
103105
GithubEditUrl = editUrl,
104106
AllowIndexing = DocumentationSet.Build.AllowIndexing && !markdown.Hidden,
105-
Features = DocumentationSet.Configuration.Features
107+
Features = DocumentationSet.Configuration.Features,
108+
StaticFileContentHashProvider = StaticFileContentHashProvider
106109
});
107110
return await slice.RenderAsync(cancellationToken: ctx);
108111
}

src/Elastic.Markdown/Slices/Index.cshtml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
StaticUrlPathPrefix = Model.StaticUrlPathPrefix,
1717
GithubEditUrl = Model.GithubEditUrl,
1818
AllowIndexing = Model.AllowIndexing,
19-
Features = Model.Features
19+
Features = Model.Features,
20+
StaticFileContentHashProvider = Model.StaticFileContentHashProvider
2021
};
2122
}
2223
<section id="elastic-docs-v3">

src/Elastic.Markdown/Slices/_ViewModels.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class IndexViewModel
2828
public required ApplicableTo? Applies { get; init; }
2929
public required bool AllowIndexing { get; init; }
3030
public required FeatureFlags Features { get; init; }
31+
public required StaticFileContentHashProvider StaticFileContentHashProvider { get; init; }
3132
}
3233

3334
public class LayoutViewModel
@@ -68,10 +69,15 @@ public MarkdownFile[] Parents
6869

6970
public string Static(string path)
7071
{
71-
path = $"_static/{path.TrimStart('/')}";
72-
return $"{StaticUrlPathPrefix}/{path}";
72+
var staticPath = $"_static/{path.TrimStart('/')}";
73+
var contentHash = StaticFileContentHashProvider.GetContentHash(path.TrimStart('/'));
74+
return string.IsNullOrEmpty(contentHash)
75+
? $"{StaticUrlPathPrefix}/{staticPath}"
76+
: $"{StaticUrlPathPrefix}/{staticPath}?v={contentHash}";
7377
}
7478

79+
public required StaticFileContentHashProvider StaticFileContentHashProvider { get; init; }
80+
7581
public string Link(string path)
7682
{
7783
path = path.AsSpan().TrimStart('/').ToString();

src/docs-builder/Http/DocumentationWebHost.cs

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,9 @@
1313
using Microsoft.AspNetCore.Hosting;
1414
using Microsoft.AspNetCore.Http;
1515
using Microsoft.Extensions.DependencyInjection;
16-
using Microsoft.Extensions.FileProviders;
1716
using Microsoft.Extensions.Hosting;
1817
using Microsoft.Extensions.Logging;
19-
using Microsoft.Extensions.Primitives;
2018
using Westwind.AspNetCore.LiveReload;
21-
using IFileInfo = Microsoft.Extensions.FileProviders.IFileInfo;
2219

2320
namespace Documentation.Builder.Http;
2421

@@ -184,74 +181,3 @@ private static async Task<IResult> ServeDocumentationFile(ReloadableGeneratorSta
184181
}
185182
}
186183
}
187-
188-
189-
public sealed class EmbeddedOrPhysicalFileProvider : IFileProvider, IDisposable
190-
{
191-
private readonly EmbeddedFileProvider _embeddedProvider = new(typeof(BuildContext).Assembly, "Elastic.Markdown._static");
192-
private readonly PhysicalFileProvider? _staticFilesInDocsFolder;
193-
194-
private readonly PhysicalFileProvider? _staticWebFilesDuringDebug;
195-
196-
public EmbeddedOrPhysicalFileProvider(BuildContext context)
197-
{
198-
var documentationStaticFiles = Path.Combine(context.DocumentationSourceDirectory.FullName, "_static");
199-
#if DEBUG
200-
// this attempts to serve files directly from their source rather than the embedded resources during development.
201-
// this allows us to change js/css files without restarting the webserver
202-
var solutionRoot = Paths.GetSolutionDirectory();
203-
if (solutionRoot != null)
204-
{
205-
206-
var debugWebFiles = Path.Combine(solutionRoot.FullName, "src", "Elastic.Markdown", "_static");
207-
_staticWebFilesDuringDebug = new PhysicalFileProvider(debugWebFiles);
208-
}
209-
#else
210-
_staticWebFilesDuringDebug = null;
211-
#endif
212-
if (context.ReadFileSystem.Directory.Exists(documentationStaticFiles))
213-
_staticFilesInDocsFolder = new PhysicalFileProvider(documentationStaticFiles);
214-
}
215-
216-
private T? FirstYielding<T>(string arg, Func<string, PhysicalFileProvider, T?> predicate) =>
217-
Yield(arg, predicate, _staticWebFilesDuringDebug) ?? Yield(arg, predicate, _staticFilesInDocsFolder);
218-
219-
private static T? Yield<T>(string arg, Func<string, PhysicalFileProvider, T?> predicate, PhysicalFileProvider? provider)
220-
{
221-
if (provider is null)
222-
return default;
223-
var result = predicate(arg, provider);
224-
return result ?? default;
225-
}
226-
227-
public IDirectoryContents GetDirectoryContents(string subpath)
228-
{
229-
var contents = FirstYielding(subpath, static (a, p) => p.GetDirectoryContents(a));
230-
if (contents is null || !contents.Exists)
231-
contents = _embeddedProvider.GetDirectoryContents(subpath);
232-
return contents;
233-
}
234-
235-
public IFileInfo GetFileInfo(string subpath)
236-
{
237-
var path = subpath.Replace($"{Path.DirectorySeparatorChar}_static", "");
238-
var fileInfo = FirstYielding(path, static (a, p) => p.GetFileInfo(a));
239-
if (fileInfo is null || !fileInfo.Exists)
240-
fileInfo = _embeddedProvider.GetFileInfo(subpath);
241-
return fileInfo;
242-
}
243-
244-
public IChangeToken Watch(string filter)
245-
{
246-
var changeToken = FirstYielding(filter, static (f, p) => p.Watch(f));
247-
if (changeToken is null or NullChangeToken)
248-
changeToken = _embeddedProvider.Watch(filter);
249-
return changeToken;
250-
}
251-
252-
public void Dispose()
253-
{
254-
_staticFilesInDocsFolder?.Dispose();
255-
_staticWebFilesDuringDebug?.Dispose();
256-
}
257-
}

0 commit comments

Comments
 (0)