Skip to content
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
2 changes: 1 addition & 1 deletion app/src/App/src/Articles/View.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 29 additions & 4 deletions app/src/App/src/Common/View.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) (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<Dictionary<string, string>>(File.ReadAllText manifestPath)
:> IReadOnlyDictionary<string, string>
with _ ->
Dictionary<string, string>() :> IReadOnlyDictionary<string, string>
else
Dictionary<string, string>() :> IReadOnlyDictionary<string, string>

let fingerprinted (path:string) =
resolveWithManifest manifest.Value path

module MiniIcon =
let github =
raw """
Expand Down Expand Up @@ -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}'}}"
Expand Down
2 changes: 1 addition & 1 deletion app/src/App/src/Index/View.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions app/src/App/src/Projects/View.fs
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ 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"
}
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"
Expand Down
45 changes: 45 additions & 0 deletions app/src/Build/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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())

Expand Down
55 changes: 39 additions & 16 deletions app/src/Tests/ViewTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,47 @@ module ViewTests
open App.Common.View
open Expecto
open FSharp.ViewEngine
open System.Collections.Generic
open type Html

[<Tests>]
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 "<title>Andy Meier</title>" "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<string, string>()
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<string, string>()
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 "<title>Andy Meier</title>" "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"
}
]
]
Loading