core/templates/bn/engagement.templ
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

515 lines
17 KiB
Plaintext

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>
}
}