diff --git a/templates/bn/asset_hooks.go b/templates/bn/asset_hooks.go new file mode 100644 index 0000000..20525df --- /dev/null +++ b/templates/bn/asset_hooks.go @@ -0,0 +1,11 @@ +package bn + +// VersionedAssetURL resolves an asset URL to a cache-versioned form. The +// default is the identity function; the CMS host wires it to +// internal/assets.VersionedURL at startup so plugin stylesheet links get +// content-hash ?v= params. Guest-side (wasm) renders keep the identity +// default — asset hashing is a host concern. +// +// This file is part of the cms→core template sync set (make sync-templates); +// it must stay SDK-clean (no cms imports). +var VersionedAssetURL = func(url string) string { return url } diff --git a/templates/bn/engagement_templ.go b/templates/bn/engagement_templ.go index 9c0ca5c..6a9d669 100644 --- a/templates/bn/engagement_templ.go +++ b/templates/bn/engagement_templ.go @@ -62,7 +62,7 @@ func EngagementScript(config EngagementConfig) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(engagementConfigJSON(config)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bn/engagement.templ`, Line: 30, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { diff --git a/templates/bn/head.templ b/templates/bn/head.templ index f2a89e4..cfd2e26 100644 --- a/templates/bn/head.templ +++ b/templates/bn/head.templ @@ -16,34 +16,69 @@ func resolveMediaURL(url string) string { return url } +// canonicalURL resolves the canonical link. An admin-set value wins (made absolute +// against the page's own origin when it is root-relative); otherwise the page's own +// absolute URL is used as a self-referencing canonical. Returns "" only when neither +// is available. +func canonicalURL(canonical, pageURL string) string { + if canonical == "" { + return pageURL + } + if strings.HasPrefix(canonical, "http://") || strings.HasPrefix(canonical, "https://") { + return canonical + } + if strings.HasPrefix(canonical, "/") && pageURL != "" { + if i := strings.Index(pageURL, "://"); i >= 0 { + if j := strings.IndexByte(pageURL[i+3:], '/'); j >= 0 { + return pageURL[:i+3+j] + canonical + } + return pageURL + canonical + } + } + return canonical +} + // BrandingData contains generated favicon/icon URLs type BrandingData struct { - FaviconICO string // /brand/{id}/favicon.ico - Favicon16 string // /brand/{id}/favicon-16x16.png - Favicon32 string // /brand/{id}/favicon-32x32.png - AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180) - Android192 string // /brand/{id}/android-chrome-192x192.png - Android512 string // /brand/{id}/android-chrome-512x512.png - Maskable512 string // /brand/{id}/maskable-512x512.png - Master1024 string // /brand/{id}/icon-1024x1024.png - ManifestURL string // /brand/{id}/site.webmanifest + FaviconICO string // /.brand/{id}/favicon.ico + Favicon16 string // /.brand/{id}/favicon-16x16.png + Favicon32 string // /.brand/{id}/favicon-32x32.png + Favicon96 string // /.brand/{id}/favicon-96x96.png + AppleTouchIcon string // /.brand/{id}/apple-touch-icon.png (180x180) + Android192 string // /.brand/{id}/android-chrome-192x192.png + Android512 string // /.brand/{id}/android-chrome-512x512.png + Maskable512 string // /.brand/{id}/maskable-512x512.png + Master1024 string // /.brand/{id}/icon-1024x1024.png + ManifestURL string // /.brand/{id}/site.webmanifest + MaskIcon string // /.brand/{id}/mask-icon.svg + MSTile150 string // /.brand/{id}/mstile-150x150.png + BrowserConfig string // /.brand/{id}/browserconfig.xml ThemeColor string SVG string // Optional SVG pass-through IsGenerated bool } +// CustomScriptEntry represents a single custom script with placement control +type CustomScriptEntry struct { + Name string + Placement string // "head" or "body" + Code string + Enabled bool +} + // SiteSettingsData contains site-wide settings for head injection type SiteSettingsData struct { - Title string - Description string - Favicon string - Logo string - LogoAlt string - AppleTouchIcon string - GoogleAnalyticsID string - CustomHeadScripts string - CustomBodyScripts string - MetaDescription string + Title string + Description string + Favicon string + Logo string + LogoAlt string + AppleTouchIcon string + GoogleAnalyticsID string + CustomScripts []CustomScriptEntry + GoogleAnalyticsEnabled bool + GoogleAnalyticsExplicitlySet bool // true if the enabled flag was explicitly set in JSON (not nil) + MetaDescription string DefaultOGImage string TwitterHandle string OGSiteName string @@ -67,6 +102,10 @@ type PageMeta struct { TwitterImage string // Twitter card image URL CanonicalURL string // Canonical URL for this page RobotsDirective string // Robots directive (e.g., "noindex, nofollow") + OGType string // Open Graph type ("article" for posts, else "website") + PageURL string // Absolute URL of this page (og:url + auto-canonical) + ArticlePublishedTime string // ISO 8601 published time (articles only) + ArticleAuthor string // Author name (articles only) } // HeadData contains all data needed to render the
element @@ -159,15 +198,75 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData { } // Analytics + var legacyHeadScripts, legacyBodyScripts string if analyticsData, ok := siteData["analytics"].(map[string]any); ok { if v, ok := analyticsData["google_analytics_id"].(string); ok { settings.GoogleAnalyticsID = v } + // Read GA enabled flag — if present, use it; if absent but ID is set, default to enabled (backward compat) + if v, ok := analyticsData["google_analytics_enabled"].(bool); ok { + settings.GoogleAnalyticsEnabled = v + settings.GoogleAnalyticsExplicitlySet = true + } else if settings.GoogleAnalyticsID != "" { + settings.GoogleAnalyticsEnabled = true + } + // Preserve legacy flat fields for fallback if v, ok := analyticsData["custom_head_scripts"].(string); ok { - settings.CustomHeadScripts = v + legacyHeadScripts = v } if v, ok := analyticsData["custom_body_scripts"].(string); ok { - settings.CustomBodyScripts = v + legacyBodyScripts = v + } + } + + // Structured custom scripts + if scriptsArr, ok := siteData["custom_scripts"].([]any); ok && len(scriptsArr) > 0 { + for _, item := range scriptsArr { + entry, ok := item.(map[string]any) + if !ok { + continue + } + cs := CustomScriptEntry{} + if v, ok := entry["name"].(string); ok { + cs.Name = v + } + // Placement: proto enum stored as float64 (1=head, 2=body) or string + switch p := entry["placement"].(type) { + case float64: + if p == 1 { + cs.Placement = "head" + } else if p == 2 { + cs.Placement = "body" + } + case string: + cs.Placement = p + } + if v, ok := entry["code"].(string); ok { + cs.Code = v + } + if v, ok := entry["enabled"].(bool); ok { + cs.Enabled = v + } + settings.CustomScripts = append(settings.CustomScripts, cs) + } + } + // Fallback: if no structured scripts, migrate old flat fields + if len(settings.CustomScripts) == 0 { + if legacyHeadScripts != "" { + settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{ + Name: "Legacy Head Scripts", + Placement: "head", + Code: legacyHeadScripts, + Enabled: true, + }) + } + if legacyBodyScripts != "" { + settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{ + Name: "Legacy Body Scripts", + Placement: "body", + Code: legacyBodyScripts, + Enabled: true, + }) } } @@ -209,6 +308,18 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData { if v, ok := brandingData["svg"].(string); ok { settings.Branding.SVG = v } + if v, ok := brandingData["favicon_96"].(string); ok { + settings.Branding.Favicon96 = v + } + if v, ok := brandingData["mask_icon"].(string); ok { + settings.Branding.MaskIcon = v + } + if v, ok := brandingData["mstile_150"].(string); ok { + settings.Branding.MSTile150 = v + } + if v, ok := brandingData["browserconfig"].(string); ok { + settings.Branding.BrowserConfig = v + } } // Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon) @@ -276,6 +387,18 @@ func ParsePageMeta(doc map[string]any) PageMeta { if v, ok := doc["robotsDirective"].(string); ok { meta.RobotsDirective = v } + if v, ok := doc["ogType"].(string); ok { + meta.OGType = v + } + if v, ok := doc["pageUrl"].(string); ok { + meta.PageURL = v + } + if v, ok := doc["articlePublishedTime"].(string); ok { + meta.ArticlePublishedTime = v + } + if v, ok := doc["articleAuthor"].(string); ok { + meta.ArticleAuthor = v + } return meta } @@ -310,6 +433,12 @@ func ParseToolbarData(data map[string]any) ToolbarData { if v, ok := data["preview_mode"].(string); ok { toolbar.PreviewMode = v } + if v, ok := data["hide_preview_toggle"].(bool); ok { + toolbar.HidePreviewToggle = v + } + if v, ok := data["animate"].(bool); ok { + toolbar.Animate = v + } if v, ok := data["position"].(string); ok { toolbar.Position = v } @@ -390,15 +519,30 @@ templ Head(data HeadData) { @themeInitScript(data.ThemeMode) if data.Settings.Branding.IsGenerated { - // Use generated brand assets - - + // Use generated brand assets (modern RFG-parity set; SVG first) + if data.Settings.Branding.SVG != "" { + + } + if data.Settings.Branding.Favicon96 != "" { + + } + + + if data.Settings.Branding.MaskIcon != "" { + + } if data.Settings.Branding.ThemeColor != "" { } + if data.Settings.Branding.BrowserConfig != "" { + + } + if data.Title != "" { + + } } else { // Legacy fallback - manual favicon uploads if data.Settings.Favicon != "" { @@ -418,9 +562,9 @@ templ Head(data HeadData) { } - // Canonical URL (page-level only, no site default) - if data.PageMeta.CanonicalURL != "" { - + // Canonical URL: admin override (resolved to absolute) → auto self-canonical (M8) + if canonical := canonicalURL(data.PageMeta.CanonicalURL, data.PageMeta.PageURL); canonical != "" { + } // Robots directive (page-level only - defaults to index,follow if not set) @@ -459,8 +603,25 @@ templ Head(data HeadData) { } - // og:type - default to website - + // og:type: article for posts, website otherwise (M5) + if data.PageMeta.OGType != "" { + + } else { + + } + // og:url: absolute URL of this page (M6) + if data.PageMeta.PageURL != "" { + + } + // article:* metadata for posts (M5) + if data.PageMeta.OGType == "article" { + if data.PageMeta.ArticlePublishedTime != "" { + + } + if data.PageMeta.ArticleAuthor != "" { + + } + } // === TWITTER CARD META TAGS === if data.Settings.TwitterHandle != "" { @@ -512,7 +673,7 @@ templ Head(data HeadData) { } - if data.Settings.GoogleAnalyticsID != "" { + if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) { @googleAnalyticsScript(data.Settings.GoogleAnalyticsID) } @@ -521,14 +682,16 @@ templ Head(data HeadData) { // 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) + for _, script := range data.Settings.CustomScripts { + if script.Enabled && script.Placement == "head" { + @templ.Raw(script.Code) + } } if data.StructuredData != "" { @structuredDataScript(data.StructuredData) @@ -570,8 +733,10 @@ templ AdminBypassBanner(settings SiteSettingsData) { // BodyEnd renders custom scripts and admin toolbar before templ BodyEnd(settings SiteSettingsData) { @recordValidationCall(ctx, "BodyEnd") - if settings.CustomBodyScripts != "" { - @templ.Raw(settings.CustomBodyScripts) + for _, script := range settings.CustomScripts { + if script.Enabled && script.Placement == "body" { + @templ.Raw(script.Code) + } } // Render admin editor toolbar if enabled (auto-injects for all templates) @AdminEditorToolbar(settings.Toolbar) @@ -779,7 +944,6 @@ func themeInitScript(themeMode string) templ.Component { // preference (bn-theme cookie/localStorage) → site default → system. // Exposed as window.bnApplyTheme so the toolbar theme tester can re-apply // after changing the override without duplicating this resolution. - // KEEP IN SYNC with cms backend/templates/bn/head.templ. return templ.Raw(` ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.PageMeta.MetaDescription != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Settings.MetaDescription != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.PageMeta.TwitterImage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.PageMeta.OGImage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Settings.DefaultOGImage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Settings.LLMsTxtEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Settings.RSSFeedURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1160,20 +1500,20 @@ func Head(data HeadData) templ.Component { return templ_7745c5c3_Err } for _, style := range data.PluginStyles { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1184,10 +1524,12 @@ func Head(data HeadData) templ.Component { return templ_7745c5c3_Err } } - if data.Settings.CustomHeadScripts != "" { - templ_7745c5c3_Err = templ.Raw(data.Settings.CustomHeadScripts).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for _, script := range data.Settings.CustomScripts { + if script.Enabled && script.Placement == "head" { + templ_7745c5c3_Err = templ.Raw(script.Code).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } if data.StructuredData != "" { @@ -1200,7 +1542,7 @@ func Head(data HeadData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1231,28 +1573,28 @@ func AdminBypassBanner(settings SiteSettingsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var53 := templ.GetChildren(ctx) + if templ_7745c5c3_Var53 == nil { + templ_7745c5c3_Var53 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if settings.AdminBypassMode != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "