core/templates/bn/engagement_templ.go
Alex Dunmow 868df2d761 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 <noreply@anthropic.com>
2026-05-01 09:17:55 +08:00

81 lines
20 KiB
Go

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