From 868df2d76173fea7107d6b25f1c0900b688b45c4 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Fri, 1 May 2026 09:17:55 +0800 Subject: [PATCH] feat: WO-PS-011 SDK templates/bn shared components Head, engagement, toolbar templ components and validation helpers for use by template plugins. Co-Authored-By: Claude Opus 4.6 --- templates/bn/doc.go | 1 - templates/bn/engagement.templ | 514 +++++++++++ templates/bn/engagement_templ.go | 80 ++ templates/bn/head.templ | 915 +++++++++++++++++++ templates/bn/head_templ.go | 1470 ++++++++++++++++++++++++++++++ templates/bn/toolbar.templ | 528 +++++++++++ templates/bn/toolbar_templ.go | 969 ++++++++++++++++++++ templates/bn/validation.go | 50 + 8 files changed, 4526 insertions(+), 1 deletion(-) delete mode 100644 templates/bn/doc.go create mode 100644 templates/bn/engagement.templ create mode 100644 templates/bn/engagement_templ.go create mode 100644 templates/bn/head.templ create mode 100644 templates/bn/head_templ.go create mode 100644 templates/bn/toolbar.templ create mode 100644 templates/bn/toolbar_templ.go create mode 100644 templates/bn/validation.go 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 <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() } + if data.CSSHash != "" { + + } + @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 != "" { + `) +} + +// 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 <head> 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, "<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") + 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 { +
+ + + + + + + + + +
+ + +
+ +
+ + + + if data.IsBlogPost() && data.ReadingTime > 0 { + + } + + if data.CanPublish() { + + } + +
+ +
+
+
+ + + } +} + +// PageInfoDropdown renders the page info and quick actions dropdown +templ PageInfoDropdown(data ToolbarData) { +
+ + if data.TodayPageviews > 0 || data.PageviewsTrend != "" { +
+
+
+ + + + { fmt.Sprintf("%d", data.TodayPageviews) } views today +
+ if data.TrendPercent != 0 { + + { data.TrendIcon() } { fmt.Sprintf("%d%%", abs(data.TrendPercent)) } + + } +
+
+ } + + if data.IsBlogPost() { +
+
+ if data.AuthorName != "" { +
+ + + + if data.AuthorSlug != "" { + { data.AuthorName } + } else { + { data.AuthorName } + } +
+ } + if data.ReadingTime > 0 { + { fmt.Sprintf("%d min read", data.ReadingTime) } + } +
+ if data.WordCount > 0 { +
{ fmt.Sprintf("%d words", data.WordCount) }
+ } +
+ } + +
+
+ Template + { data.TemplateName } +
+
+ Modified + { data.LastModifiedFormatted() } +
+
+ + + +
+ + +
+ +
+
Move toolbar
+
+ @positionButton(data.PageID, "tl", data.Position, "Top left") + @positionButton(data.PageID, "tc", data.Position, "Top center") + @positionButton(data.PageID, "tr", data.Position, "Top right") + @positionButton(data.PageID, "bl", data.Position, "Bottom left") + @positionButton(data.PageID, "bc", data.Position, "Bottom center") + @positionButton(data.PageID, "br", data.Position, "Bottom right") +
+
+ + if data.HasUnpublishedChanges { +
+ +
+ } +
+} + +// positionButton renders a position selector button +templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) { + +} + +// abs returns the absolute value of an integer +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// copyPageURL generates the script to copy page URL +script copyPageURL(slug string) { + const url = window.location.origin + slug; + navigator.clipboard.writeText(url).then(() => { + // Show brief feedback - could enhance with toast later + const btn = event.currentTarget; + const originalText = btn.innerHTML; + btn.innerHTML = ' Copied!'; + setTimeout(() => { btn.innerHTML = originalText; }, 2000); + }); +} + +// togglePreviewMode toggles between preview (draft) and published mode via URL +script togglePreviewMode(isCurrentlyPreview bool) { + const url = new URL(window.location.href); + if (isCurrentlyPreview) { + // Currently previewing, remove preview param + url.searchParams.delete('preview'); + } else { + // Not previewing, add preview param + url.searchParams.set('preview', '1'); + } + window.location.href = url.toString(); +} diff --git a/templates/bn/toolbar_templ.go b/templates/bn/toolbar_templ.go new file mode 100644 index 0000000..2f7cfd7 --- /dev/null +++ b/templates/bn/toolbar_templ.go @@ -0,0 +1,969 @@ +// 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 ( + "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 +func AdminEditorToolbar(data ToolbarData) 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 data.Enabled { + var templ_7745c5c3_Var2 = []any{"bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses()} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Edit
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 = []any{"w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 = []any{"bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode())} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, togglePreviewMode(data.IsPreviewMode())) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsBlogPost() && data.ReadingTime > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.CanPublish() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// PageInfoDropdown renders the page info and quick actions dropdown +func PageInfoDropdown(data ToolbarData) 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_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.TodayPageviews > 0 || data.PageviewsTrend != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TodayPageviews)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 358, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " views today
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.TrendPercent != 0 { + var templ_7745c5c3_Var20 = []any{"text-xs font-medium", data.TrendColorClass()} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.TrendIcon()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 362, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%%", abs(data.TrendPercent))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 362, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsBlogPost() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.AuthorName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.AuthorSlug != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.AuthorName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 378, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.AuthorName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 380, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.ReadingTime > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min read", data.ReadingTime)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 385, Col: 86} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.WordCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d words", data.WordCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 389, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
Template ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(data.TemplateName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 397, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
Modified ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastModifiedFormatted()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 401, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, copyPageURL(data.PageSlug)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
Move toolbar
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = positionButton(data.PageID, "tl", data.Position, "Top left").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = positionButton(data.PageID, "tc", data.Position, "Top center").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = positionButton(data.PageID, "tr", data.Position, "Top right").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = positionButton(data.PageID, "bl", data.Position, "Bottom left").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = positionButton(data.PageID, "bc", data.Position, "Bottom center").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = positionButton(data.PageID, "br", data.Position, "Bottom right").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasUnpublishedChanges { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// positionButton renders a position selector button +func positionButton(pageID uuid.UUID, pos string, currentPos string, label 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_Var37 := templ.GetChildren(ctx) + if templ_7745c5c3_Var37 == nil { + templ_7745c5c3_Var37 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var38 = []any{"w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bg-info border-info", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-border bn-dd-hover", pos != currentPos && !(currentPos == "" && pos == "tr"))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// abs returns the absolute value of an integer +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// copyPageURL generates the script to copy page URL +func copyPageURL(slug string) templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_copyPageURL_044c`, + Function: `function __templ_copyPageURL_044c(slug){const url = window.location.origin + slug; + navigator.clipboard.writeText(url).then(() => { + // Show brief feedback - could enhance with toast later + const btn = event.currentTarget; + const originalText = btn.innerHTML; + btn.innerHTML = ' Copied!'; + setTimeout(() => { btn.innerHTML = originalText; }, 2000); + }); +}`, + Call: templ.SafeScript(`__templ_copyPageURL_044c`, slug), + CallInline: templ.SafeScriptInline(`__templ_copyPageURL_044c`, slug), + } +} + +// togglePreviewMode toggles between preview (draft) and published mode via URL +func togglePreviewMode(isCurrentlyPreview bool) templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_togglePreviewMode_114f`, + Function: `function __templ_togglePreviewMode_114f(isCurrentlyPreview){const url = new URL(window.location.href); + if (isCurrentlyPreview) { + // Currently previewing, remove preview param + url.searchParams.delete('preview'); + } else { + // Not previewing, add preview param + url.searchParams.set('preview', '1'); + } + window.location.href = url.toString(); +}`, + Call: templ.SafeScript(`__templ_togglePreviewMode_114f`, isCurrentlyPreview), + CallInline: templ.SafeScriptInline(`__templ_togglePreviewMode_114f`, isCurrentlyPreview), + } +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/bn/validation.go b/templates/bn/validation.go new file mode 100644 index 0000000..2315eb5 --- /dev/null +++ b/templates/bn/validation.go @@ -0,0 +1,50 @@ +package bn + +import ( + "context" + "io" + + "github.com/a-h/templ" +) + +// validationContextKey is the key used to store validation tracking in context. +type validationContextKey struct{} + +// ValidationTracker tracks which bn functions are called during template rendering. +// Used during template registration to validate templates include required calls. +type ValidationTracker struct { + HeadCalled bool + BodyEndCalled bool +} + +// NewValidationContext creates a context with validation tracking enabled. +// Use GetValidationTracker after rendering to check which functions were called. +func NewValidationContext(ctx context.Context) (context.Context, *ValidationTracker) { + tracker := &ValidationTracker{} + return context.WithValue(ctx, validationContextKey{}, tracker), tracker +} + +// GetValidationTracker retrieves the validation tracker from context, if any. +func GetValidationTracker(ctx context.Context) *ValidationTracker { + if tracker, ok := ctx.Value(validationContextKey{}).(*ValidationTracker); ok { + return tracker + } + return nil +} + +// recordValidationCall records that a bn function was called during rendering. +// Returns an empty component (renders nothing) but has the side effect of tracking the call. +func recordValidationCall(ctx context.Context, fnName string) templ.Component { + if tracker := GetValidationTracker(ctx); tracker != nil { + switch fnName { + case "Head": + tracker.HeadCalled = true + case "BodyEnd": + tracker.BodyEndCalled = true + } + } + // Return empty component - renders nothing + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + return nil + }) +}