diff --git a/app/src/App/src/Articles/Handler.fs b/app/src/App/src/Articles/Handler.fs
index 06299bb..0db339b 100644
--- a/app/src/App/src/Articles/Handler.fs
+++ b/app/src/App/src/Articles/Handler.fs
@@ -21,7 +21,7 @@ let private getArticlesPage (services:Services) : HttpHandler =
do! pushUrl ds "/articles"
return Some ctx
else
- return! renderPage page "nav-articles" next ctx
+ return! renderPage services page "nav-articles" next ctx
}
let private getArticlePage (services:Services) (id:string) : HttpHandler =
@@ -39,7 +39,7 @@ let private getArticlePage (services:Services) (id:string) : HttpHandler =
do! pushUrl ds url
return Some ctx
else
- return! renderPage page "nav-articles" next ctx
+ return! renderPage services page "nav-articles" next ctx
| None ->
let page = notFoundPage
if ctx.IsDatastar then
@@ -47,7 +47,7 @@ let private getArticlePage (services:Services) (id:string) : HttpHandler =
do! patchElement ds page
return Some ctx
else
- return! renderPage page "nav-articles" next ctx
+ return! renderPage services page "nav-articles" next ctx
}
let handler (services:Services) : HttpHandler =
diff --git a/app/src/App/src/Common/Handler.fs b/app/src/App/src/Common/Handler.fs
index bfd1bea..ac3b817 100644
--- a/app/src/App/src/Common/Handler.fs
+++ b/app/src/App/src/Common/Handler.fs
@@ -1,6 +1,7 @@
module App.Common.Handler
open App.Common.View
+open App.ServiceRegistry
open Giraffe
open FSharp.ViewEngine
open Microsoft.AspNetCore.Http
@@ -21,9 +22,9 @@ let pushUrl (ds:IDatastarService) (url:string) = task {
do! ds.ExecuteScriptAsync(js)
}
-let renderPage (page:HtmlElement) (selectedNav:string) : HttpHandler =
+let renderPage (services:Services) (page:HtmlElement) (selectedNav:string) : HttpHandler =
fun next ctx -> task {
- let doc = Document.primary(page, selectedNav=selectedNav)
+ let doc = Document.primary(page, services.config.googleAnalytics.measurementId, selectedNav)
let html = Render.toHtmlDocString doc
return! htmlString html next ctx
}
diff --git a/app/src/App/src/Common/View.fs b/app/src/App/src/Common/View.fs
index 8ae280b..2c689cc 100644
--- a/app/src/App/src/Common/View.fs
+++ b/app/src/App/src/Common/View.fs
@@ -95,7 +95,7 @@ module ArticleCard =
_class "mt-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
a {
_href url
- _dataOn ("click", $"@get('{url}')")
+ _dataOn ("click__prevent", $"@get('{url}')")
_class "hover:text-emerald-600 dark:hover:text-emerald-400"
article'.title
}
@@ -266,7 +266,7 @@ module Page =
div { _id "page"; _class "min-h-screen bg-gray-100 dark:bg-gray-900"; page }
type Document =
- static member primary (page:HtmlElement, ?selectedNav:string) =
+ static member primary (page:HtmlElement, googleAnalyticsMeasurementId:string, ?selectedNav:string) =
let selectedNav = defaultArg selectedNav ""
html {
_lang "en"
@@ -275,6 +275,9 @@ type Document =
meta { _charset "UTF-8" }
meta { _name "viewport"; _content "width=device-width, initial-scale=1.0" }
script { js "let t=localStorage.getItem('theme');if(t==='dark'||(!t||t==='system')&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.classList.add('dark')}" }
+ 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" }
@@ -289,6 +292,41 @@ type Document =
page
Footer.primary
}
+ div {
+ _id "cookie-consent-banner"
+ _class "hidden fixed inset-x-0 bottom-0 z-50 border-t border-gray-300 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-gray-700 dark:bg-gray-900/95"
+ _role "dialog"
+ _ariaLive "polite"
+ div {
+ _class "mx-auto flex max-w-5xl flex-col gap-4 md:flex-row md:items-center md:justify-between"
+ div {
+ _class "max-w-3xl"
+ p {
+ _class "text-sm font-semibold text-gray-900 dark:text-gray-100"
+ "Analytics cookies"
+ }
+ p {
+ _class "mt-1 text-sm text-gray-600 dark:text-gray-300"
+ "I use Google Analytics to measure how this site is used. You can accept or reject analytics cookies, and the site will work either way."
+ }
+ }
+ div {
+ _class "flex flex-col gap-2 sm:flex-row"
+ button {
+ _type "button"
+ _class "inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:cursor-pointer dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
+ _onclick "setAnalyticsConsent('declined')"
+ "Reject"
+ }
+ button {
+ _type "button"
+ _class "inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 hover:cursor-pointer dark:bg-emerald-500 dark:hover:bg-emerald-400"
+ _onclick "setAnalyticsConsent('accepted')"
+ "Accept"
+ }
+ }
+ }
+ }
script { js "function getInitialTheme(){return localStorage.getItem('theme')||'system'};function applyTheme(t){var d=document.documentElement,isDark=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);d.classList.toggle('dark',isDark)};function setTheme(t){localStorage.setItem('theme',t);applyTheme(t)}" }
}
}
diff --git a/app/src/App/src/Config.fs b/app/src/App/src/Config.fs
index feba4a5..6e28718 100644
--- a/app/src/App/src/Config.fs
+++ b/app/src/App/src/Config.fs
@@ -17,11 +17,19 @@ module ServerConfig =
let load () =
{ url = Env.variableOrDefault "SERVER_URL" "https://localhost:5000" }
+type GoogleAnalyticsConfig =
+ { measurementId:string }
+
+module GoogleAnalyticsConfig =
+ let load () =
+ { measurementId = Env.variable "GOOGLE_ANALYTICS_MEASUREMENT_ID" }
+
type Config =
{ debug:bool
appName:string
server:ServerConfig
seq:SeqConfig
+ googleAnalytics:GoogleAnalyticsConfig
sqlite:Sqlite.Config
notion:Notion.Config }
@@ -31,5 +39,6 @@ module Config =
appName = "andymeier"
server = ServerConfig.load ()
seq = SeqConfig.load ()
+ googleAnalytics = GoogleAnalyticsConfig.load ()
sqlite = Sqlite.Config.load ()
notion = Notion.Config.load () }
diff --git a/app/src/App/src/Index/Handler.fs b/app/src/App/src/Index/Handler.fs
index 6e6fe3c..c1651ba 100644
--- a/app/src/App/src/Index/Handler.fs
+++ b/app/src/App/src/Index/Handler.fs
@@ -21,7 +21,7 @@ let private getHomePage (services:Services) : HttpHandler =
do! pushUrl ds "/"
return Some ctx
else
- return! renderPage page "nav-home" next ctx
+ return! renderPage services page "nav-home" next ctx
}
let handler (services:Services) : HttpHandler =
diff --git a/app/src/App/src/Projects/Handler.fs b/app/src/App/src/Projects/Handler.fs
index 365fd80..d8fb500 100644
--- a/app/src/App/src/Projects/Handler.fs
+++ b/app/src/App/src/Projects/Handler.fs
@@ -19,7 +19,7 @@ let private getPage (services:Services) : HttpHandler =
do! pushUrl ds "/projects"
return Some ctx
else
- return! renderPage page "nav-projects" next ctx
+ return! renderPage services page "nav-projects" next ctx
}
let handler (services:Services) : HttpHandler =
diff --git a/app/src/App/src/Services/Handler.fs b/app/src/App/src/Services/Handler.fs
index 9de51d2..06f453e 100644
--- a/app/src/App/src/Services/Handler.fs
+++ b/app/src/App/src/Services/Handler.fs
@@ -19,7 +19,7 @@ let private getPage (services:Services) : HttpHandler =
do! pushUrl ds "/services"
return Some ctx
else
- return! renderPage page "nav-services" next ctx
+ return! renderPage services page "nav-services" next ctx
}
let handler (services:Services) : HttpHandler =
diff --git a/app/src/Tests/Tests.fsproj b/app/src/Tests/Tests.fsproj
index 52545b9..41673b8 100644
--- a/app/src/Tests/Tests.fsproj
+++ b/app/src/Tests/Tests.fsproj
@@ -12,6 +12,7 @@
+
diff --git a/app/src/Tests/ViewTests.fs b/app/src/Tests/ViewTests.fs
new file mode 100644
index 0000000..5608288
--- /dev/null
+++ b/app/src/Tests/ViewTests.fs
@@ -0,0 +1,26 @@
+module ViewTests
+
+open App.Common.View
+open Expecto
+open FSharp.ViewEngine
+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"
+ }
+ ]
diff --git a/pulumi/src/config.ts b/pulumi/src/config.ts
index a4cf795..8cb38c2 100644
--- a/pulumi/src/config.ts
+++ b/pulumi/src/config.ts
@@ -38,3 +38,9 @@ export const notionConfig = {
articlesDatabaseId: rawNotionConfig.require('articlesDatabaseId'),
apiKey: rawNotionConfig.requireSecret('apiKey')
}
+
+const rawGoogleAnalyticsConfig = new pulumi.Config('googleAnalytics')
+
+export const googleAnalyticsConfig = {
+ measurementId: rawGoogleAnalyticsConfig.require('measurementId')
+}
diff --git a/pulumi/src/k8s/deployment.ts b/pulumi/src/k8s/deployment.ts
index c6105cf..e7b7dfa 100644
--- a/pulumi/src/k8s/deployment.ts
+++ b/pulumi/src/k8s/deployment.ts
@@ -17,6 +17,7 @@ let appSecret = new k8s.core.v1.Secret('app', {
SQLITE_PATH: '/data/app.db',
NOTION_ARTICLES_DATABASE_ID: config.notionConfig.articlesDatabaseId,
NOTION_API_KEY: config.notionConfig.apiKey,
+ GOOGLE_ANALYTICS_MEASUREMENT_ID: config.googleAnalyticsConfig.measurementId
}
}, { provider })