Head, engagement, toolbar templ components and validation helpers for use by template plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
916 lines
32 KiB
Plaintext
916 lines
32 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
|
|
TurnstileSiteKey string // Cloudflare Turnstile site key for bot protection
|
|
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
|
|
}
|
|
}
|
|
|
|
// Turnstile bot protection (from site settings)
|
|
if turnstileData, ok := siteData["turnstile"].(map[string]any); ok {
|
|
// Only set site key if Turnstile is enabled
|
|
if enabled, ok := turnstileData["enabled"].(bool); ok && enabled {
|
|
if v, ok := turnstileData["site_key"].(string); ok {
|
|
settings.TurnstileSiteKey = 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)
|
|
}
|
|
// Cloudflare Turnstile invisible bot protection
|
|
if data.Settings.TurnstileSiteKey != "" {
|
|
@turnstileScript(data.Settings.TurnstileSiteKey)
|
|
}
|
|
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>`)
|
|
}
|
|
|
|
// turnstileScript renders the Cloudflare Turnstile invisible bot protection
|
|
templ turnstileScript(siteKey string) {
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
|
<script data-turnstile-key={ siteKey }>
|
|
(function(){
|
|
var siteKey = document.currentScript.getAttribute('data-turnstile-key');
|
|
if (!siteKey) return;
|
|
|
|
// Store active widget IDs per form
|
|
var formWidgets = new WeakMap();
|
|
|
|
// Initialize Turnstile when API is ready
|
|
function initTurnstile() {
|
|
if (typeof turnstile === 'undefined') {
|
|
setTimeout(initTurnstile, 100);
|
|
return;
|
|
}
|
|
|
|
// Find all forms that POST to /api/* endpoints
|
|
function instrumentForm(form) {
|
|
// Skip if already instrumented
|
|
if (formWidgets.has(form)) return;
|
|
|
|
var action = form.action || '';
|
|
var hxPost = form.getAttribute('hx-post') || '';
|
|
|
|
// Only protect /api/* POST endpoints
|
|
if (!action.includes('/api/') && !hxPost.includes('/api/')) return;
|
|
|
|
// Skip exempted endpoints
|
|
var exempted = ['/api/track', '/api/webhooks/'];
|
|
for (var i = 0; i < exempted.length; i++) {
|
|
if (action.includes(exempted[i]) || hxPost.includes(exempted[i])) return;
|
|
}
|
|
|
|
// Create container for widget
|
|
var container = document.createElement('div');
|
|
container.className = 'cf-turnstile-container';
|
|
container.style.cssText = 'position:absolute;left:-9999px;';
|
|
form.appendChild(container);
|
|
|
|
// Render invisible widget
|
|
var widgetId = turnstile.render(container, {
|
|
sitekey: siteKey,
|
|
size: 'invisible',
|
|
callback: function(token) {
|
|
// Store token in hidden field
|
|
var input = form.querySelector('input[name="cf-turnstile-response"]');
|
|
if (!input) {
|
|
input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'cf-turnstile-response';
|
|
form.appendChild(input);
|
|
}
|
|
input.value = token;
|
|
}
|
|
});
|
|
|
|
formWidgets.set(form, widgetId);
|
|
}
|
|
|
|
// Instrument existing forms
|
|
document.querySelectorAll('form').forEach(instrumentForm);
|
|
|
|
// Watch for new forms (dynamic content / HTMX)
|
|
var observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
mutation.addedNodes.forEach(function(node) {
|
|
if (node.nodeName === 'FORM') {
|
|
instrumentForm(node);
|
|
}
|
|
if (node.querySelectorAll) {
|
|
node.querySelectorAll('form').forEach(instrumentForm);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
|
|
// HTMX integration: add token to request parameters
|
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
|
var form = evt.detail.elt.closest('form');
|
|
if (!form || !formWidgets.has(form)) return;
|
|
|
|
var widgetId = formWidgets.get(form);
|
|
var token = turnstile.getResponse(widgetId);
|
|
|
|
if (token) {
|
|
evt.detail.parameters['cf-turnstile-response'] = token;
|
|
}
|
|
});
|
|
|
|
// Reset widgets after HTMX swap
|
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
|
if (evt.detail.target.querySelectorAll) {
|
|
evt.detail.target.querySelectorAll('form').forEach(instrumentForm);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start initialization
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initTurnstile);
|
|
} else {
|
|
initTurnstile();
|
|
}
|
|
})();
|
|
</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>`)
|
|
}
|