From b4eab644af3b772e52327fe6b55c943f7f91dcd4 Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Wed, 15 Apr 2026 11:34:26 -0400 Subject: [PATCH] use content-hashed static assets --- app/src/App/src/Articles/View.fs | 2 +- app/src/App/src/Common/View.fs | 33 ++++++++++++++++--- app/src/App/src/Index/View.fs | 2 +- app/src/App/src/Projects/View.fs | 4 +-- app/src/Build/Program.fs | 45 ++++++++++++++++++++++++++ app/src/Tests/ViewTests.fs | 55 ++++++++++++++++++++++---------- 6 files changed, 117 insertions(+), 24 deletions(-) diff --git a/app/src/App/src/Articles/View.fs b/app/src/App/src/Articles/View.fs index 3d23263..80fd70f 100644 --- a/app/src/App/src/Articles/View.fs +++ b/app/src/App/src/Articles/View.fs @@ -183,7 +183,7 @@ let articlePage (article':Article) = for el in Content.toHtml article'.blocks do el } } - script { _src "/scripts/prism.1.29.0.js" } + script { _src (Asset.fingerprinted "/scripts/prism.1.29.0.js") } script { js "function highlightCode(el){if(el?.querySelectorAll)Prism.highlightAllUnder(el)}" } } Page.primary content diff --git a/app/src/App/src/Common/View.fs b/app/src/App/src/Common/View.fs index 2c689cc..436ea04 100644 --- a/app/src/App/src/Common/View.fs +++ b/app/src/App/src/Common/View.fs @@ -2,10 +2,35 @@ module App.Common.View open FSharp.ViewEngine open Domain.Article +open System.Collections.Generic +open System.IO +open System.Text.Json open type Html open type Datastar open type Tailwind +module Asset = + let resolveWithManifest (manifest:IReadOnlyDictionary) (path:string) = + match manifest.TryGetValue path with + | true, resolvedPath -> resolvedPath + | false, _ -> path + + let private manifest = + lazy + let manifestPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "asset-manifest.json") + + if File.Exists(manifestPath) then + try + JsonSerializer.Deserialize>(File.ReadAllText manifestPath) + :> IReadOnlyDictionary + with _ -> + Dictionary() :> IReadOnlyDictionary + else + Dictionary() :> IReadOnlyDictionary + + let fingerprinted (path:string) = + resolveWithManifest manifest.Value path + module MiniIcon = let github = raw """ @@ -278,10 +303,10 @@ type Document = script { js $"window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){{dataLayer.push(arguments);}};gtag('consent','default',{{analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}});window.loadGoogleAnalytics=window.loadGoogleAnalytics||function(){{if(window.__gaLoaded)return;window.__gaLoaded=true;var s=document.createElement('script');s.async=true;s.src='https://www.googletagmanager.com/gtag/js?id={googleAnalyticsMeasurementId}';document.head.appendChild(s);gtag('js',new Date());gtag('config','{googleAnalyticsMeasurementId}');}};window.applyAnalyticsConsent=window.applyAnalyticsConsent||function(v){{if(v==='accepted'){{gtag('consent','update',{{analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}});window.loadGoogleAnalytics();}}else{{gtag('consent','update',{{analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}});}}}};window.setAnalyticsConsent=window.setAnalyticsConsent||function(v){{localStorage.setItem('analytics-consent',v);window.applyAnalyticsConsent(v);var b=document.getElementById('cookie-consent-banner');if(b)b.classList.add('hidden');}};document.addEventListener('DOMContentLoaded',function(){{var saved=localStorage.getItem('analytics-consent');var banner=document.getElementById('cookie-consent-banner');if(saved==='accepted'||saved==='declined'){{window.applyAnalyticsConsent(saved);if(banner)banner.classList.add('hidden');}}else if(banner){{banner.classList.remove('hidden');}}}});" } - link { _href "/css/compiled.css"; _rel "stylesheet" } - link { _href "/css/prism.css"; _rel "stylesheet" } - script { _type "module"; _src "/scripts/tailwindplus-elements.1.js" } - script { _type "module"; _src "/scripts/datastar.1.0.0-RC.6.js" } + link { _href (Asset.fingerprinted "/css/compiled.css"); _rel "stylesheet" } + link { _href (Asset.fingerprinted "/css/prism.css"); _rel "stylesheet" } + script { _type "module"; _src (Asset.fingerprinted "/scripts/tailwindplus-elements.1.js") } + script { _type "module"; _src (Asset.fingerprinted "/scripts/datastar.1.0.0-RC.6.js") } } body { _dataSignals $"{{selectedNav: '{selectedNav}'}}" diff --git a/app/src/App/src/Index/View.fs b/app/src/App/src/Index/View.fs index 514eab2..d88fd73 100644 --- a/app/src/App/src/Index/View.fs +++ b/app/src/App/src/Index/View.fs @@ -30,7 +30,7 @@ let homePage (recentArticles:Article list) = _class "grid gap-4 grid-cols-1 md:grid-cols-2" div { _class "flex flex-col items-center" - img { _class "w-72 aspect-square rounded-full mb-4"; _src "/images/profile.jpg" } + img { _class "w-72 aspect-square rounded-full mb-4"; _src (Asset.fingerprinted "/images/profile.jpg") } div { _class "flex justify-center space-x-2" a { _class "p-2 text-gray-600 rounded-full hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"; _href "https://github.com/meiermade"; MiniIcon.github } diff --git a/app/src/App/src/Projects/View.fs b/app/src/App/src/Projects/View.fs index 6bd6fc8..190b801 100644 --- a/app/src/App/src/Projects/View.fs +++ b/app/src/App/src/Projects/View.fs @@ -40,7 +40,7 @@ let page = projectCard { title = "FSharp.ViewEngine" description = "A minimal, fast view engine for F# with a clean computation-expression DSL." - logoSrc = "/images/fsharpviewengine.svg" + logoSrc = Asset.fingerprinted "/images/fsharpviewengine.svg" logoAlt = "FSharp.ViewEngine logo" href = "https://fsharpviewengine.meiermade.com" label = "fsharpviewengine.meiermade.com" @@ -48,7 +48,7 @@ let page = projectCard { title = "Geldos" description = "A financial operating system for building modern finance and accounting workflows." - logoSrc = "/images/geldos.svg" + logoSrc = Asset.fingerprinted "/images/geldos.svg" logoAlt = "Geldos logo" href = "https://geldos.com" label = "geldos.com" diff --git a/app/src/Build/Program.fs b/app/src/Build/Program.fs index 0a3b82b..bee0707 100644 --- a/app/src/Build/Program.fs +++ b/app/src/Build/Program.fs @@ -3,6 +3,8 @@ open Fake.Core.TargetOperators open Fake.IO open Fake.IO.FileSystemOperators open System +open System.IO +open System.Security.Cryptography open System.Text.Json open System.Threading.Tasks @@ -16,6 +18,47 @@ Environment.GetCommandLineArgs() let srcDir = Path.getDirectory __SOURCE_DIRECTORY__ let rootDir = Path.getDirectory srcDir let appDir = srcDir "App" +let outDir = appDir "out" +let wwwrootDir = outDir "wwwroot" +let hashedAssetExtensions = + set [ ".css"; ".gif"; ".ico"; ".jpeg"; ".jpg"; ".js"; ".png"; ".svg"; ".webp"; ".woff"; ".woff2" ] + +let toWebPath (rootDir:string) (filePath:string) = + let relativePath = Path.GetRelativePath(rootDir, filePath).Replace(Path.DirectorySeparatorChar, '/') + "/" + relativePath + +let fingerprintedFilePath (filePath:string) (hash:string) = + let dir = Path.GetDirectoryName(filePath) + let name = Path.GetFileNameWithoutExtension(filePath) + let ext = Path.GetExtension(filePath) + Path.Combine(dir, $"{name}.{hash}{ext}") + +let hashFileContents (filePath:string) = + use stream = File.OpenRead(filePath) + use sha256 = SHA256.Create() + sha256.ComputeHash(stream) + |> Convert.ToHexString + |> fun hash -> hash.ToLowerInvariant().Substring(0, 12) + +let fingerprintAssets (rootDir:string) = + let files = + Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories) + |> Seq.filter (fun path -> hashedAssetExtensions.Contains(Path.GetExtension(path).ToLowerInvariant())) + |> Seq.sort + |> Seq.toList + + let manifest = + files + |> Seq.map (fun path -> + let hash = hashFileContents path + let fingerprintedPath = fingerprintedFilePath path hash + File.Copy(path, fingerprintedPath, true) + toWebPath rootDir path, toWebPath rootDir fingerprintedPath) + |> Map.ofSeq + + let manifestPath = Path.Combine(rootDir, "asset-manifest.json") + let json = JsonSerializer.Serialize(manifest) + File.WriteAllText(manifestPath, json) let inline (==>!) x y = x ==> y |> ignore @@ -75,12 +118,14 @@ Target.create "Test" <| fun _ -> tests.Wait() Target.create "Publish" <| fun _ -> + Shell.cleanDir outDir let publish = exec "dotnet" appDir [ "publish" "--output"; "./out" "--self-contained"; "false" ] publish.Wait() + fingerprintAssets wwwrootDir Target.create "Default" (fun _ -> Target.listAvailable()) diff --git a/app/src/Tests/ViewTests.fs b/app/src/Tests/ViewTests.fs index 5608288..5455b17 100644 --- a/app/src/Tests/ViewTests.fs +++ b/app/src/Tests/ViewTests.fs @@ -3,24 +3,47 @@ module ViewTests open App.Common.View open Expecto open FSharp.ViewEngine +open System.Collections.Generic open type Html [] let tests = - testList "Document" [ - test "includes consent banner and delayed google analytics loading" { - let doc = Document.primary(div { "Hello" }, "G-TEST123", "nav-home") - - let html = Render.toHtmlDocString doc - - Expect.stringContains html "Andy Meier" "Expected page to render" - Expect.stringContains html "selectedNav: 'nav-home'" "Expected nav signal to render" - Expect.stringContains html "cookie-consent-banner" "Expected consent banner" - Expect.stringContains html "Reject" "Expected reject action" - Expect.stringContains html "Accept" "Expected accept action" - Expect.stringContains html "gtag('consent','default',{analytics_storage:'denied'" "Expected denied-by-default consent mode" - Expect.stringContains html "localStorage.setItem('analytics-consent',v)" "Expected consent to be persisted" - Expect.stringContains html "https://www.googletagmanager.com/gtag/js?id=G-TEST123" "Expected deferred gtag script source" - Expect.stringContains html "gtag('config','G-TEST123');" "Expected GA config call after consent" - } + testList "View" [ + testList "Asset" [ + test "uses fingerprinted path from manifest when present" { + let manifest = Dictionary() + manifest.Add("/css/compiled.css", "/css/compiled.abc123.css") + + let path = Asset.resolveWithManifest manifest "/css/compiled.css" + + Expect.equal path "/css/compiled.abc123.css" "Expected manifest fingerprinted path" + } + + test "falls back to original path when manifest entry is missing" { + let manifest = Dictionary() + manifest.Add("/css/other.css", "/css/other.abc123.css") + + let path = Asset.resolveWithManifest manifest "/css/compiled.css" + + Expect.equal path "/css/compiled.css" "Expected original path when manifest entry is missing" + } + ] + + testList "Document" [ + test "includes consent banner and delayed google analytics loading" { + let doc = Document.primary(div { "Hello" }, "G-TEST123", "nav-home") + + let html = Render.toHtmlDocString doc + + Expect.stringContains html "Andy Meier" "Expected page to render" + Expect.stringContains html "selectedNav: 'nav-home'" "Expected nav signal to render" + Expect.stringContains html "cookie-consent-banner" "Expected consent banner" + Expect.stringContains html "Reject" "Expected reject action" + Expect.stringContains html "Accept" "Expected accept action" + Expect.stringContains html "gtag('consent','default',{analytics_storage:'denied'" "Expected denied-by-default consent mode" + Expect.stringContains html "localStorage.setItem('analytics-consent',v)" "Expected consent to be persisted" + Expect.stringContains html "https://www.googletagmanager.com/gtag/js?id=G-TEST123" "Expected deferred gtag script source" + Expect.stringContains html "gtag('config','G-TEST123');" "Expected GA config call after consent" + } + ] ]