Cap is the sole captcha provider in the CMS; the Cloudflare Turnstile TurnstileSiteKey field, settings read, and turnstileScript CDN injector are retired from the shared bn head template.
790 lines
28 KiB
Plaintext
790 lines
28 KiB
Plaintext
package bn
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// resolveMediaURL converts media: prefixed URLs to proper /media/ paths
|
|
// e.g., "media:abc/image.webp" → "/media/abc/image.webp"
|
|
func resolveMediaURL(url string) string {
|
|
if strings.HasPrefix(url, "media:") {
|
|
return "/media/" + strings.TrimPrefix(url, "media:")
|
|
}
|
|
return url
|
|
}
|
|
|
|
// 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
|
|
ThemeColor string
|
|
SVG string // Optional SVG pass-through
|
|
IsGenerated 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
|
|
DefaultOGImage string
|
|
TwitterHandle string
|
|
OGSiteName string
|
|
Branding BrandingData
|
|
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
|
|
Toolbar ToolbarData
|
|
LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery
|
|
RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable
|
|
RSSFeedTitle string // Feed title for discovery link
|
|
}
|
|
|
|
// PageMeta contains page-level SEO meta data (overrides site defaults)
|
|
type PageMeta struct {
|
|
MetaTitle string // Custom SEO title (overrides page title)
|
|
MetaDescription string // Page-specific meta description
|
|
OGTitle string // Open Graph title
|
|
OGDescription string // Open Graph description
|
|
OGImage string // Open Graph image URL
|
|
TwitterTitle string // Twitter card title
|
|
TwitterDescription string // Twitter card description
|
|
TwitterImage string // Twitter card image URL
|
|
CanonicalURL string // Canonical URL for this page
|
|
RobotsDirective string // Robots directive (e.g., "noindex, nofollow")
|
|
}
|
|
|
|
// HeadData contains all data needed to render the <head> element
|
|
type HeadData struct {
|
|
Title string
|
|
Settings SiteSettingsData
|
|
PageMeta PageMeta // Page-level SEO overrides
|
|
ThemeCSS string
|
|
ThemeMode string // "light", "dark", or "system"
|
|
PluginStyles []string // Additional stylesheet URLs
|
|
StructuredData string // JSON-LD structured data
|
|
CSSHash string // Cache-busting hash for styles.css
|
|
PageviewNonce string // Unique nonce for pageview deduplication
|
|
EngagementConfig EngagementConfig // Engagement tracking config (for blog posts)
|
|
}
|
|
|
|
// ParseEngagementConfig extracts engagement config from the document map
|
|
func ParseEngagementConfig(doc map[string]any) EngagementConfig {
|
|
config := EngagementConfig{}
|
|
|
|
engData, ok := doc["engagement_config"].(map[string]any)
|
|
if !ok {
|
|
return config
|
|
}
|
|
|
|
if v, ok := engData["page_path"].(string); ok {
|
|
config.PagePath = v
|
|
}
|
|
if v, ok := engData["page_id"].(string); ok {
|
|
config.PageID = v
|
|
}
|
|
if v, ok := engData["post_word_count"].(int); ok {
|
|
config.PostWordCount = v
|
|
}
|
|
if v, ok := engData["session_id"].(string); ok {
|
|
config.SessionID = v
|
|
}
|
|
if v, ok := engData["visitor_hash"].(string); ok {
|
|
config.VisitorHash = v
|
|
}
|
|
if v, ok := engData["is_post"].(bool); ok {
|
|
config.IsPost = v
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
// ParseSiteSettings extracts site settings from the document map
|
|
func ParseSiteSettings(doc map[string]any) SiteSettingsData {
|
|
settings := SiteSettingsData{}
|
|
|
|
siteData, ok := doc["site_settings"].(map[string]any)
|
|
if !ok {
|
|
return settings
|
|
}
|
|
|
|
if v, ok := siteData["title"].(string); ok {
|
|
settings.Title = v
|
|
}
|
|
if v, ok := siteData["description"].(string); ok {
|
|
settings.Description = v
|
|
}
|
|
if v, ok := siteData["favicon"].(string); ok {
|
|
settings.Favicon = v
|
|
}
|
|
if v, ok := siteData["logo"].(string); ok {
|
|
settings.Logo = v
|
|
}
|
|
if v, ok := siteData["logo_alt"].(string); ok {
|
|
settings.LogoAlt = v
|
|
}
|
|
if v, ok := siteData["apple_touch_icon"].(string); ok {
|
|
settings.AppleTouchIcon = v
|
|
}
|
|
|
|
// SEO defaults
|
|
if seoData, ok := siteData["seo_defaults"].(map[string]any); ok {
|
|
if v, ok := seoData["meta_description"].(string); ok {
|
|
settings.MetaDescription = v
|
|
}
|
|
if v, ok := seoData["default_og_image"].(string); ok {
|
|
settings.DefaultOGImage = v
|
|
}
|
|
if v, ok := seoData["twitter_handle"].(string); ok {
|
|
settings.TwitterHandle = v
|
|
}
|
|
if v, ok := seoData["og_site_name"].(string); ok {
|
|
settings.OGSiteName = v
|
|
}
|
|
}
|
|
|
|
// Analytics
|
|
if analyticsData, ok := siteData["analytics"].(map[string]any); ok {
|
|
if v, ok := analyticsData["google_analytics_id"].(string); ok {
|
|
settings.GoogleAnalyticsID = v
|
|
}
|
|
if v, ok := analyticsData["custom_head_scripts"].(string); ok {
|
|
settings.CustomHeadScripts = v
|
|
}
|
|
if v, ok := analyticsData["custom_body_scripts"].(string); ok {
|
|
settings.CustomBodyScripts = v
|
|
}
|
|
}
|
|
|
|
// Branding (generated favicons)
|
|
if brandingData, ok := siteData["branding"].(map[string]any); ok {
|
|
if v, ok := brandingData["is_generated"].(bool); ok {
|
|
settings.Branding.IsGenerated = v
|
|
}
|
|
if v, ok := brandingData["favicon_ico"].(string); ok {
|
|
settings.Branding.FaviconICO = v
|
|
}
|
|
if v, ok := brandingData["favicon_16"].(string); ok {
|
|
settings.Branding.Favicon16 = v
|
|
}
|
|
if v, ok := brandingData["favicon_32"].(string); ok {
|
|
settings.Branding.Favicon32 = v
|
|
}
|
|
if v, ok := brandingData["apple_touch_icon"].(string); ok {
|
|
settings.Branding.AppleTouchIcon = v
|
|
}
|
|
if v, ok := brandingData["android_192"].(string); ok {
|
|
settings.Branding.Android192 = v
|
|
}
|
|
if v, ok := brandingData["android_512"].(string); ok {
|
|
settings.Branding.Android512 = v
|
|
}
|
|
if v, ok := brandingData["maskable_512"].(string); ok {
|
|
settings.Branding.Maskable512 = v
|
|
}
|
|
if v, ok := brandingData["master_1024"].(string); ok {
|
|
settings.Branding.Master1024 = v
|
|
}
|
|
if v, ok := brandingData["manifest_url"].(string); ok {
|
|
settings.Branding.ManifestURL = v
|
|
}
|
|
if v, ok := brandingData["theme_color"].(string); ok {
|
|
settings.Branding.ThemeColor = v
|
|
}
|
|
if v, ok := brandingData["svg"].(string); ok {
|
|
settings.Branding.SVG = v
|
|
}
|
|
}
|
|
|
|
// Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon)
|
|
if v, ok := siteData["admin_bypass_mode"].(string); ok {
|
|
settings.AdminBypassMode = v
|
|
}
|
|
|
|
// Admin editor toolbar
|
|
if toolbarData, ok := siteData["toolbar"].(map[string]any); ok {
|
|
settings.Toolbar = ParseToolbarData(toolbarData)
|
|
}
|
|
|
|
// AI Optimization settings
|
|
if aiOpt, ok := siteData["aiOptimization"].(map[string]any); ok {
|
|
if v, ok := aiOpt["llmsTxtEnabled"].(bool); ok {
|
|
settings.LLMsTxtEnabled = v
|
|
}
|
|
}
|
|
|
|
// RSS feed auto-discovery (injected by page handler from system page)
|
|
if v, ok := siteData["rss_feed_url"].(string); ok {
|
|
settings.RSSFeedURL = v
|
|
}
|
|
if v, ok := siteData["rss_feed_title"].(string); ok {
|
|
settings.RSSFeedTitle = v
|
|
}
|
|
|
|
return settings
|
|
}
|
|
|
|
// ParsePageMeta extracts page-level SEO metadata from the document map
|
|
// These fields override site-level defaults when set
|
|
func ParsePageMeta(doc map[string]any) PageMeta {
|
|
meta := PageMeta{}
|
|
|
|
// Page-level SEO fields are stored directly in the document root
|
|
// (matching frontend PageInfo type in web/src/store/editor.ts)
|
|
if v, ok := doc["metaTitle"].(string); ok {
|
|
meta.MetaTitle = v
|
|
}
|
|
if v, ok := doc["metaDescription"].(string); ok {
|
|
meta.MetaDescription = v
|
|
}
|
|
if v, ok := doc["ogTitle"].(string); ok {
|
|
meta.OGTitle = v
|
|
}
|
|
if v, ok := doc["ogDescription"].(string); ok {
|
|
meta.OGDescription = v
|
|
}
|
|
if v, ok := doc["ogImage"].(string); ok {
|
|
meta.OGImage = v
|
|
}
|
|
if v, ok := doc["twitterTitle"].(string); ok {
|
|
meta.TwitterTitle = v
|
|
}
|
|
if v, ok := doc["twitterDescription"].(string); ok {
|
|
meta.TwitterDescription = v
|
|
}
|
|
if v, ok := doc["twitterImage"].(string); ok {
|
|
meta.TwitterImage = v
|
|
}
|
|
if v, ok := doc["canonicalUrl"].(string); ok {
|
|
meta.CanonicalURL = v
|
|
}
|
|
if v, ok := doc["robotsDirective"].(string); ok {
|
|
meta.RobotsDirective = v
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
// ParseToolbarData extracts toolbar data from the document map
|
|
func ParseToolbarData(data map[string]any) ToolbarData {
|
|
toolbar := ToolbarData{}
|
|
|
|
if v, ok := data["enabled"].(bool); ok {
|
|
toolbar.Enabled = v
|
|
}
|
|
if v, ok := data["page_id"].(string); ok {
|
|
if id, err := uuid.Parse(v); err == nil {
|
|
toolbar.PageID = id
|
|
}
|
|
}
|
|
if v, ok := data["page_slug"].(string); ok {
|
|
toolbar.PageSlug = v
|
|
}
|
|
if v, ok := data["page_title"].(string); ok {
|
|
toolbar.PageTitle = v
|
|
}
|
|
if v, ok := data["post_type"].(string); ok {
|
|
toolbar.PostType = v
|
|
}
|
|
if v, ok := data["status"].(string); ok {
|
|
toolbar.Status = v
|
|
}
|
|
if v, ok := data["has_unpublished_changes"].(bool); ok {
|
|
toolbar.HasUnpublishedChanges = v
|
|
}
|
|
if v, ok := data["preview_mode"].(string); ok {
|
|
toolbar.PreviewMode = v
|
|
}
|
|
if v, ok := data["position"].(string); ok {
|
|
toolbar.Position = v
|
|
}
|
|
if v, ok := data["template_name"].(string); ok {
|
|
toolbar.TemplateName = v
|
|
}
|
|
if v, ok := data["author_name"].(string); ok {
|
|
toolbar.AuthorName = v
|
|
}
|
|
if v, ok := data["author_slug"].(string); ok {
|
|
toolbar.AuthorSlug = v
|
|
}
|
|
if v, ok := data["last_modified"].(time.Time); ok {
|
|
toolbar.LastModified = v
|
|
}
|
|
if v, ok := data["edit_url"].(string); ok {
|
|
toolbar.EditURL = v
|
|
}
|
|
if v, ok := data["settings_url"].(string); ok {
|
|
toolbar.SettingsURL = v
|
|
}
|
|
if v, ok := data["history_url"].(string); ok {
|
|
toolbar.HistoryURL = v
|
|
}
|
|
if v, ok := data["analytics_url"].(string); ok {
|
|
toolbar.AnalyticsURL = v
|
|
}
|
|
|
|
// Analytics snapshot
|
|
if v, ok := data["today_pageviews"].(int64); ok {
|
|
toolbar.TodayPageviews = v
|
|
}
|
|
if v, ok := data["pageviews_trend"].(string); ok {
|
|
toolbar.PageviewsTrend = v
|
|
}
|
|
if v, ok := data["trend_percent"].(int); ok {
|
|
toolbar.TrendPercent = v
|
|
}
|
|
|
|
// Blog-specific fields
|
|
if v, ok := data["reading_time"].(int); ok {
|
|
toolbar.ReadingTime = v
|
|
}
|
|
if v, ok := data["word_count"].(int); ok {
|
|
toolbar.WordCount = v
|
|
}
|
|
if v, ok := data["category_count"].(int); ok {
|
|
toolbar.CategoryCount = v
|
|
}
|
|
|
|
return toolbar
|
|
}
|
|
|
|
// EffectiveTitle returns the title to use in the <title> tag.
|
|
// Priority: PageMeta.MetaTitle → Page Title | Site Name → Page Title
|
|
func (d HeadData) EffectiveTitle() string {
|
|
if d.PageMeta.MetaTitle != "" {
|
|
return d.PageMeta.MetaTitle
|
|
}
|
|
if d.Settings.OGSiteName != "" && d.Title != "" {
|
|
return d.Title + " | " + d.Settings.OGSiteName
|
|
}
|
|
return d.Title
|
|
}
|
|
|
|
// Head renders the complete <head> element with site settings and plugin extensions.
|
|
templ Head(data HeadData) {
|
|
@recordValidationCall(ctx, "Head")
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ data.EffectiveTitle() }</title>
|
|
if data.CSSHash != "" {
|
|
<link rel="stylesheet" href={ "/data/styles/styles.css?v=" + data.CSSHash }/>
|
|
} else {
|
|
<link rel="stylesheet" href="/data/styles/styles.css"/>
|
|
}
|
|
@themeInitScript(data.ThemeMode)
|
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
if data.Settings.Branding.IsGenerated {
|
|
// Use generated brand assets
|
|
<link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/>
|
|
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/>
|
|
<link rel="icon" type="image/png" sizes="32x32" href={ data.Settings.Branding.Favicon32 }/>
|
|
<link rel="apple-touch-icon" sizes="180x180" href={ data.Settings.Branding.AppleTouchIcon }/>
|
|
<link rel="manifest" href={ data.Settings.Branding.ManifestURL }/>
|
|
if data.Settings.Branding.ThemeColor != "" {
|
|
<meta name="theme-color" content={ data.Settings.Branding.ThemeColor }/>
|
|
}
|
|
} else {
|
|
// Legacy fallback - manual favicon uploads
|
|
if data.Settings.Favicon != "" {
|
|
<link rel="icon" href={ data.Settings.Favicon }/>
|
|
}
|
|
if data.Settings.AppleTouchIcon != "" {
|
|
<link rel="apple-touch-icon" href={ data.Settings.AppleTouchIcon }/>
|
|
}
|
|
}
|
|
// === SEO META TAGS ===
|
|
// Fallback chain: Page-level → Site defaults
|
|
|
|
// Meta description: Page override → Site default
|
|
if data.PageMeta.MetaDescription != "" {
|
|
<meta name="description" content={ data.PageMeta.MetaDescription }/>
|
|
} else if data.Settings.MetaDescription != "" {
|
|
<meta name="description" content={ data.Settings.MetaDescription }/>
|
|
}
|
|
|
|
// Canonical URL (page-level only, no site default)
|
|
if data.PageMeta.CanonicalURL != "" {
|
|
<link rel="canonical" href={ data.PageMeta.CanonicalURL }/>
|
|
}
|
|
|
|
// Robots directive (page-level only - defaults to index,follow if not set)
|
|
if data.PageMeta.RobotsDirective != "" {
|
|
<meta name="robots" content={ data.PageMeta.RobotsDirective }/>
|
|
}
|
|
|
|
// === OPEN GRAPH META TAGS ===
|
|
// og:site_name is always site-level
|
|
if data.Settings.OGSiteName != "" {
|
|
<meta property="og:site_name" content={ data.Settings.OGSiteName }/>
|
|
}
|
|
|
|
// og:title: Page OG → Page Meta Title → Page Title
|
|
if data.PageMeta.OGTitle != "" {
|
|
<meta property="og:title" content={ data.PageMeta.OGTitle }/>
|
|
} else if data.PageMeta.MetaTitle != "" {
|
|
<meta property="og:title" content={ data.PageMeta.MetaTitle }/>
|
|
} else if data.Title != "" {
|
|
<meta property="og:title" content={ data.Title }/>
|
|
}
|
|
|
|
// og:description: Page OG → Page Meta Description → Site default
|
|
if data.PageMeta.OGDescription != "" {
|
|
<meta property="og:description" content={ data.PageMeta.OGDescription }/>
|
|
} else if data.PageMeta.MetaDescription != "" {
|
|
<meta property="og:description" content={ data.PageMeta.MetaDescription }/>
|
|
} else if data.Settings.MetaDescription != "" {
|
|
<meta property="og:description" content={ data.Settings.MetaDescription }/>
|
|
}
|
|
|
|
// og:image: Page OG → Site default (resolve media: URLs)
|
|
if data.PageMeta.OGImage != "" {
|
|
<meta property="og:image" content={ resolveMediaURL(data.PageMeta.OGImage) }/>
|
|
} else if data.Settings.DefaultOGImage != "" {
|
|
<meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
|
}
|
|
|
|
// og:type - default to website
|
|
<meta property="og:type" content="website"/>
|
|
|
|
// === TWITTER CARD META TAGS ===
|
|
if data.Settings.TwitterHandle != "" {
|
|
<meta name="twitter:site" content={ data.Settings.TwitterHandle }/>
|
|
}
|
|
<meta name="twitter:card" content="summary_large_image"/>
|
|
|
|
// twitter:title: Page Twitter → Page OG → Page Meta Title → Page Title
|
|
if data.PageMeta.TwitterTitle != "" {
|
|
<meta name="twitter:title" content={ data.PageMeta.TwitterTitle }/>
|
|
} else if data.PageMeta.OGTitle != "" {
|
|
<meta name="twitter:title" content={ data.PageMeta.OGTitle }/>
|
|
} else if data.PageMeta.MetaTitle != "" {
|
|
<meta name="twitter:title" content={ data.PageMeta.MetaTitle }/>
|
|
} else if data.Title != "" {
|
|
<meta name="twitter:title" content={ data.Title }/>
|
|
}
|
|
|
|
// twitter:description: Page Twitter → Page OG → Page Meta Description → Site default
|
|
if data.PageMeta.TwitterDescription != "" {
|
|
<meta name="twitter:description" content={ data.PageMeta.TwitterDescription }/>
|
|
} else if data.PageMeta.OGDescription != "" {
|
|
<meta name="twitter:description" content={ data.PageMeta.OGDescription }/>
|
|
} else if data.PageMeta.MetaDescription != "" {
|
|
<meta name="twitter:description" content={ data.PageMeta.MetaDescription }/>
|
|
} else if data.Settings.MetaDescription != "" {
|
|
<meta name="twitter:description" content={ data.Settings.MetaDescription }/>
|
|
}
|
|
|
|
// twitter:image: Page Twitter → Page OG → Site default (resolve media: URLs)
|
|
if data.PageMeta.TwitterImage != "" {
|
|
<meta name="twitter:image" content={ resolveMediaURL(data.PageMeta.TwitterImage) }/>
|
|
} else if data.PageMeta.OGImage != "" {
|
|
<meta name="twitter:image" content={ resolveMediaURL(data.PageMeta.OGImage) }/>
|
|
} else if data.Settings.DefaultOGImage != "" {
|
|
<meta name="twitter:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
|
}
|
|
|
|
// === AI CONTENT DISCOVERY ===
|
|
// llms.txt meta tag for AI crawlers (if enabled)
|
|
if data.Settings.LLMsTxtEnabled {
|
|
<link rel="ai-content" type="text/plain" href="/llms.txt"/>
|
|
<meta name="llms" content="/llms.txt"/>
|
|
}
|
|
|
|
// === RSS/ATOM FEED DISCOVERY ===
|
|
if data.Settings.RSSFeedURL != "" {
|
|
<link rel="alternate" type="application/rss+xml" title={ data.Settings.RSSFeedTitle + " (RSS)" } href={ data.Settings.RSSFeedURL }/>
|
|
<link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/>
|
|
}
|
|
|
|
if data.Settings.GoogleAnalyticsID != "" {
|
|
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script>
|
|
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
|
|
}
|
|
// Built-in analytics tracking (cookie-less) with nonce for server-side deduplication
|
|
@analyticsScript(data.PageviewNonce)
|
|
// Blog post engagement tracking (only rendered for posts with engagement config)
|
|
@EngagementScript(data.EngagementConfig)
|
|
for _, style := range data.PluginStyles {
|
|
<link rel="stylesheet" href={ style }/>
|
|
}
|
|
// Theme CSS injected AFTER plugin styles to take precedence
|
|
if data.ThemeCSS != "" {
|
|
@themeStyle(data.ThemeCSS)
|
|
}
|
|
if data.Settings.CustomHeadScripts != "" {
|
|
@templ.Raw(data.Settings.CustomHeadScripts)
|
|
}
|
|
if data.StructuredData != "" {
|
|
@structuredDataScript(data.StructuredData)
|
|
}
|
|
{ children... }
|
|
</head>
|
|
}
|
|
|
|
// structuredDataScript renders JSON-LD structured data
|
|
func structuredDataScript(jsonLD string) templ.Component {
|
|
return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`)
|
|
}
|
|
|
|
// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
|
|
// This should be rendered at the very start of the <body> to push down all content
|
|
templ AdminBypassBanner(settings SiteSettingsData) {
|
|
if settings.AdminBypassMode != "" {
|
|
<div class="bg-warning text-warning-foreground px-4 py-2 text-center text-sm font-medium relative z-[9999]">
|
|
<div class="max-w-7xl mx-auto flex items-center justify-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
if settings.AdminBypassMode == "maintenance" {
|
|
<span>
|
|
<strong>Maintenance Mode Active</strong> — You're seeing the normal site because you're logged in as admin.
|
|
<a href="/admin/settings?tab=status" class="underline hover:no-underline">Manage</a>
|
|
</span>
|
|
} else if settings.AdminBypassMode == "coming_soon" {
|
|
<span>
|
|
<strong>Coming Soon Mode Active</strong> — You're seeing the normal site because you're logged in as admin.
|
|
<a href="/admin/settings?tab=status" class="underline hover:no-underline">Manage</a>
|
|
</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// BodyEnd renders custom scripts and admin toolbar before </body>
|
|
templ BodyEnd(settings SiteSettingsData) {
|
|
@recordValidationCall(ctx, "BodyEnd")
|
|
if settings.CustomBodyScripts != "" {
|
|
@templ.Raw(settings.CustomBodyScripts)
|
|
}
|
|
// Render admin editor toolbar if enabled (auto-injects for all templates)
|
|
@AdminEditorToolbar(settings.Toolbar)
|
|
{ children... }
|
|
}
|
|
|
|
// themeStyle renders the theme CSS variables
|
|
func themeStyleComponent(css string) templ.Component {
|
|
return templ.Raw(`<style id="theme-variables">` + css + `</style>`)
|
|
}
|
|
|
|
templ themeStyle(css string) {
|
|
@themeStyleComponent(css)
|
|
}
|
|
|
|
// googleAnalyticsScript renders the GA4 inline script
|
|
func googleAnalyticsScript(gaID string) templ.Component {
|
|
return templ.Raw(`<script>window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '` + gaID + `');</script>`)
|
|
}
|
|
|
|
// analyticsScript renders the built-in analytics tracking script (cookie-less)
|
|
// nonce is used for deduplication with server-side tracking
|
|
templ analyticsScript(nonce string) {
|
|
<script data-nonce={ nonce }>
|
|
(function(){
|
|
// Don't track admin pages
|
|
if(location.pathname.startsWith('/admin'))return;
|
|
|
|
// CRITICAL: Capture nonce immediately while document.currentScript is valid
|
|
// document.currentScript is only available during script parsing, not later
|
|
var _nonce = document.currentScript ? document.currentScript.getAttribute('data-nonce') : '';
|
|
|
|
// Persistent session ID (survives page navigation within session)
|
|
var sid=sessionStorage.getItem('_bn_sid');
|
|
if(!sid){sid=crypto.randomUUID();sessionStorage.setItem('_bn_sid',sid);}
|
|
|
|
// ============================================================
|
|
// Advanced Bot Detection - Client-Side Signals
|
|
// See docs/BOT_DETECTION.md for full documentation
|
|
// ============================================================
|
|
var botSignals={},botScore=0;
|
|
try{
|
|
// 1. WebDriver property (30 points) - automation frameworks set this
|
|
botSignals.webdriver=!!navigator.webdriver;
|
|
if(botSignals.webdriver)botScore+=30;
|
|
|
|
// 2. CDP Detection (25 points) - detects Puppeteer/Playwright/Selenium
|
|
botSignals.cdp=false;
|
|
try{
|
|
var e=new Error();
|
|
Object.defineProperty(e,'stack',{get:function(){botSignals.cdp=true;}});
|
|
console.debug(e);
|
|
if(botSignals.cdp)botScore+=25;
|
|
}catch(x){}
|
|
|
|
// 3. Plugin count (15 points) - headless often has no plugins
|
|
botSignals.plugins=navigator.plugins?navigator.plugins.length:0;
|
|
if(botSignals.plugins===0)botScore+=15;
|
|
|
|
// 4. Languages (15 points) - headless often has empty languages
|
|
botSignals.languages=navigator.languages?navigator.languages.length:0;
|
|
if(botSignals.languages===0)botScore+=15;
|
|
|
|
// 5. Chrome runtime (10 points) - real Chrome has chrome.runtime
|
|
botSignals.chromeRuntime=!!(window.chrome&&window.chrome.runtime);
|
|
if(window.chrome&&!window.chrome.runtime)botScore+=10;
|
|
|
|
// 6. WebGL renderer (20 points) - headless uses SwiftShader/llvmpipe
|
|
try{
|
|
var c=document.createElement('canvas');
|
|
var gl=c.getContext('webgl')||c.getContext('experimental-webgl');
|
|
if(gl){
|
|
var dbg=gl.getExtension('WEBGL_debug_renderer_info');
|
|
if(dbg){
|
|
botSignals.webglRenderer=gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)||'';
|
|
var r=botSignals.webglRenderer.toLowerCase();
|
|
if(r.includes('swiftshader')||r.includes('llvmpipe')||r.includes('software'))botScore+=20;
|
|
}
|
|
}
|
|
}catch(x){}
|
|
|
|
// 7. Permission anomalies (15 points) - inconsistent API states
|
|
if(window.Notification&&navigator.permissions){
|
|
navigator.permissions.query({name:'notifications'}).then(function(p){
|
|
if(p.state==='denied'&&Notification.permission==='default'){
|
|
botSignals.permissionAnomaly=true;
|
|
botScore+=15;
|
|
}
|
|
}).catch(function(){});
|
|
}
|
|
|
|
// 8. Headless in Client Hints (20 points)
|
|
if(navigator.userAgentData&&navigator.userAgentData.getHighEntropyValues){
|
|
navigator.userAgentData.getHighEntropyValues(['fullVersionList','platform']).then(function(h){
|
|
if(h.brands&&h.brands.some(function(b){return b.brand.toLowerCase().includes('headless');})){
|
|
botSignals.headlessHints=true;
|
|
botScore+=20;
|
|
}
|
|
}).catch(function(){});
|
|
}
|
|
|
|
// 9. Automation variables (10 points each)
|
|
if(window._phantom||window.__nightmare||window.callPhantom){botSignals.phantomjs=true;botScore+=10;}
|
|
if(window.__selenium_unwrapped||window.__webdriver_evaluate||document.__selenium_evaluate){botSignals.selenium=true;botScore+=10;}
|
|
if(window.__fxdriver_evaluate||window.__webdriver_script_fn){botSignals.selenium=true;botScore+=10;}
|
|
|
|
// 10. Broken image dimensions (10 points) - headless often fails to render
|
|
try{
|
|
var img=new Image();
|
|
img.src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
if(img.height===0){botSignals.brokenImage=true;botScore+=10;}
|
|
}catch(x){}
|
|
|
|
// Cap at 100
|
|
botScore=Math.min(botScore,100);
|
|
botSignals.score=botScore;
|
|
}catch(x){botSignals.error=x.message;}
|
|
|
|
// ============================================================
|
|
// Web Vitals collection
|
|
// ============================================================
|
|
var _lcp=0,_fcp=0,_cls=0,_ttfb=0;
|
|
if('PerformanceObserver'in window){
|
|
try{
|
|
new PerformanceObserver(function(l){var e=l.getEntries().pop();if(e)_lcp=Math.round(e.startTime);}).observe({type:'largest-contentful-paint',buffered:true});
|
|
new PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(e.name==='first-contentful-paint')_fcp=Math.round(e.startTime);});}).observe({type:'paint',buffered:true});
|
|
new PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(!e.hadRecentInput)_cls+=e.value;});}).observe({type:'layout-shift',buffered:true});
|
|
}catch(e){}
|
|
}
|
|
try{var nav=performance.getEntriesByType('navigation')[0];if(nav)_ttfb=Math.round(nav.responseStart);}catch(e){}
|
|
|
|
// Scroll depth tracking
|
|
var _maxScroll=0;
|
|
function updateScroll(){
|
|
var h=document.documentElement.scrollHeight-window.innerHeight;
|
|
if(h>0){var pct=Math.round((window.scrollY/h)*100);if(pct>_maxScroll)_maxScroll=pct;}
|
|
}
|
|
window.addEventListener('scroll',updateScroll,{passive:true});
|
|
|
|
// Send beacon with fallback to fetch
|
|
function send(url,data){
|
|
var payload=JSON.stringify(data);
|
|
if(!navigator.sendBeacon(url,payload)){
|
|
fetch(url,{method:'POST',body:payload,keepalive:true}).catch(function(){});
|
|
}
|
|
}
|
|
|
|
// Track pageview with server-side deduplication nonce
|
|
function t(){
|
|
var d={
|
|
p:location.pathname+location.search,
|
|
r:document.referrer,
|
|
sw:screen.width,
|
|
sh:screen.height,
|
|
vw:window.innerWidth,
|
|
vh:window.innerHeight,
|
|
pr:window.devicePixelRatio||1,
|
|
tz:new Date().getTimezoneOffset(),
|
|
cs:window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light',
|
|
// Bot detection signals
|
|
bs:botSignals,
|
|
bsc:botScore
|
|
};
|
|
if(navigator.connection){
|
|
d.ct=navigator.connection.effectiveType||'';
|
|
d.cd=navigator.connection.downlink||0;
|
|
}
|
|
// Use the nonce captured at parse time for server-side deduplication
|
|
if(_nonce)d.n=_nonce;
|
|
var u=new URLSearchParams(location.search);
|
|
if(u.get('s'))d.st=u.get('s');
|
|
send('/api/track',d);
|
|
}
|
|
|
|
// Exit beacon with time on page and web vitals
|
|
var pageStart=Date.now();
|
|
function sendExit(){
|
|
send('/api/track/exit',{
|
|
sid:sid,
|
|
top:Date.now()-pageStart,
|
|
sd:_maxScroll,
|
|
lcp:_lcp,
|
|
fcp:_fcp,
|
|
cls:Math.round(_cls*1000)/1000,
|
|
ttfb:_ttfb
|
|
});
|
|
}
|
|
document.addEventListener('visibilitychange',function(){
|
|
if(document.visibilityState==='hidden')sendExit();
|
|
});
|
|
|
|
// Track on load
|
|
if(document.readyState==='complete'){t();}else{window.addEventListener('load',t);}
|
|
})();
|
|
</script>
|
|
}
|
|
|
|
func themeInitScript(themeMode string) templ.Component {
|
|
switch themeMode {
|
|
case "light", "dark", "system":
|
|
default:
|
|
themeMode = "light"
|
|
}
|
|
return templ.Raw(`<script>
|
|
(function(){
|
|
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
|
|
var t=c?c[1]:localStorage.getItem('bn-theme');
|
|
if(!t)t='` + themeMode + `';
|
|
var h=document.documentElement;
|
|
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
|
if(t==='dark')h.classList.add('dark');
|
|
else h.classList.remove('dark');
|
|
})();
|
|
</script>`)
|
|
}
|