From 20c67b80eed5b4edb1b04808a1f06e5762c0e844 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Tue, 7 Jul 2026 03:02:34 +0800 Subject: [PATCH] feat(templates): full bn chrome sync from cms + VersionedAssetURL hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cms is now the authoring source for the bn chrome (head/toolbar/engagement); this repo carries a mechanically-synced copy solely for guest-side plugin templates (cms `make sync-templates`, drift gated by check-safety check 31). head.templ picks up everything it had drifted behind on (RFG-parity favicon head, analytics/web-vitals beacons, custom-scripts placement, bnApplyTheme) and is now SDK-clean: asset versioning goes through the new bn.VersionedAssetURL hook (identity default; the CMS host wires it to its internal assets registry). HeadData is shape-compatible with existing plugin usage; BrandingData/SiteSettingsData internals changed — plugins see that at compile time on their next core bump. Spec: cms docs/superpowers/specs/2026-07-07-bn-chrome-single-source-design.md Co-Authored-By: Claude Fable 5 --- templates/bn/asset_hooks.go | 11 + templates/bn/engagement_templ.go | 2 +- templates/bn/head.templ | 236 +++++- templates/bn/head_templ.go | 1225 +++++++++++++++++++----------- templates/bn/toolbar_templ.go | 64 +- 5 files changed, 1028 insertions(+), 510 deletions(-) create mode 100644 templates/bn/asset_hooks.go diff --git a/templates/bn/asset_hooks.go b/templates/bn/asset_hooks.go new file mode 100644 index 0000000..20525df --- /dev/null +++ b/templates/bn/asset_hooks.go @@ -0,0 +1,11 @@ +package bn + +// VersionedAssetURL resolves an asset URL to a cache-versioned form. The +// default is the identity function; the CMS host wires it to +// internal/assets.VersionedURL at startup so plugin stylesheet links get +// content-hash ?v= params. Guest-side (wasm) renders keep the identity +// default — asset hashing is a host concern. +// +// This file is part of the cms→core template sync set (make sync-templates); +// it must stay SDK-clean (no cms imports). +var VersionedAssetURL = func(url string) string { return url } diff --git a/templates/bn/engagement_templ.go b/templates/bn/engagement_templ.go index 9c0ca5c..6a9d669 100644 --- a/templates/bn/engagement_templ.go +++ b/templates/bn/engagement_templ.go @@ -62,7 +62,7 @@ func EngagementScript(config EngagementConfig) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(engagementConfigJSON(config)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bn/engagement.templ`, Line: 30, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { diff --git a/templates/bn/head.templ b/templates/bn/head.templ index f2a89e4..cfd2e26 100644 --- a/templates/bn/head.templ +++ b/templates/bn/head.templ @@ -16,34 +16,69 @@ func resolveMediaURL(url string) string { return url } +// canonicalURL resolves the canonical link. An admin-set value wins (made absolute +// against the page's own origin when it is root-relative); otherwise the page's own +// absolute URL is used as a self-referencing canonical. Returns "" only when neither +// is available. +func canonicalURL(canonical, pageURL string) string { + if canonical == "" { + return pageURL + } + if strings.HasPrefix(canonical, "http://") || strings.HasPrefix(canonical, "https://") { + return canonical + } + if strings.HasPrefix(canonical, "/") && pageURL != "" { + if i := strings.Index(pageURL, "://"); i >= 0 { + if j := strings.IndexByte(pageURL[i+3:], '/'); j >= 0 { + return pageURL[:i+3+j] + canonical + } + return pageURL + canonical + } + } + return canonical +} + // BrandingData contains generated favicon/icon URLs type BrandingData struct { - FaviconICO string // /brand/{id}/favicon.ico - Favicon16 string // /brand/{id}/favicon-16x16.png - Favicon32 string // /brand/{id}/favicon-32x32.png - AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180) - Android192 string // /brand/{id}/android-chrome-192x192.png - Android512 string // /brand/{id}/android-chrome-512x512.png - Maskable512 string // /brand/{id}/maskable-512x512.png - Master1024 string // /brand/{id}/icon-1024x1024.png - ManifestURL string // /brand/{id}/site.webmanifest + FaviconICO string // /.brand/{id}/favicon.ico + Favicon16 string // /.brand/{id}/favicon-16x16.png + Favicon32 string // /.brand/{id}/favicon-32x32.png + Favicon96 string // /.brand/{id}/favicon-96x96.png + AppleTouchIcon string // /.brand/{id}/apple-touch-icon.png (180x180) + Android192 string // /.brand/{id}/android-chrome-192x192.png + Android512 string // /.brand/{id}/android-chrome-512x512.png + Maskable512 string // /.brand/{id}/maskable-512x512.png + Master1024 string // /.brand/{id}/icon-1024x1024.png + ManifestURL string // /.brand/{id}/site.webmanifest + MaskIcon string // /.brand/{id}/mask-icon.svg + MSTile150 string // /.brand/{id}/mstile-150x150.png + BrowserConfig string // /.brand/{id}/browserconfig.xml ThemeColor string SVG string // Optional SVG pass-through IsGenerated bool } +// CustomScriptEntry represents a single custom script with placement control +type CustomScriptEntry struct { + Name string + Placement string // "head" or "body" + Code string + Enabled bool +} + // SiteSettingsData contains site-wide settings for head injection type SiteSettingsData struct { - Title string - Description string - Favicon string - Logo string - LogoAlt string - AppleTouchIcon string - GoogleAnalyticsID string - CustomHeadScripts string - CustomBodyScripts string - MetaDescription string + Title string + Description string + Favicon string + Logo string + LogoAlt string + AppleTouchIcon string + GoogleAnalyticsID string + CustomScripts []CustomScriptEntry + GoogleAnalyticsEnabled bool + GoogleAnalyticsExplicitlySet bool // true if the enabled flag was explicitly set in JSON (not nil) + MetaDescription string DefaultOGImage string TwitterHandle string OGSiteName string @@ -67,6 +102,10 @@ type PageMeta struct { TwitterImage string // Twitter card image URL CanonicalURL string // Canonical URL for this page RobotsDirective string // Robots directive (e.g., "noindex, nofollow") + OGType string // Open Graph type ("article" for posts, else "website") + PageURL string // Absolute URL of this page (og:url + auto-canonical) + ArticlePublishedTime string // ISO 8601 published time (articles only) + ArticleAuthor string // Author name (articles only) } // HeadData contains all data needed to render the element @@ -159,15 +198,75 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData { } // Analytics + var legacyHeadScripts, legacyBodyScripts string if analyticsData, ok := siteData["analytics"].(map[string]any); ok { if v, ok := analyticsData["google_analytics_id"].(string); ok { settings.GoogleAnalyticsID = v } + // Read GA enabled flag — if present, use it; if absent but ID is set, default to enabled (backward compat) + if v, ok := analyticsData["google_analytics_enabled"].(bool); ok { + settings.GoogleAnalyticsEnabled = v + settings.GoogleAnalyticsExplicitlySet = true + } else if settings.GoogleAnalyticsID != "" { + settings.GoogleAnalyticsEnabled = true + } + // Preserve legacy flat fields for fallback if v, ok := analyticsData["custom_head_scripts"].(string); ok { - settings.CustomHeadScripts = v + legacyHeadScripts = v } if v, ok := analyticsData["custom_body_scripts"].(string); ok { - settings.CustomBodyScripts = v + legacyBodyScripts = v + } + } + + // Structured custom scripts + if scriptsArr, ok := siteData["custom_scripts"].([]any); ok && len(scriptsArr) > 0 { + for _, item := range scriptsArr { + entry, ok := item.(map[string]any) + if !ok { + continue + } + cs := CustomScriptEntry{} + if v, ok := entry["name"].(string); ok { + cs.Name = v + } + // Placement: proto enum stored as float64 (1=head, 2=body) or string + switch p := entry["placement"].(type) { + case float64: + if p == 1 { + cs.Placement = "head" + } else if p == 2 { + cs.Placement = "body" + } + case string: + cs.Placement = p + } + if v, ok := entry["code"].(string); ok { + cs.Code = v + } + if v, ok := entry["enabled"].(bool); ok { + cs.Enabled = v + } + settings.CustomScripts = append(settings.CustomScripts, cs) + } + } + // Fallback: if no structured scripts, migrate old flat fields + if len(settings.CustomScripts) == 0 { + if legacyHeadScripts != "" { + settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{ + Name: "Legacy Head Scripts", + Placement: "head", + Code: legacyHeadScripts, + Enabled: true, + }) + } + if legacyBodyScripts != "" { + settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{ + Name: "Legacy Body Scripts", + Placement: "body", + Code: legacyBodyScripts, + Enabled: true, + }) } } @@ -209,6 +308,18 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData { if v, ok := brandingData["svg"].(string); ok { settings.Branding.SVG = v } + if v, ok := brandingData["favicon_96"].(string); ok { + settings.Branding.Favicon96 = v + } + if v, ok := brandingData["mask_icon"].(string); ok { + settings.Branding.MaskIcon = v + } + if v, ok := brandingData["mstile_150"].(string); ok { + settings.Branding.MSTile150 = v + } + if v, ok := brandingData["browserconfig"].(string); ok { + settings.Branding.BrowserConfig = v + } } // Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon) @@ -276,6 +387,18 @@ func ParsePageMeta(doc map[string]any) PageMeta { if v, ok := doc["robotsDirective"].(string); ok { meta.RobotsDirective = v } + if v, ok := doc["ogType"].(string); ok { + meta.OGType = v + } + if v, ok := doc["pageUrl"].(string); ok { + meta.PageURL = v + } + if v, ok := doc["articlePublishedTime"].(string); ok { + meta.ArticlePublishedTime = v + } + if v, ok := doc["articleAuthor"].(string); ok { + meta.ArticleAuthor = v + } return meta } @@ -310,6 +433,12 @@ func ParseToolbarData(data map[string]any) ToolbarData { if v, ok := data["preview_mode"].(string); ok { toolbar.PreviewMode = v } + if v, ok := data["hide_preview_toggle"].(bool); ok { + toolbar.HidePreviewToggle = v + } + if v, ok := data["animate"].(bool); ok { + toolbar.Animate = v + } if v, ok := data["position"].(string); ok { toolbar.Position = v } @@ -390,15 +519,30 @@ templ Head(data HeadData) { @themeInitScript(data.ThemeMode) if data.Settings.Branding.IsGenerated { - // Use generated brand assets - - + // Use generated brand assets (modern RFG-parity set; SVG first) + if data.Settings.Branding.SVG != "" { + + } + if data.Settings.Branding.Favicon96 != "" { + + } + + + if data.Settings.Branding.MaskIcon != "" { + + } if data.Settings.Branding.ThemeColor != "" { } + if data.Settings.Branding.BrowserConfig != "" { + + } + if data.Title != "" { + + } } else { // Legacy fallback - manual favicon uploads if data.Settings.Favicon != "" { @@ -418,9 +562,9 @@ templ Head(data HeadData) { } - // Canonical URL (page-level only, no site default) - if data.PageMeta.CanonicalURL != "" { - + // Canonical URL: admin override (resolved to absolute) → auto self-canonical (M8) + if canonical := canonicalURL(data.PageMeta.CanonicalURL, data.PageMeta.PageURL); canonical != "" { + } // Robots directive (page-level only - defaults to index,follow if not set) @@ -459,8 +603,25 @@ templ Head(data HeadData) { } - // og:type - default to website - + // og:type: article for posts, website otherwise (M5) + if data.PageMeta.OGType != "" { + + } else { + + } + // og:url: absolute URL of this page (M6) + if data.PageMeta.PageURL != "" { + + } + // article:* metadata for posts (M5) + if data.PageMeta.OGType == "article" { + if data.PageMeta.ArticlePublishedTime != "" { + + } + if data.PageMeta.ArticleAuthor != "" { + + } + } // === TWITTER CARD META TAGS === if data.Settings.TwitterHandle != "" { @@ -512,7 +673,7 @@ templ Head(data HeadData) { } - if data.Settings.GoogleAnalyticsID != "" { + if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.PageMeta.MetaDescription != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Settings.MetaDescription != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.PageMeta.TwitterImage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.PageMeta.OGImage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Settings.DefaultOGImage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Settings.LLMsTxtEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Settings.RSSFeedURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1160,20 +1500,20 @@ func Head(data HeadData) templ.Component { return templ_7745c5c3_Err } for _, style := range data.PluginStyles { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1184,10 +1524,12 @@ func Head(data HeadData) templ.Component { return templ_7745c5c3_Err } } - if data.Settings.CustomHeadScripts != "" { - templ_7745c5c3_Err = templ.Raw(data.Settings.CustomHeadScripts).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for _, script := range data.Settings.CustomScripts { + if script.Enabled && script.Placement == "head" { + templ_7745c5c3_Err = templ.Raw(script.Code).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } if data.StructuredData != "" { @@ -1200,7 +1542,7 @@ func Head(data HeadData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1231,28 +1573,28 @@ func AdminBypassBanner(settings SiteSettingsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var53 := templ.GetChildren(ctx) + if templ_7745c5c3_Var53 == nil { + templ_7745c5c3_Var53 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if settings.AdminBypassMode != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if settings.AdminBypassMode == "maintenance" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Maintenance Mode Active — You're seeing the normal site because you're logged in as admin. Manage") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "Maintenance Mode Active — You're seeing the normal site because you're logged in as admin. Manage") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if settings.AdminBypassMode == "coming_soon" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "Coming Soon Mode Active — You're seeing the normal site because you're logged in as admin. Manage") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "Coming Soon Mode Active — You're seeing the normal site because you're logged in as admin. Manage") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1278,26 +1620,28 @@ func BodyEnd(settings SiteSettingsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var44 := templ.GetChildren(ctx) - if templ_7745c5c3_Var44 == nil { - templ_7745c5c3_Var44 = templ.NopComponent + templ_7745c5c3_Var54 := templ.GetChildren(ctx) + if templ_7745c5c3_Var54 == nil { + templ_7745c5c3_Var54 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = recordValidationCall(ctx, "BodyEnd").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if settings.CustomBodyScripts != "" { - templ_7745c5c3_Err = templ.Raw(settings.CustomBodyScripts).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for _, script := range settings.CustomScripts { + if script.Enabled && script.Placement == "body" { + templ_7745c5c3_Err = templ.Raw(script.Code).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } templ_7745c5c3_Err = AdminEditorToolbar(settings.Toolbar).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templ_7745c5c3_Var44.Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = templ_7745c5c3_Var54.Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1326,9 +1670,9 @@ func themeStyle(css string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var45 := templ.GetChildren(ctx) - if templ_7745c5c3_Var45 == nil { - templ_7745c5c3_Var45 = templ.NopComponent + templ_7745c5c3_Var55 := templ.GetChildren(ctx) + if templ_7745c5c3_Var55 == nil { + templ_7745c5c3_Var55 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = themeStyleComponent(css).Render(ctx, templ_7745c5c3_Buffer) @@ -1362,25 +1706,25 @@ func analyticsScript(nonce string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var46 := templ.GetChildren(ctx) - if templ_7745c5c3_Var46 == nil { - templ_7745c5c3_Var46 = templ.NopComponent + templ_7745c5c3_Var56 := templ.GetChildren(ctx) + if templ_7745c5c3_Var56 == nil { + templ_7745c5c3_Var56 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\">\n\t\t(function(){\n\t\t\t// Don't track admin pages\n\t\t\tif(location.pathname.startsWith('/admin'))return;\n\n\t\t\t// CRITICAL: Capture nonce immediately while document.currentScript is valid\n\t\t\t// document.currentScript is only available during script parsing, not later\n\t\t\tvar _nonce = document.currentScript ? document.currentScript.getAttribute('data-nonce') : '';\n\n\t\t\t// Persistent session ID (survives page navigation within session)\n\t\t\tvar sid=sessionStorage.getItem('_bn_sid');\n\t\t\tif(!sid){sid=crypto.randomUUID();sessionStorage.setItem('_bn_sid',sid);}\n\n\t\t\t// ============================================================\n\t\t\t// Advanced Bot Detection - Client-Side Signals\n\t\t\t// See docs/BOT_DETECTION.md for full documentation\n\t\t\t// ============================================================\n\t\t\tvar botSignals={},botScore=0;\n\t\t\ttry{\n\t\t\t\t// 1. WebDriver property (30 points) - automation frameworks set this\n\t\t\t\tbotSignals.webdriver=!!navigator.webdriver;\n\t\t\t\tif(botSignals.webdriver)botScore+=30;\n\n\t\t\t\t// 2. CDP Detection (25 points) - detects Puppeteer/Playwright/Selenium\n\t\t\t\tbotSignals.cdp=false;\n\t\t\t\ttry{\n\t\t\t\t\tvar e=new Error();\n\t\t\t\t\tObject.defineProperty(e,'stack',{get:function(){botSignals.cdp=true;}});\n\t\t\t\t\tconsole.debug(e);\n\t\t\t\t\tif(botSignals.cdp)botScore+=25;\n\t\t\t\t}catch(x){}\n\n\t\t\t\t// 3. Plugin count (15 points) - headless often has no plugins\n\t\t\t\tbotSignals.plugins=navigator.plugins?navigator.plugins.length:0;\n\t\t\t\tif(botSignals.plugins===0)botScore+=15;\n\n\t\t\t\t// 4. Languages (15 points) - headless often has empty languages\n\t\t\t\tbotSignals.languages=navigator.languages?navigator.languages.length:0;\n\t\t\t\tif(botSignals.languages===0)botScore+=15;\n\n\t\t\t\t// 5. Chrome runtime (10 points) - real Chrome has chrome.runtime\n\t\t\t\tbotSignals.chromeRuntime=!!(window.chrome&&window.chrome.runtime);\n\t\t\t\tif(window.chrome&&!window.chrome.runtime)botScore+=10;\n\n\t\t\t\t// 6. WebGL renderer (20 points) - headless uses SwiftShader/llvmpipe\n\t\t\t\ttry{\n\t\t\t\t\tvar c=document.createElement('canvas');\n\t\t\t\t\tvar gl=c.getContext('webgl')||c.getContext('experimental-webgl');\n\t\t\t\t\tif(gl){\n\t\t\t\t\t\tvar dbg=gl.getExtension('WEBGL_debug_renderer_info');\n\t\t\t\t\t\tif(dbg){\n\t\t\t\t\t\t\tbotSignals.webglRenderer=gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)||'';\n\t\t\t\t\t\t\tvar r=botSignals.webglRenderer.toLowerCase();\n\t\t\t\t\t\t\tif(r.includes('swiftshader')||r.includes('llvmpipe')||r.includes('software'))botScore+=20;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}catch(x){}\n\n\t\t\t\t// 7. Permission anomalies (15 points) - inconsistent API states\n\t\t\t\tif(window.Notification&&navigator.permissions){\n\t\t\t\t\tnavigator.permissions.query({name:'notifications'}).then(function(p){\n\t\t\t\t\t\tif(p.state==='denied'&&Notification.permission==='default'){\n\t\t\t\t\t\t\tbotSignals.permissionAnomaly=true;\n\t\t\t\t\t\t\tbotScore+=15;\n\t\t\t\t\t\t}\n\t\t\t\t\t}).catch(function(){});\n\t\t\t\t}\n\n\t\t\t\t// 8. Headless in Client Hints (20 points)\n\t\t\t\tif(navigator.userAgentData&&navigator.userAgentData.getHighEntropyValues){\n\t\t\t\t\tnavigator.userAgentData.getHighEntropyValues(['fullVersionList','platform']).then(function(h){\n\t\t\t\t\t\tif(h.brands&&h.brands.some(function(b){return b.brand.toLowerCase().includes('headless');})){\n\t\t\t\t\t\t\tbotSignals.headlessHints=true;\n\t\t\t\t\t\t\tbotScore+=20;\n\t\t\t\t\t\t}\n\t\t\t\t\t}).catch(function(){});\n\t\t\t\t}\n\n\t\t\t\t// 9. Automation variables (10 points each)\n\t\t\t\tif(window._phantom||window.__nightmare||window.callPhantom){botSignals.phantomjs=true;botScore+=10;}\n\t\t\t\tif(window.__selenium_unwrapped||window.__webdriver_evaluate||document.__selenium_evaluate){botSignals.selenium=true;botScore+=10;}\n\t\t\t\tif(window.__fxdriver_evaluate||window.__webdriver_script_fn){botSignals.selenium=true;botScore+=10;}\n\n\t\t\t\t// 10. Broken image dimensions (10 points) - headless often fails to render\n\t\t\t\ttry{\n\t\t\t\t\tvar img=new Image();\n\t\t\t\t\timg.src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';\n\t\t\t\t\tif(img.height===0){botSignals.brokenImage=true;botScore+=10;}\n\t\t\t\t}catch(x){}\n\n\t\t\t\t// Cap at 100\n\t\t\t\tbotScore=Math.min(botScore,100);\n\t\t\t\tbotSignals.score=botScore;\n\t\t\t}catch(x){botSignals.error=x.message;}\n\n\t\t\t// ============================================================\n\t\t\t// Web Vitals collection\n\t\t\t// ============================================================\n\t\t\tvar _lcp=0,_fcp=0,_cls=0,_ttfb=0;\n\t\t\tif('PerformanceObserver'in window){\n\t\t\t\ttry{\n\t\t\t\t\tnew PerformanceObserver(function(l){var e=l.getEntries().pop();if(e)_lcp=Math.round(e.startTime);}).observe({type:'largest-contentful-paint',buffered:true});\n\t\t\t\t\tnew PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(e.name==='first-contentful-paint')_fcp=Math.round(e.startTime);});}).observe({type:'paint',buffered:true});\n\t\t\t\t\tnew PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(!e.hadRecentInput)_cls+=e.value;});}).observe({type:'layout-shift',buffered:true});\n\t\t\t\t}catch(e){}\n\t\t\t}\n\t\t\ttry{var nav=performance.getEntriesByType('navigation')[0];if(nav)_ttfb=Math.round(nav.responseStart);}catch(e){}\n\n\t\t\t// Scroll depth tracking\n\t\t\tvar _maxScroll=0;\n\t\t\tfunction updateScroll(){\n\t\t\t\tvar h=document.documentElement.scrollHeight-window.innerHeight;\n\t\t\t\tif(h>0){var pct=Math.round((window.scrollY/h)*100);if(pct>_maxScroll)_maxScroll=pct;}\n\t\t\t}\n\t\t\twindow.addEventListener('scroll',updateScroll,{passive:true});\n\n\t\t\t// Send beacon with fallback to fetch\n\t\t\tfunction send(url,data){\n\t\t\t\tvar payload=JSON.stringify(data);\n\t\t\t\tif(!navigator.sendBeacon(url,payload)){\n\t\t\t\t\tfetch(url,{method:'POST',body:payload,keepalive:true}).catch(function(){});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Track pageview with server-side deduplication nonce\n\t\t\tfunction t(){\n\t\t\t\tvar d={\n\t\t\t\t\tp:location.pathname+location.search,\n\t\t\t\t\tr:document.referrer,\n\t\t\t\t\tsw:screen.width,\n\t\t\t\t\tsh:screen.height,\n\t\t\t\t\tvw:window.innerWidth,\n\t\t\t\t\tvh:window.innerHeight,\n\t\t\t\t\tpr:window.devicePixelRatio||1,\n\t\t\t\t\ttz:new Date().getTimezoneOffset(),\n\t\t\t\t\tcs:window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light',\n\t\t\t\t\t// Bot detection signals\n\t\t\t\t\tbs:botSignals,\n\t\t\t\t\tbsc:botScore\n\t\t\t\t};\n\t\t\t\tif(navigator.connection){\n\t\t\t\t\td.ct=navigator.connection.effectiveType||'';\n\t\t\t\t\td.cd=navigator.connection.downlink||0;\n\t\t\t\t}\n\t\t\t\t// Use the nonce captured at parse time for server-side deduplication\n\t\t\t\tif(_nonce)d.n=_nonce;\n\t\t\t\tvar u=new URLSearchParams(location.search);\n\t\t\t\tif(u.get('s'))d.st=u.get('s');\n\t\t\t\tsend('/api/track',d);\n\t\t\t}\n\n\t\t\t// Exit beacon with time on page and web vitals\n\t\t\tvar pageStart=Date.now();\n\t\t\tfunction sendExit(){\n\t\t\t\tsend('/api/track/exit',{\n\t\t\t\t\tsid:sid,\n\t\t\t\t\ttop:Date.now()-pageStart,\n\t\t\t\t\tsd:_maxScroll,\n\t\t\t\t\tlcp:_lcp,\n\t\t\t\t\tfcp:_fcp,\n\t\t\t\t\tcls:Math.round(_cls*1000)/1000,\n\t\t\t\t\tttfb:_ttfb\n\t\t\t\t});\n\t\t\t}\n\t\t\tdocument.addEventListener('visibilitychange',function(){\n\t\t\t\tif(document.visibilityState==='hidden')sendExit();\n\t\t\t});\n\n\t\t\t// Track on load\n\t\t\tif(document.readyState==='complete'){t();}else{window.addEventListener('load',t);}\n\t\t})();\n\t") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1398,7 +1742,6 @@ func themeInitScript(themeMode string) templ.Component { // preference (bn-theme cookie/localStorage) → site default → system. // Exposed as window.bnApplyTheme so the toolbar theme tester can re-apply // after changing the override without duplicating this resolution. - // KEEP IN SYNC with cms backend/templates/bn/head.templ. return templ.Raw(`