Head, engagement, toolbar templ components and validation helpers for use by template plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
515 lines
17 KiB
Plaintext
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>
|
|
}
|
|
}
|