feat!: delete guest chrome (templates/bn, templates/pongo) — host composes the envelope (P3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-07 13:32:22 +08:00
parent 80328eddb1
commit 27e57b8837
14 changed files with 5 additions and 5395 deletions

View File

@ -15,6 +15,11 @@ between first-party entities (cms, orchestrator, ninja CLI).
surface here — `plugin/`, `plugin/wasmguest/**`, and the guest-facing type
packages — is **one language binding** of the SDK. A non-Go plugin implements
the ABI hooks directly and never sees these Go structs.
- **The SDK ships no page chrome.** A template renders only its
`TemplateDocument`; the **host** composes the document envelope
(doctype / `<html>` / `<head>` / `<body>` + bn chrome) around it. A guest
expresses its envelope needs (title, meta, asset hooks) through
`templates.PageDocument` — never by emitting head/body markup itself.
## Critical Rules
@ -25,15 +30,6 @@ between first-party entities (cms, orchestrator, ninja CLI).
API and update consumers.
- Regenerate Go bindings after editing any `.proto`: `make proto`.
## `templates/bn/` is a synced copy — do not author here
The bn page chrome (head / toolbar / engagement / asset_hooks) is **authored in
`cms/backend/templates/bn`** and mechanically copied here via cms `make
sync-templates`, solely so guest-side plugin templates can compile it into their
wasm. check-safety **check 31** fails cms commits while the copies drift. Never
edit these files here directly — change them in cms and sync. (This chrome is
transitional: it is deleted once host-side head/body injection lands — Phase 3.)
## Design
Program spec (in the cms repo):

View File

@ -1,11 +0,0 @@
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 }

View File

@ -1,514 +0,0 @@
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 != "" {
<script data-engagement-config={ engagementConfigJSON(config) }>
(function(){
'use strict';
// Parse config from script tag
var script = document.currentScript;
var cfg;
try { cfg = JSON.parse(script.getAttribute('data-engagement-config')); } catch(e) { return; }
if (!cfg.isPost) return;
// =================================================================
// State
// =================================================================
var state = {
sessionId: cfg.sessionId || crypto.randomUUID(),
visitorHash: cfg.visitorHash || '',
pageStart: Date.now(),
activeStart: Date.now(),
totalActiveTime: 0,
isActive: true,
lastActivityTime: Date.now(),
maxScrollPercent: 0,
scrollMilestones: {},
viewedHeadings: {},
currentHeading: null,
mouseMovements: 0,
scrollEvents: [],
lastScrollTime: 0,
lastScrollPos: 0,
directionChanges: 0,
eventQueue: [],
flushTimer: null,
clickPositions: [],
interactiveTags: {'A':1,'BUTTON':1,'INPUT':1,'SELECT':1,'TEXTAREA':1,'LABEL':1},
sentExit: false
};
// =================================================================
// Utility Functions
// =================================================================
function getScrollPercent() {
var docHeight = document.documentElement.scrollHeight - window.innerHeight;
if (docHeight <= 0) return 100;
return Math.min(100, Math.round((window.scrollY / docHeight) * 100));
}
function getContentDepthPercent() {
var content = document.querySelector('[data-slot="main"]') ||
document.querySelector('article') ||
document.querySelector('main');
if (!content) return getScrollPercent();
var rect = content.getBoundingClientRect();
var contentTop = window.scrollY + rect.top;
var contentHeight = rect.height;
var viewportBottom = window.scrollY + window.innerHeight;
var contentViewed = Math.max(0, viewportBottom - contentTop);
return Math.min(100, Math.round((contentViewed / contentHeight) * 100));
}
function getVisibleHeadings() {
var headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
var visible = [];
var viewportTop = window.scrollY;
var viewportBottom = viewportTop + window.innerHeight;
for (var i = 0; i < headings.length; i++) {
var h = headings[i];
var rect = h.getBoundingClientRect();
var absTop = window.scrollY + rect.top;
var absBottom = absTop + rect.height;
if (absBottom >= viewportTop && absTop <= viewportBottom) {
visible.push({
id: h.id || 'heading-' + i,
text: (h.textContent || '').trim().substring(0, 100),
level: parseInt(h.tagName.substring(1)),
top: absTop
});
}
}
return visible;
}
function hashPhrase(str) {
var hash = 0;
for (var i = 0; i < str.length; i++) {
var chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash.toString(16);
}
// =================================================================
// Quality Score Calculation
// =================================================================
function calculateQualityScore() {
var score = 50;
// Positive signals
if (state.mouseMovements > 10) score += 10;
if (state.totalActiveTime > 30000) score += 15;
if (state.scrollEvents.length > 5) score += 10;
// Scroll naturalness
if (state.scrollEvents.length >= 3) {
var intervals = [];
for (var i = 1; i < state.scrollEvents.length; i++) {
intervals.push(state.scrollEvents[i].time - state.scrollEvents[i-1].time);
}
var avg = intervals.reduce(function(a,b){ return a+b; }, 0) / intervals.length;
var variance = intervals.reduce(function(sum, i){ return sum + Math.pow(i - avg, 2); }, 0) / intervals.length;
if (variance > 50000) score += 10;
if (variance < 1000 && intervals.length > 10) score -= 20;
}
// Direction changes indicate engaged reading
if (state.directionChanges > 2) score += 5;
// Time vs content
var expectedReadTime = (cfg.postWordCount / 200) * 60 * 1000;
var actualTime = Date.now() - state.pageStart;
var readRatio = actualTime / expectedReadTime;
if (readRatio > 0.3 && readRatio < 5) score += 10;
// Negative signals
if (document.hidden && state.totalActiveTime < 5000) score -= 20;
// Instant 100% scroll is suspicious
if (state.maxScrollPercent >= 100 && actualTime < 3000) score -= 30;
return Math.max(0, Math.min(100, score));
}
// =================================================================
// Event Queue & Batching
// =================================================================
function queueEvent(type, name, data) {
state.eventQueue.push({
t: type,
n: name,
d: data,
ts: Date.now()
});
if (state.eventQueue.length >= 10) {
flushEvents();
} else if (!state.flushTimer) {
state.flushTimer = setTimeout(flushEvents, 5000);
}
}
function flushEvents() {
if (state.flushTimer) {
clearTimeout(state.flushTimer);
state.flushTimer = null;
}
if (state.eventQueue.length === 0) return;
var payload = {
pp: cfg.pagePath,
pid: cfg.pageId,
sid: state.sessionId,
vh: state.visitorHash,
qs: calculateQualityScore(),
events: state.eventQueue.slice()
};
state.eventQueue = [];
navigator.sendBeacon('/api/engagement', JSON.stringify(payload));
}
// =================================================================
// Scroll Tracking
// =================================================================
var scrollThrottle = null;
function handleScroll() {
if (scrollThrottle) return;
scrollThrottle = setTimeout(function() {
scrollThrottle = null;
var percent = getScrollPercent();
var contentPercent = getContentDepthPercent();
var now = Date.now();
// Track scroll direction changes
var currentPos = window.scrollY;
if (state.lastScrollPos !== 0) {
var wasGoingDown = state.scrollEvents.length > 0 &&
state.scrollEvents[state.scrollEvents.length - 1].pos < currentPos;
var isGoingDown = currentPos > state.lastScrollPos;
if (state.scrollEvents.length > 1 && wasGoingDown !== isGoingDown) {
state.directionChanges++;
}
}
state.lastScrollPos = currentPos;
// Track scroll event for quality scoring
state.scrollEvents.push({ time: now, percent: percent, pos: currentPos });
if (state.scrollEvents.length > 50) state.scrollEvents.shift();
// Detect jump scrolling (>500px instant)
if (state.scrollEvents.length >= 2) {
var prev = state.scrollEvents[state.scrollEvents.length - 2];
var posDiff = Math.abs(currentPos - prev.pos);
var timeDiff = now - prev.time;
if (posDiff > 500 && timeDiff < 50) {
queueEvent('navigation', 'jump_scroll', { distance: posDiff });
}
}
// Detect erratic scrolling (rapid direction changes)
if (state.directionChanges > 5 && now - state.pageStart < 10000) {
queueEvent('frustration', 'erratic_scroll', {});
state.directionChanges = 0;
}
// Update max scroll
if (percent > state.maxScrollPercent) {
state.maxScrollPercent = percent;
}
// Check milestones
[25, 50, 75, 100].forEach(function(milestone) {
if (percent >= milestone && !state.scrollMilestones[milestone]) {
state.scrollMilestones[milestone] = true;
queueEvent('scroll', 'scroll_' + milestone, {
maxScrollPercent: state.maxScrollPercent,
contentDepthPercent: contentPercent,
timeToMilestoneMs: now - state.pageStart
});
}
});
// Track heading visibility
var visible = getVisibleHeadings();
var newHeading = visible[0];
if (state.currentHeading && (!newHeading || newHeading.id !== state.currentHeading.id)) {
var entry = state.viewedHeadings[state.currentHeading.id];
if (entry) {
entry.totalDwell += now - entry.lastEnter;
queueEvent('section', 'heading_viewed', {
headingId: state.currentHeading.id,
headingText: state.currentHeading.text,
headingLevel: state.currentHeading.level,
dwellTimeMs: entry.totalDwell
});
}
}
if (newHeading && (!state.currentHeading || newHeading.id !== state.currentHeading.id)) {
if (!state.viewedHeadings[newHeading.id]) {
state.viewedHeadings[newHeading.id] = { lastEnter: now, totalDwell: 0 };
} else {
var e = state.viewedHeadings[newHeading.id];
e.lastEnter = now;
queueEvent('section', 'reread', {
headingId: newHeading.id,
headingText: newHeading.text
});
}
}
state.currentHeading = newHeading;
state.lastScrollTime = now;
}, 100);
}
// =================================================================
// Visibility / Active Time Tracking
// =================================================================
function handleVisibilityChange() {
var now = Date.now();
if (document.hidden) {
if (state.isActive) {
state.totalActiveTime += now - state.activeStart;
state.isActive = false;
}
// Early blur might indicate bounce intent
if (now - state.pageStart < 10000 && state.maxScrollPercent < 25) {
queueEvent('frustration', 'early_blur', { timeOnPage: now - state.pageStart });
}
} else {
state.activeStart = now;
state.isActive = true;
}
}
// Idle detection
function handleActivity() {
state.lastActivityTime = Date.now();
}
// =================================================================
// Interaction Tracking
// =================================================================
var selectionTimeout = null;
function handleTextSelection() {
if (selectionTimeout) clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(function() {
var selection = window.getSelection();
if (selection && selection.toString().trim().length > 5) {
var text = selection.toString().trim();
if (text.length > 50) text = text.substring(0, 50);
var range = selection.getRangeAt(0);
var container = range.commonAncestorContainer;
var nearestHeading = container.parentElement?.closest('h1,h2,h3,h4,h5,h6');
queueEvent('interaction', 'text_select', {
phrase: text,
phraseHash: hashPhrase(text),
length: selection.toString().length,
nearHeading: nearestHeading?.id || null,
nearHeadingText: nearestHeading?.textContent?.substring(0, 50) || null
});
}
}, 500);
}
function handleCopy(e) {
var selection = window.getSelection()?.toString() || '';
queueEvent('interaction', 'copy', {
length: selection.length
});
}
function handleLinkClick(e) {
var link = e.target.closest('a');
if (!link || !link.href) return;
var isExternal = !link.href.startsWith(window.location.origin);
var isTOC = link.closest('[class*="toc"]') || link.closest('nav[aria-label*="table"]');
var isAnchor = link.href.includes('#') && link.href.split('#')[0] === window.location.href.split('#')[0];
if (isTOC) {
queueEvent('navigation', 'toc_click', {});
} else if (isAnchor) {
queueEvent('navigation', 'anchor_jump', {});
} else {
queueEvent('interaction', 'link_click', {
isExternal: isExternal,
domain: isExternal ? new URL(link.href).hostname : null
});
}
}
function handleClick(e) {
var now = Date.now();
var x = e.clientX, y = e.clientY;
// Track click position for rage click detection
state.clickPositions.push({ x: x, y: y, time: now });
if (state.clickPositions.length > 10) state.clickPositions.shift();
// Rage click detection: 3+ clicks within 500ms in 50px radius
var recentClicks = state.clickPositions.filter(function(c) {
return now - c.time < 500 &&
Math.abs(c.x - x) < 50 &&
Math.abs(c.y - y) < 50;
});
if (recentClicks.length >= 3) {
queueEvent('frustration', 'rage_click', { count: recentClicks.length });
state.clickPositions = [];
}
// Dead click detection: click on non-interactive element
var tag = e.target.tagName;
var isInteractive = state.interactiveTags[tag] ||
e.target.onclick ||
e.target.closest('a') ||
e.target.closest('button') ||
e.target.getAttribute('role') === 'button';
if (!isInteractive && e.target.closest('[data-slot="main"]')) {
queueEvent('frustration', 'dead_click', {});
}
}
// Image viewing
function initImageTracking() {
var images = document.querySelectorAll('article img, [data-slot="main"] img');
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
queueEvent('interaction', 'image_view', {
alt: (entry.target.alt || '').substring(0, 50)
});
observer.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
images.forEach(function(img) { observer.observe(img); });
}
// Code block interaction
function initCodeBlockTracking() {
var blocks = document.querySelectorAll('pre, code');
blocks.forEach(function(block) {
block.addEventListener('click', function() {
queueEvent('interaction', 'code_interact', {});
}, { once: true });
});
}
// Keyboard shortcuts
function handleKeyDown(e) {
// Ctrl+F / Cmd+F detection
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
queueEvent('frustration', 'search_attempt', {});
}
// Ctrl+P / Cmd+P detection
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
queueEvent('navigation', 'print_attempt', {});
}
}
function handleMouseMove() {
state.mouseMovements++;
state.lastActivityTime = Date.now();
}
// =================================================================
// Page Exit
// =================================================================
function handlePageExit() {
if (state.sentExit) return;
state.sentExit = true;
// Finalize active time
if (state.isActive) {
state.totalActiveTime += Date.now() - state.activeStart;
}
// Calculate reading velocity
var readTimeMinutes = state.totalActiveTime / 60000;
var wpm = readTimeMinutes > 0 ? cfg.postWordCount / readTimeMinutes : 0;
// Send exit event
queueEvent('scroll', 'page_exit', {
exitScrollPercent: state.maxScrollPercent,
totalTimeMs: Date.now() - state.pageStart,
activeTimeMs: state.totalActiveTime,
readingVelocityWpm: Math.round(wpm),
maxScrollPercent: state.maxScrollPercent,
directionChanges: state.directionChanges
});
// Flush immediately on exit
flushEvents();
}
// =================================================================
// Initialize
// =================================================================
window.addEventListener('scroll', handleScroll, { passive: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('selectionchange', handleTextSelection);
document.addEventListener('copy', handleCopy);
document.addEventListener('click', handleClick);
document.addEventListener('click', handleLinkClick);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('touchstart', handleActivity, { passive: true });
window.addEventListener('beforeunload', handlePageExit);
window.addEventListener('pagehide', handlePageExit);
// Initialize observers
initImageTracking();
initCodeBlockTracking();
// Initial scroll check
setTimeout(handleScroll, 100);
})();
</script>
}
}

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,686 +0,0 @@
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"
HidePreviewToggle bool
Animate bool // true only on initial full-page render (entrance animation); false on HTMX re-renders
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"
}
// EnterAnimationClass returns the entrance-animation classes for the initial
// full-page render. It slides the pill in from the edge it rests against (up for
// bottom positions, down for top) then gives a brief pulse. Empty on HTMX
// re-renders (Animate=false) so publish/discard/reposition swaps appear instantly.
func (t ToolbarData) EnterAnimationClass() string {
if !t.Animate {
return ""
}
if t.IsBottomPosition() {
return "bn-toolbar-enter bn-toolbar-enter-up"
}
return "bn-toolbar-enter bn-toolbar-enter-down"
}
// 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 {
<div
id="bn-admin-toolbar"
class={ "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(), data.EnterAnimationClass() }
>
<style>
/* Toolbar: dark by default (for light pages), light when page has .dark class */
.bn-toolbar {
--bn-toolbar-bg: rgba(24, 24, 27, 0.95);
--bn-toolbar-fg: #fafafa;
--bn-toolbar-muted: #a1a1aa;
--bn-toolbar-border: rgba(63, 63, 70, 0.8);
--bn-toolbar-hover: rgba(63, 63, 70, 0.5);
--bn-toolbar-primary-bg: #3b82f6;
--bn-toolbar-primary-fg: #ffffff;
background: var(--bn-toolbar-bg);
color: var(--bn-toolbar-fg);
border: 1px solid var(--bn-toolbar-border);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
}
/* Entrance: slide in from the resting edge, then a brief pulse.
Animates `transform` only; Tailwind v4 centres via the separate
`translate` property, so `-translate-x-1/2` is preserved. Runs
~1s after render, 500ms total, initial page load only. */
@keyframes bn-toolbar-slide-up {
0% { opacity: 0; transform: translateY(1.25rem); }
60% { opacity: 1; transform: translateY(0) scale(1); }
80% { transform: translateY(0) scale(1.05); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes bn-toolbar-slide-down {
0% { opacity: 0; transform: translateY(-1.25rem); }
60% { opacity: 1; transform: translateY(0) scale(1); }
80% { transform: translateY(0) scale(1.05); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.bn-toolbar-enter {
animation-duration: 500ms;
animation-delay: 900ms;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
}
.bn-toolbar-enter-up { animation-name: bn-toolbar-slide-up; }
.bn-toolbar-enter-down { animation-name: bn-toolbar-slide-down; }
@media (prefers-reduced-motion: reduce) {
.bn-toolbar-enter { animation: none; }
}
/* Light toolbar when page theme is dark (.dark class on html or body) */
.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {
--bn-toolbar-bg: rgba(250, 250, 250, 0.95);
--bn-toolbar-fg: #18181b;
--bn-toolbar-muted: #71717a;
--bn-toolbar-border: rgba(228, 228, 231, 0.8);
--bn-toolbar-hover: rgba(228, 228, 231, 0.5);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.25);
}
.bn-toolbar .bn-toolbar-text { color: var(--bn-toolbar-fg); }
.bn-toolbar .bn-toolbar-muted { color: var(--bn-toolbar-muted); }
.bn-toolbar .bn-toolbar-divider { background: var(--bn-toolbar-border); }
.bn-toolbar .bn-toolbar-hover:hover { background: var(--bn-toolbar-hover); }
.bn-toolbar .bn-toolbar-primary {
background: var(--bn-toolbar-primary-bg);
color: var(--bn-toolbar-primary-fg);
}
.bn-toolbar .bn-toolbar-primary:hover {
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black);
}
/* Theme tester: highlighted while a light/dark override is forced.
Uses toolbar vars (not site theme tokens) so it renders on any site. */
.bn-toolbar .bn-theme-tester-active,
.bn-toolbar .bn-theme-tester-active:hover {
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 20%, transparent);
color: var(--bn-toolbar-primary-bg);
}
/* Dropdown styling - inherits toolbar vars */
.bn-toolbar-dropdown {
background: var(--bn-toolbar-bg);
color: var(--bn-toolbar-fg);
border: 1px solid var(--bn-toolbar-border);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
}
.bn-toolbar-dropdown .bn-dd-border { border-color: var(--bn-toolbar-border); }
.bn-toolbar-dropdown .bn-dd-text { color: var(--bn-toolbar-fg); }
.bn-toolbar-dropdown .bn-dd-muted { color: var(--bn-toolbar-muted); }
.bn-toolbar-dropdown .bn-dd-hover:hover { background: var(--bn-toolbar-hover); }
.bn-toolbar-dropdown .bn-dd-link { color: var(--bn-toolbar-primary-bg); }
.bn-toolbar-dropdown .bn-dd-link:hover { text-decoration: underline; }
/* Dropdown positioning based on data attributes */
.bn-dropdown-container { position: absolute; }
.bn-dropdown-container[data-direction="up"] { bottom: 100%; margin-bottom: 0.5rem; }
.bn-dropdown-container[data-direction="down"] { top: 100%; margin-top: 0.5rem; }
.bn-dropdown-container[data-align="left"] { left: 0; }
.bn-dropdown-container[data-align="right"] { right: 0; }
.bn-dropdown-container[data-align="center"] { left: 50%; transform: translateX(-50%); }
</style>
<!-- Edit button -->
<a
href={ templ.SafeURL(data.EditURL) }
class="bn-toolbar-primary inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-colors"
title="Edit this page"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
<span class="hidden sm:inline">Edit</span>
</a>
<!-- Status indicator -->
<div class="flex items-center gap-1.5 px-2" title={ data.StatusLabel() }>
<span class={ "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) }></span>
<span class="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span>
</div>
if !data.HidePreviewToggle {
<!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div>
<!-- Preview toggle - compact -->
<button
type="button"
class={ "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()) }
onclick={ togglePreviewMode(data.IsPreviewMode()) }
title={ func() string { if data.IsPreviewMode() { return "Viewing draft - click to view published" } else { return "Viewing published - click to preview draft" } }() }
>
if data.IsPreviewMode() {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
<span class="hidden sm:inline">Draft</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="hidden sm:inline">Live</span>
}
</button>
}
<!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div>
<!-- Theme tester: cycles site default → forced light → forced dark.
Per-tab override (sessionStorage), applied by bnApplyTheme in head.templ.
Never touches the visitor bn-theme preference. -->
<button
type="button"
id="bn-theme-tester"
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors"
onclick="bnCycleThemeTester()"
title="Theme: site default — click to force light"
>
<span data-tt="auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</span>
<span data-tt="light" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</span>
<span data-tt="dark" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</span>
<span data-tt-label class="hidden sm:inline" style="display:none"></span>
</button>
<script>
// Theme tester: per-tab light/dark override for admins. Lives inside
// #bn-admin-toolbar so HTMX outerHTML swaps re-run the sync call.
window.bnCycleThemeTester = function() {
var cur = null;
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
var next = cur === 'light' ? 'dark' : (cur === 'dark' ? null : 'light');
try {
if (next) sessionStorage.setItem('bn-theme-override', next);
else sessionStorage.removeItem('bn-theme-override');
} catch (e) {}
if (window.bnApplyTheme) {
window.bnApplyTheme();
} else if (next) {
// Fallback for templates without the bn head script
document.documentElement.classList.toggle('dark', next === 'dark');
}
window.bnSyncThemeTester();
// Let darkmode-switcher blocks refresh their icons
var mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
document.querySelectorAll('[data-bn-theme-toggle]').forEach(function(el) {
el.dispatchEvent(new CustomEvent('bn:theme-changed', { detail: { mode: mode } }));
});
};
window.bnSyncThemeTester = function() {
var btn = document.getElementById('bn-theme-tester');
if (!btn) return;
var cur = null;
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
var state = (cur === 'light' || cur === 'dark') ? cur : 'auto';
btn.querySelectorAll('[data-tt]').forEach(function(s) {
s.style.display = s.getAttribute('data-tt') === state ? '' : 'none';
});
var lbl = btn.querySelector('[data-tt-label]');
if (state === 'auto') {
lbl.style.display = 'none';
lbl.textContent = '';
} else {
lbl.style.display = '';
lbl.textContent = state === 'light' ? 'Light' : 'Dark';
}
btn.classList.toggle('bn-theme-tester-active', state !== 'auto');
btn.classList.toggle('bn-toolbar-muted', state === 'auto');
btn.title = state === 'auto'
? 'Theme: site default — click to force light'
: (state === 'light'
? 'Theme: forced light — click to force dark'
: 'Theme: forced dark — click to reset to site default');
};
window.bnSyncThemeTester();
</script>
<!-- Blog-specific: Reading time -->
if data.IsBlogPost() && data.ReadingTime > 0 {
<div class="bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs" title="Reading time">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
</svg>
{ fmt.Sprintf("%d min", data.ReadingTime) }
</div>
}
<!-- Publish button (if changes) -->
if data.CanPublish() {
<button
type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-success hover:bg-success/90 text-success-foreground rounded-full transition-colors"
hx-post={ fmt.Sprintf("/toolbar/publish/%s", data.PageID) }
hx-target="#bn-admin-toolbar"
hx-swap="outerHTML"
hx-confirm="Publish this page?"
title="Publish changes"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="hidden sm:inline">Publish</span>
</button>
}
<!-- More menu -->
<div class="relative" id="more-menu-container">
<button
type="button"
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center justify-center w-8 h-8 rounded-full transition-colors"
hx-get={ fmt.Sprintf("/toolbar/page-info/%s", data.PageID) }
hx-target="#page-info-dropdown"
hx-trigger="click"
hx-swap="innerHTML"
title="More options"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"></path>
</svg>
</button>
<div
id="page-info-dropdown"
class="bn-dropdown-container"
data-direction={ data.DropdownDirection() }
data-align={ data.DropdownAlign() }
></div>
</div>
</div>
<!-- Click outside to close dropdowns -->
<script>
document.addEventListener('click', function(e) {
if (!e.target.closest('#more-menu-container')) {
document.getElementById('page-info-dropdown').innerHTML = '';
}
});
</script>
}
}
// PageInfoDropdown renders the page info and quick actions dropdown
templ PageInfoDropdown(data ToolbarData) {
<div class="bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]">
<style>
/* Position-picker chips - shipped with the fragment so styling never
desyncs from the separately-rendered toolbar shell. Uses toolbar
vars (resolve via the enclosing .bn-toolbar) to stay visible in
both inverted themes. */
.bn-toolbar-dropdown .bn-dd-poschip { border-color: var(--bn-toolbar-border); }
.bn-toolbar-dropdown .bn-dd-poschip:hover { background: var(--bn-toolbar-hover); }
.bn-toolbar-dropdown .bn-dd-poschip-active,
.bn-toolbar-dropdown .bn-dd-poschip-active:hover {
background: var(--bn-toolbar-primary-bg);
border-color: var(--bn-toolbar-primary-bg);
}
.bn-toolbar-dropdown .bn-dd-dot { background: var(--bn-toolbar-muted); }
.bn-toolbar-dropdown .bn-dd-dot-active { background: var(--bn-toolbar-primary-fg); }
</style>
<!-- Analytics snapshot -->
if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
<div class="bn-dd-border px-3 py-2 border-b">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
</svg>
<span class="bn-dd-text text-sm font-medium">{ fmt.Sprintf("%d", data.TodayPageviews) } views today</span>
</div>
if data.TrendPercent != 0 {
<span class={ "text-xs font-medium", data.TrendColorClass() }>
{ data.TrendIcon() } { fmt.Sprintf("%d%%", abs(data.TrendPercent)) }
</span>
}
</div>
</div>
}
<!-- Blog-specific: Author & Reading time -->
if data.IsBlogPost() {
<div class="bn-dd-border px-3 py-2 border-b">
<div class="flex items-center justify-between text-sm">
if data.AuthorName != "" {
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
</svg>
if data.AuthorSlug != "" {
<a href={ templ.SafeURL("/author/" + data.AuthorSlug) } class="bn-dd-link">{ data.AuthorName }</a>
} else {
<span class="bn-dd-text">{ data.AuthorName }</span>
}
</div>
}
if data.ReadingTime > 0 {
<span class="bn-dd-muted text-xs">{ fmt.Sprintf("%d min read", data.ReadingTime) }</span>
}
</div>
if data.WordCount > 0 {
<div class="bn-dd-muted text-xs mt-1">{ fmt.Sprintf("%d words", data.WordCount) }</div>
}
</div>
}
<!-- Page info -->
<div class="bn-dd-border bn-dd-muted px-3 py-2 border-b text-xs">
<div class="flex justify-between">
<span>Template</span>
<span class="bn-dd-text">{ data.TemplateName }</span>
</div>
<div class="flex justify-between mt-1">
<span>Modified</span>
<span class="bn-dd-text">{ data.LastModifiedFormatted() }</span>
</div>
</div>
<!-- Quick actions -->
<div class="py-1">
<a href={ templ.SafeURL(data.SettingsURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
Settings
</a>
<a href={ templ.SafeURL(data.HistoryURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
</svg>
History
</a>
<a href={ templ.SafeURL(data.AnalyticsURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
</svg>
Analytics
</a>
</div>
<!-- Sharing -->
<div class="bn-dd-border border-t py-1">
<button
type="button"
class="bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
onclick={ copyPageURL(data.PageSlug) }
>
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"></path>
</svg>
Copy link
</button>
<button
type="button"
class="bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
hx-post={ fmt.Sprintf("/toolbar/share-preview/%s", data.PageID) }
hx-swap="innerHTML"
hx-target="this"
>
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z"></path>
</svg>
Share preview link
</button>
</div>
<!-- Position selector -->
<div class="bn-dd-border border-t px-3 py-2">
<div class="bn-dd-muted text-xs mb-2">Move toolbar</div>
<div class="grid grid-cols-3 gap-1">
@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")
</div>
</div>
<!-- Danger zone -->
if data.HasUnpublishedChanges {
<div class="bn-dd-border border-t py-1">
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
hx-post={ fmt.Sprintf("/toolbar/discard/%s", data.PageID) }
hx-target="#bn-admin-toolbar"
hx-swap="outerHTML"
hx-confirm="Discard all unpublished changes? This cannot be undone."
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
Discard changes
</button>
</div>
}
</div>
}
// positionButton renders a position selector button
templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) {
{{ active := pos == currentPos || (currentPos == "" && pos == "tr") }}
<button
type="button"
class={ "bn-dd-poschip w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bn-dd-poschip-active", active) }
hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) }
hx-target="#bn-admin-toolbar"
hx-swap="outerHTML"
title={ label }
>
<span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bn-dd-dot-active", active), templ.KV("bn-dd-dot", !active) }></span>
</button>
}
// 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 = '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> 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();
}

File diff suppressed because one or more lines are too long

View File

@ -1,50 +0,0 @@
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
})
}

View File

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="{{ theme_mode }}">
{{ head_html|safe }}
<body class="{% block body_class %}bg-background text-foreground antialiased min-h-screen flex flex-col{% endblock %}">
{{ admin_banner_html|safe }}
{% block body %}{% endblock %}
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,100 +0,0 @@
package pongo
import (
"context"
"maps"
"git.dev.alexdunmow.com/block/ninjatpl"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/templates/bn"
)
// buildPageContext builds a ninjatpl.Context with pre-rendered head/body HTML
// and all page-level variables from the standard doc map.
func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) ninjatpl.Context {
title := "Untitled"
if t, ok := doc["title"].(string); ok && t != "" {
title = t
}
slots := make(map[string]string)
if s, ok := doc["slots"].(map[string]string); ok {
slots = s
}
themeMode := "dark"
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
themeMode = tm
}
themeCSS := ""
if tc, ok := doc["theme_css"].(string); ok {
themeCSS = tc
}
structuredData := ""
if sd, ok := doc["structured_data"].(string); ok {
structuredData = sd
}
cssHash := ""
if ch, ok := doc["css_hash"].(string); ok {
cssHash = ch
}
pageviewNonce := ""
if pn, ok := doc["pageview_nonce"].(string); ok {
pageviewNonce = pn
}
settings := bn.ParseSiteSettings(doc)
pageMeta := bn.ParsePageMeta(doc)
engagementConfig := bn.ParseEngagementConfig(doc)
headHTML := renderComponent(ctx, bn.Head(bn.HeadData{
Title: title,
Settings: settings,
PageMeta: pageMeta,
ThemeMode: themeMode,
ThemeCSS: themeCSS,
PluginStyles: e.stylePaths,
StructuredData: structuredData,
CSSHash: cssHash,
PageviewNonce: pageviewNonce,
EngagementConfig: engagementConfig,
}))
bodyEndHTML := renderComponent(ctx, bn.BodyEnd(settings))
bannerHTML := renderComponent(ctx, bn.AdminBypassBanner(settings))
slotsAny := make(map[string]any, len(slots))
for k, v := range slots {
slotsAny[k] = v
}
return ninjatpl.Context{
"head_html": headHTML,
"body_end_html": bodyEndHTML,
"admin_banner_html": bannerHTML,
"title": title,
"slots": slotsAny,
"theme_mode": themeMode,
"theme_css": themeCSS,
"css_hash": cssHash,
"site_settings": settings,
"page_meta": pageMeta,
}
}
// buildBlockContext builds a ninjatpl.Context for block rendering.
// Content fields are available directly; request context is under "ctx".
func buildBlockContext(ctx context.Context, content map[string]any) ninjatpl.Context {
pongoCtx := make(ninjatpl.Context, len(content)+1)
maps.Copy(pongoCtx, content)
if bc := blocks.GetBlockContext(ctx); bc != nil {
pongoCtx["ctx"] = bc.ToMap()
}
return pongoCtx
}

View File

@ -1,6 +0,0 @@
package pongo
import "embed"
//go:embed base.html
var baseFS embed.FS

View File

@ -1,169 +0,0 @@
package pongo
import (
"bytes"
"context"
"io"
"io/fs"
"maps"
"git.dev.alexdunmow.com/block/ninjatpl"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
)
// Engine provides first-class pongo2 template support for BlockNinja plugins.
// Plugins create an Engine with their embedded template FS, then call
// MustPageTemplate / MustBlockTemplate to get functions compatible with
// the template and block registries.
type Engine struct {
set *ninjatpl.TemplateSet
stylePaths []string
}
// NewEngine creates a pongo2 Engine.
// pluginFS should contain the plugin's .html templates.
// stylePaths are CSS URLs included in the page <head> via bn.Head.
func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine {
loader := &multiLoader{
loaders: []ninjatpl.TemplateLoader{
&fsLoader{fsys: pluginFS},
&fsLoader{fsys: baseFS},
},
}
set := ninjatpl.NewSet("plugin", loader)
return &Engine{set: set, stylePaths: stylePaths}
}
// pongoComponent wraps a pongo2 template execution as an HTMLComponent.
type pongoComponent struct {
tpl *ninjatpl.Template
ctx ninjatpl.Context
}
func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
return c.tpl.ExecuteWriter(c.ctx, w)
}
// MustPageTemplate parses a pongo2 page template and returns a TemplateFunc.
// Panics on parse error.
//
// Available context variables in page templates:
//
// {{ head_html|safe }} — full <head> element
// {{ body_end_html|safe }} — body-end scripts and admin toolbar
// {{ admin_banner_html|safe }} — maintenance/coming-soon admin banner
// {{ title }} — page title
// {{ slots.header|safe }} — rendered slot HTML
// {{ theme_mode }} — "light", "dark", or "system"
// {{ theme_css }} — raw CSS custom properties
// {{ css_hash }} — cache-busting hash
// {{ site_settings }} — bn.SiteSettingsData struct
// {{ page_meta }} — bn.PageMeta struct
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
tpl := ninjatpl.Must(e.set.FromFile(name))
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
pongoCtx := e.buildPageContext(ctx, doc)
return &pongoComponent{tpl: tpl, ctx: pongoCtx}
}
}
// MustBlockTemplate parses a pongo2 block template and returns a BlockFunc.
// Panics on parse error.
//
// The content map fields are available directly as template variables.
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
tpl := ninjatpl.Must(e.set.FromFile(name))
return func(ctx context.Context, content map[string]any) string {
pongoCtx := buildBlockContext(ctx, content)
out, err := tpl.Execute(pongoCtx)
if err != nil {
return ""
}
return blocks.ProcessIcons(out)
}
}
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
// values before rendering. Content keys override defaults.
func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]any) blocks.BlockFunc {
tpl := ninjatpl.Must(e.set.FromFile(name))
return func(ctx context.Context, content map[string]any) string {
merged := make(map[string]any, len(defaults)+len(content))
maps.Copy(merged, defaults)
maps.Copy(merged, content)
pongoCtx := buildBlockContext(ctx, merged)
out, err := tpl.Execute(pongoCtx)
if err != nil {
return ""
}
return blocks.ProcessIcons(out)
}
}
// MustTemplateOverride parses a pongo2 template for use as a block template
// override (e.g., custom heading/text styling). Same as MustBlockTemplate
// but named distinctly for clarity at the call site.
func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
return e.MustBlockTemplate(name)
}
// MustEmailWrapper parses a pongo2 email template and returns an
// EmailWrapperFunc. Panics on parse error.
//
// Available context variables:
//
// {{ body|safe }} — the email body HTML
// {{ site_name }} — site name
// {{ site_url }} — site URL
// {{ logo_url }} — site logo URL
// {{ unsubscribe_url }} — unsubscribe link (may be empty)
// {{ preview_text }} — email preview/preheader text
// {{ colors.primary }} — primary hex color
// {{ colors.secondary }} — secondary hex color
// {{ colors.background }} — background hex color
// {{ colors.foreground }} — foreground hex color
// {{ colors.border }} — border hex color
// {{ colors.muted }} — muted hex color
// (and all other EmailColors fields as lowercase keys)
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
tpl := ninjatpl.Must(e.set.FromFile(name))
return func(body string, ctx templates.EmailContext) string {
pongoCtx := ninjatpl.Context{
"body": body,
"site_name": ctx.SiteSettings.SiteName,
"site_url": ctx.SiteSettings.SiteURL,
"logo_url": ctx.SiteSettings.LogoURL,
"support_email": ctx.SiteSettings.SupportEmail,
"unsubscribe_url": ctx.UnsubscribeURL,
"preview_text": ctx.PreviewText,
"colors": map[string]any{
"primary": ctx.Colors.Primary,
"primaryForeground": ctx.Colors.PrimaryForeground,
"secondary": ctx.Colors.Secondary,
"secondaryForeground": ctx.Colors.SecondaryForeground,
"background": ctx.Colors.Background,
"foreground": ctx.Colors.Foreground,
"muted": ctx.Colors.Muted,
"mutedForeground": ctx.Colors.MutedForeground,
"border": ctx.Colors.Border,
"card": ctx.Colors.Card,
"cardForeground": ctx.Colors.CardForeground,
},
}
out, err := tpl.Execute(pongoCtx)
if err != nil {
return body
}
return out
}
}
// renderComponent renders any HTMLComponent to a string.
func renderComponent(ctx context.Context, c templates.HTMLComponent) string {
var buf bytes.Buffer
_ = c.Render(ctx, &buf)
return buf.String()
}

View File

@ -1,43 +0,0 @@
package pongo
import (
"fmt"
"io"
"io/fs"
"git.dev.alexdunmow.com/block/ninjatpl"
)
// fsLoader adapts an fs.FS to pongo2's TemplateLoader interface.
type fsLoader struct {
fsys fs.FS
}
func (l *fsLoader) Abs(base, name string) string {
return name
}
func (l *fsLoader) Get(path string) (io.Reader, error) {
return l.fsys.Open(path)
}
// multiLoader chains multiple loaders, returning the first hit.
// Plugin templates can {% extends "base.html" %} where base.html
// lives in the core embedded FS rather than the plugin FS.
type multiLoader struct {
loaders []ninjatpl.TemplateLoader
}
func (l *multiLoader) Abs(base, name string) string {
return name
}
func (l *multiLoader) Get(path string) (io.Reader, error) {
for _, loader := range l.loaders {
r, err := loader.Get(path)
if err == nil {
return r, nil
}
}
return nil, fmt.Errorf("pongo: template %q not found in any loader", path)
}