diff --git a/templates/bn/doc.go b/templates/bn/doc.go
deleted file mode 100644
index 76816e2..0000000
--- a/templates/bn/doc.go
+++ /dev/null
@@ -1 +0,0 @@
-package bn
diff --git a/templates/bn/engagement.templ b/templates/bn/engagement.templ
new file mode 100644
index 0000000..b1a7d82
--- /dev/null
+++ b/templates/bn/engagement.templ
@@ -0,0 +1,514 @@
+package bn
+
+import (
+ "encoding/json"
+)
+
+// EngagementConfig contains configuration for the engagement tracker
+type EngagementConfig struct {
+ PagePath string `json:"pagePath"`
+ PageID string `json:"pageId"`
+ PostWordCount int `json:"postWordCount"`
+ SessionID string `json:"sessionId"`
+ VisitorHash string `json:"visitorHash"`
+ IsPost bool `json:"isPost"`
+}
+
+// engagementConfigJSON converts the config to JSON for embedding in the script
+func engagementConfigJSON(config EngagementConfig) string {
+ data, err := json.Marshal(config)
+ if err != nil {
+ return "{}"
+ }
+ return string(data)
+}
+
+// EngagementScript renders the blog post engagement tracking script.
+// Only renders if IsPost is true to avoid bloating regular pages.
+templ EngagementScript(config EngagementConfig) {
+ if config.IsPost && config.PageID != "" {
+
+ }
+}
diff --git a/templates/bn/engagement_templ.go b/templates/bn/engagement_templ.go
new file mode 100644
index 0000000..43653e8
--- /dev/null
+++ b/templates/bn/engagement_templ.go
@@ -0,0 +1,80 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1001
+package bn
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "encoding/json"
+)
+
+// EngagementConfig contains configuration for the engagement tracker
+type EngagementConfig struct {
+ PagePath string `json:"pagePath"`
+ PageID string `json:"pageId"`
+ PostWordCount int `json:"postWordCount"`
+ SessionID string `json:"sessionId"`
+ VisitorHash string `json:"visitorHash"`
+ IsPost bool `json:"isPost"`
+}
+
+// engagementConfigJSON converts the config to JSON for embedding in the script
+func engagementConfigJSON(config EngagementConfig) string {
+ data, err := json.Marshal(config)
+ if err != nil {
+ return "{}"
+ }
+ return string(data)
+}
+
+// EngagementScript renders the blog post engagement tracking script.
+// Only renders if IsPost is true to avoid bloating regular pages.
+func EngagementScript(config EngagementConfig) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if config.IsPost && config.PageID != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/templates/bn/head.templ b/templates/bn/head.templ
new file mode 100644
index 0000000..6545888
--- /dev/null
+++ b/templates/bn/head.templ
@@ -0,0 +1,915 @@
+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
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 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 element with site settings and plugin extensions.
+templ Head(data HeadData) {
+ @recordValidationCall(ctx, "Head")
+
+
+
+ { data.EffectiveTitle() }
+ if data.CSSHash != "" {
+
+ } else {
+
+ }
+ @themeInitScript(data.ThemeMode)
+
+ if data.Settings.Branding.IsGenerated {
+ // Use generated brand assets
+
+
+
+
+
+ if data.Settings.Branding.ThemeColor != "" {
+
+ }
+ } else {
+ // Legacy fallback - manual favicon uploads
+ if data.Settings.Favicon != "" {
+
+ }
+ if data.Settings.AppleTouchIcon != "" {
+
+ }
+ }
+ // === SEO META TAGS ===
+ // Fallback chain: Page-level → Site defaults
+
+ // Meta description: Page override → Site default
+ if data.PageMeta.MetaDescription != "" {
+
+ } else if data.Settings.MetaDescription != "" {
+
+ }
+
+ // Canonical URL (page-level only, no site default)
+ if data.PageMeta.CanonicalURL != "" {
+
+ }
+
+ // Robots directive (page-level only - defaults to index,follow if not set)
+ if data.PageMeta.RobotsDirective != "" {
+
+ }
+
+ // === OPEN GRAPH META TAGS ===
+ // og:site_name is always site-level
+ if data.Settings.OGSiteName != "" {
+
+ }
+
+ // og:title: Page OG → Page Meta Title → Page Title
+ if data.PageMeta.OGTitle != "" {
+
+ } else if data.PageMeta.MetaTitle != "" {
+
+ } else if data.Title != "" {
+
+ }
+
+ // og:description: Page OG → Page Meta Description → Site default
+ if data.PageMeta.OGDescription != "" {
+
+ } else if data.PageMeta.MetaDescription != "" {
+
+ } else if data.Settings.MetaDescription != "" {
+
+ }
+
+ // og:image: Page OG → Site default (resolve media: URLs)
+ if data.PageMeta.OGImage != "" {
+
+ } else if data.Settings.DefaultOGImage != "" {
+
+ }
+
+ // og:type - default to website
+
+
+ // === TWITTER CARD META TAGS ===
+ if data.Settings.TwitterHandle != "" {
+
+ }
+
+
+ // twitter:title: Page Twitter → Page OG → Page Meta Title → Page Title
+ if data.PageMeta.TwitterTitle != "" {
+
+ } else if data.PageMeta.OGTitle != "" {
+
+ } else if data.PageMeta.MetaTitle != "" {
+
+ } else if data.Title != "" {
+
+ }
+
+ // twitter:description: Page Twitter → Page OG → Page Meta Description → Site default
+ if data.PageMeta.TwitterDescription != "" {
+
+ } else if data.PageMeta.OGDescription != "" {
+
+ } else if data.PageMeta.MetaDescription != "" {
+
+ } else if data.Settings.MetaDescription != "" {
+
+ }
+
+ // twitter:image: Page Twitter → Page OG → Site default (resolve media: URLs)
+ if data.PageMeta.TwitterImage != "" {
+
+ } else if data.PageMeta.OGImage != "" {
+
+ } else if data.Settings.DefaultOGImage != "" {
+
+ }
+
+ // === AI CONTENT DISCOVERY ===
+ // llms.txt meta tag for AI crawlers (if enabled)
+ if data.Settings.LLMsTxtEnabled {
+
+
+ }
+
+ // === RSS/ATOM FEED DISCOVERY ===
+ if data.Settings.RSSFeedURL != "" {
+
+
+ }
+
+ if data.Settings.GoogleAnalyticsID != "" {
+
+ @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 {
+
+ }
+ // 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... }
+
+}
+
+// structuredDataScript renders JSON-LD structured data
+func structuredDataScript(jsonLD string) templ.Component {
+ return templ.Raw(``)
+}
+
+// turnstileScript renders the Cloudflare Turnstile invisible bot protection
+templ turnstileScript(siteKey string) {
+
+
+}
+
+// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
+// This should be rendered at the very start of the to push down all content
+templ AdminBypassBanner(settings SiteSettingsData) {
+ if settings.AdminBypassMode != "" {
+
+
+
+ if settings.AdminBypassMode == "maintenance" {
+
+ Maintenance Mode Active — You're seeing the normal site because you're logged in as admin.
+ Manage
+
+ } else if settings.AdminBypassMode == "coming_soon" {
+
+ Coming Soon Mode Active — You're seeing the normal site because you're logged in as admin.
+ Manage
+
+ }
+
+
+ }
+}
+
+// BodyEnd renders custom scripts and admin toolbar before
+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(``)
+}
+
+templ themeStyle(css string) {
+ @themeStyleComponent(css)
+}
+
+// googleAnalyticsScript renders the GA4 inline script
+func googleAnalyticsScript(gaID string) templ.Component {
+ return templ.Raw(``)
+}
+
+// analyticsScript renders the built-in analytics tracking script (cookie-less)
+// nonce is used for deduplication with server-side tracking
+templ analyticsScript(nonce string) {
+
+}
+
+func themeInitScript(themeMode string) templ.Component {
+ switch themeMode {
+ case "light", "dark", "system":
+ default:
+ themeMode = "light"
+ }
+ return templ.Raw(``)
+}
diff --git a/templates/bn/head_templ.go b/templates/bn/head_templ.go
new file mode 100644
index 0000000..99b37c7
--- /dev/null
+++ b/templates/bn/head_templ.go
@@ -0,0 +1,1470 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1001
+package bn
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+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 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 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 element with site settings and plugin extensions.
+func Head(data HeadData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = recordValidationCall(ctx, "Head").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.EffectiveTitle())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `head.templ`, Line: 395, Col: 32}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.CSSHash != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = themeInitScript(data.ThemeMode).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Settings.Branding.IsGenerated {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Settings.Branding.ThemeColor != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Settings.Favicon != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Settings.AppleTouchIcon != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ if data.PageMeta.MetaDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Settings.MetaDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.CanonicalURL != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.RobotsDirective != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Settings.OGSiteName != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.OGTitle != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.MetaTitle != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Title != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.OGDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.MetaDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Settings.MetaDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.OGImage != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Settings.DefaultOGImage != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Settings.TwitterHandle != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.PageMeta.TwitterTitle != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.OGTitle != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.MetaTitle != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Title != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.TwitterDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.OGDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.MetaDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Settings.MetaDescription != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.PageMeta.TwitterImage != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.PageMeta.OGImage != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if data.Settings.DefaultOGImage != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Settings.LLMsTxtEnabled {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Settings.RSSFeedURL != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Settings.GoogleAnalyticsID != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = googleAnalyticsScript(data.Settings.GoogleAnalyticsID).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = analyticsScript(data.PageviewNonce).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = EngagementScript(data.EngagementConfig).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, style := range data.PluginStyles {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.ThemeCSS != "" {
+ templ_7745c5c3_Err = themeStyle(data.ThemeCSS).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ 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
+ }
+ }
+ if data.Settings.TurnstileSiteKey != "" {
+ templ_7745c5c3_Err = turnstileScript(data.Settings.TurnstileSiteKey).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.StructuredData != "" {
+ templ_7745c5c3_Err = structuredDataScript(data.StructuredData).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// structuredDataScript renders JSON-LD structured data
+func structuredDataScript(jsonLD string) templ.Component {
+ return templ.Raw(``)
+}
+
+// turnstileScript renders the Cloudflare Turnstile invisible bot protection
+func turnstileScript(siteKey string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var43 == nil {
+ templ_7745c5c3_Var43 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
+// This should be rendered at the very start of the to push down all content
+func AdminBypassBanner(settings SiteSettingsData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var45 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var45 == nil {
+ templ_7745c5c3_Var45 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if settings.AdminBypassMode != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if settings.AdminBypassMode == "maintenance" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "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, 88, "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, 89, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+// BodyEnd renders custom scripts and admin toolbar before
+func BodyEnd(settings SiteSettingsData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var46 == nil {
+ templ_7745c5c3_Var46 = 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
+ }
+ }
+ 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_Var46.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// themeStyle renders the theme CSS variables
+func themeStyleComponent(css string) templ.Component {
+ return templ.Raw(``)
+}
+
+func themeStyle(css string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var47 == nil {
+ templ_7745c5c3_Var47 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = themeStyleComponent(css).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// googleAnalyticsScript renders the GA4 inline script
+func googleAnalyticsScript(gaID string) templ.Component {
+ return templ.Raw(``)
+}
+
+// analyticsScript renders the built-in analytics tracking script (cookie-less)
+// nonce is used for deduplication with server-side tracking
+func analyticsScript(nonce string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var48 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var48 == nil {
+ templ_7745c5c3_Var48 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func themeInitScript(themeMode string) templ.Component {
+ switch themeMode {
+ case "light", "dark", "system":
+ default:
+ themeMode = "light"
+ }
+ return templ.Raw(``)
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/templates/bn/toolbar.templ b/templates/bn/toolbar.templ
new file mode 100644
index 0000000..7d3101a
--- /dev/null
+++ b/templates/bn/toolbar.templ
@@ -0,0 +1,528 @@
+package bn
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// ToolbarData contains data for the admin editor toolbar
+type ToolbarData struct {
+ Enabled bool
+ PageID uuid.UUID
+ PageSlug string
+ PageTitle string
+ PostType string // "page", "post", "master", "system"
+ Status string // "published", "draft", "scheduled"
+ HasUnpublishedChanges bool
+ PreviewMode string // "published" or "draft"
+ Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr")
+ ScheduledAt *time.Time
+ TemplateName string
+ AuthorName string
+ LastModified time.Time
+ EditURL string
+ SettingsURL string
+ HistoryURL string
+ AnalyticsURL string
+
+ // Analytics snapshot
+ TodayPageviews int64
+ PageviewsTrend string // "up", "down", "flat"
+ TrendPercent int
+
+ // Blog-specific fields
+ ReadingTime int // minutes
+ WordCount int
+ CategoryCount int
+ AuthorSlug string
+}
+
+// StatusBadgeClass returns the Tailwind classes for the status badge
+func (t ToolbarData) StatusBadgeClass() string {
+ switch t.Status {
+ case "published":
+ return "bg-success text-success-foreground"
+ case "scheduled":
+ return "bg-info text-info-foreground"
+ default:
+ return "bg-warning text-warning-foreground"
+ }
+}
+
+// StatusLabel returns a human-readable status label
+func (t ToolbarData) StatusLabel() string {
+ switch t.Status {
+ case "published":
+ return "Published"
+ case "scheduled":
+ return "Scheduled"
+ default:
+ return "Draft"
+ }
+}
+
+// PostTypeLabel returns a human-readable post type label
+func (t ToolbarData) PostTypeLabel() string {
+ switch t.PostType {
+ case "post":
+ return "Post"
+ case "master":
+ return "Master"
+ case "system":
+ return "System"
+ default:
+ return "Page"
+ }
+}
+
+// IsPreviewMode returns true if currently viewing draft/preview
+func (t ToolbarData) IsPreviewMode() bool {
+ return t.PreviewMode == "draft"
+}
+
+// CanPublish returns true if the publish button should be enabled
+func (t ToolbarData) CanPublish() bool {
+ return t.HasUnpublishedChanges
+}
+
+// LastModifiedFormatted returns the last modified time in a readable format
+func (t ToolbarData) LastModifiedFormatted() string {
+ return t.LastModified.Format("Jan 2, 2006 3:04 PM")
+}
+
+// IsTopPosition returns true if toolbar should be at top (for legacy compatibility)
+func (t ToolbarData) IsTopPosition() bool {
+ return t.Position == "tl" || t.Position == "tc" || t.Position == "tr" || t.Position == ""
+}
+
+// IsBottomPosition returns true if toolbar is at bottom
+func (t ToolbarData) IsBottomPosition() bool {
+ return t.Position == "bl" || t.Position == "bc" || t.Position == "br"
+}
+
+// IsLeftPosition returns true if toolbar is on the left side
+func (t ToolbarData) IsLeftPosition() bool {
+ return t.Position == "tl" || t.Position == "bl"
+}
+
+// IsRightPosition returns true if toolbar is on the right side
+func (t ToolbarData) IsRightPosition() bool {
+ return t.Position == "tr" || t.Position == "br" || t.Position == ""
+}
+
+// IsCenterPosition returns true if toolbar is centered
+func (t ToolbarData) IsCenterPosition() bool {
+ return t.Position == "tc" || t.Position == "bc"
+}
+
+// DropdownDirection returns "up" or "down" based on toolbar vertical position
+func (t ToolbarData) DropdownDirection() string {
+ if t.IsBottomPosition() {
+ return "up"
+ }
+ return "down"
+}
+
+// DropdownAlign returns "left", "center", or "right" based on toolbar horizontal position
+func (t ToolbarData) DropdownAlign() string {
+ if t.IsLeftPosition() {
+ return "left"
+ } else if t.IsCenterPosition() {
+ return "center"
+ }
+ return "right"
+}
+
+// PositionClasses returns the positioning classes for the floating pill
+func (t ToolbarData) PositionClasses() string {
+ switch t.Position {
+ case "tl":
+ return "top-4 left-4"
+ case "tc":
+ return "top-4 left-1/2 -translate-x-1/2"
+ case "bl":
+ return "bottom-4 left-4"
+ case "bc":
+ return "bottom-4 left-1/2 -translate-x-1/2"
+ case "br":
+ return "bottom-4 right-4"
+ default: // "tr" or empty
+ return "top-4 right-4"
+ }
+}
+
+// IsBlogPost returns true if this is a blog post
+func (t ToolbarData) IsBlogPost() bool {
+ return t.PostType == "post"
+}
+
+// TrendIcon returns the trend icon for pageviews
+func (t ToolbarData) TrendIcon() string {
+ switch t.PageviewsTrend {
+ case "up":
+ return "↑"
+ case "down":
+ return "↓"
+ default:
+ return "→"
+ }
+}
+
+// TrendColorClass returns the color class for the trend indicator
+func (t ToolbarData) TrendColorClass() string {
+ switch t.PageviewsTrend {
+ case "up":
+ return "text-success"
+ case "down":
+ return "text-destructive"
+ default:
+ return "text-muted-foreground"
+ }
+}
+
+// AdminEditorToolbar renders the floating pill toolbar for admins on public pages
+// Uses inverted color scheme - dark on light backgrounds, light on dark backgrounds
+templ AdminEditorToolbar(data ToolbarData) {
+ if data.Enabled {
+