Files
2026-07-13 12:38:36 +08:00

272 lines
8.3 KiB
JavaScript

(function () {
// Open search modal if signaled via sessionStorage (e.g., from Cmd+K on non-docs pages)
var SEARCH_STORAGE_KEY = 'nx-open-search';
var SEARCH_EXPIRY_MS = 30000; // 30 seconds
function openSearchFromStorage() {
var timestamp = sessionStorage.getItem(SEARCH_STORAGE_KEY);
if (!timestamp) return;
// Clear immediately to prevent re-triggering
sessionStorage.removeItem(SEARCH_STORAGE_KEY);
// Check if expired (older than 30 seconds)
var age = Date.now() - parseInt(timestamp, 10);
if (age > SEARCH_EXPIRY_MS) return;
var openSearchBtn = document.querySelector('button[data-open-modal]');
if (openSearchBtn) {
openSearchBtn.click();
// Focus the search input after modal opens
setTimeout(function () {
var searchInput = document.querySelector('dialog[open] input');
if (searchInput) {
searchInput.focus();
}
}, 100);
}
}
// Check on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', openSearchFromStorage);
} else {
// Small delay to ensure search component is initialized
setTimeout(openSearchFromStorage, 100);
}
const config = window.__CONFIG || {};
if (!config.isProd) return;
const gtmMeasurementId = config.gtmMeasurementId ?? 'GTM-KW8423B6';
window.dataLayer = window.dataLayer || [];
const loadGTM = () => {
if (!gtmMeasurementId) return;
(function (w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', gtmMeasurementId);
};
// GA4 events are dispatched via GTM dataLayer.
const pushGtmEvent = (eventName, payload) => {
window.dataLayer.push({ event: eventName, ...payload });
};
// Scroll depth tracking
const SCROLL_THRESHOLDS = [10, 25, 50, 75, 90];
let firedThresholds = new Set();
let scrollTrackingEnabled = false;
let scrollRafId = null;
function getScrollPercentage() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = window.innerHeight;
return (scrollTop + clientHeight) / scrollHeight;
}
function handleScrollTracking() {
if (!scrollTrackingEnabled) return;
const scrollPercentage = getScrollPercentage() * 100;
// Fire events for all thresholds we've passed but haven't fired yet
for (const threshold of SCROLL_THRESHOLDS) {
if (scrollPercentage >= threshold && !firedThresholds.has(threshold)) {
firedThresholds.add(threshold);
sendSearchEvent(`scroll_${threshold}`, {
event_category: 'scroll',
event_label: window.location.pathname,
});
}
}
}
function throttledScrollHandler() {
if (scrollRafId !== null) return;
scrollRafId = requestAnimationFrame(() => {
handleScrollTracking();
scrollRafId = null;
});
}
function attachScrollListener() {
window.addEventListener('scroll', throttledScrollHandler, {
passive: true,
});
}
function setupScrollTracking() {
// Reset scroll depth on navigation (for SPA-like behavior via View Transitions)
firedThresholds = new Set();
scrollTrackingEnabled = false;
// Delay tracking start to avoid false triggers during navigation
setTimeout(() => {
scrollTrackingEnabled = true;
// Immediately check current scroll position to capture thresholds
// that may have been passed during the delay
handleScrollTracking();
}, 500);
attachScrollListener();
// Handle Astro View Transitions - reset on navigation
document.addEventListener('astro:after-swap', () => {
firedThresholds = new Set();
scrollTrackingEnabled = false;
setTimeout(() => {
scrollTrackingEnabled = true;
// Immediately check current scroll position after navigation
handleScrollTracking();
}, 500);
});
}
const SEARCH_DEBOUNCE_MS = 1000;
let searchDebounceTimer;
let lastSearchQuery = '';
let currentSearchInput = null;
let inputHandler = null;
let resultClickHandler = null;
function sendSearchEvent(eventType, data) {
pushGtmEvent(eventType, data);
}
function trackSearchQuery(query) {
if (!query || query === lastSearchQuery) return;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
lastSearchQuery = query;
sendSearchEvent('search', {
search_term: query,
});
}, SEARCH_DEBOUNCE_MS);
}
function setupSearchTracking() {
const observer = new MutationObserver(() => {
const searchDialog = document.querySelector(
'dialog[open] .search-container'
);
// If dialog is found, then set up tracking
if (searchDialog) {
if (currentSearchInput && inputHandler) return; // Already tracking
const searchInput = searchDialog.querySelector(
'input[type="search"], input[type="text"]'
);
if (searchInput) {
inputHandler = (e) => {
const query = e.target.value.trim();
trackSearchQuery(query);
};
searchInput.addEventListener('input', inputHandler);
currentSearchInput = searchInput;
}
// Track search result clicks via event delegation
if (!resultClickHandler) {
resultClickHandler = (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const query = currentSearchInput
? currentSearchInput.value.trim()
: '';
pushGtmEvent('select_content', {
content_type: 'search_result',
item_id: link.getAttribute('href'),
search_term: query,
});
};
searchDialog.addEventListener('click', resultClickHandler);
}
}
// If dialog is not found but we have handler, etc. then it must be closed and we should clean up
else if (currentSearchInput && inputHandler) {
currentSearchInput.removeEventListener('input', inputHandler);
currentSearchInput = null;
inputHandler = null;
resultClickHandler = null;
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
}
function deriveCodeId(text) {
return (text || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '')
.slice(0, 60);
}
function setupCodeBlockCopyTracking() {
document.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof Element)) return;
const button = target.closest('figure.frame .copy button[data-code]');
if (!button) return;
// Expressive Code encodes newlines as DEL (0x7F) in the data-code attribute.
const code = (button.getAttribute('data-code') || '').replace(
/\x7F/g,
'\n'
);
pushGtmEvent('code_block_copy', {
event_category: 'docs_interaction',
event_label: window.location.pathname,
code_id: deriveCodeId(code),
});
});
}
const initializeAnalytics = () => {
if (!gtmMeasurementId) return;
loadGTM();
setupSearchTracking();
setupScrollTracking();
setupCodeBlockCopyTracking();
};
// Add GTM noscript iframe to body
const addGTMNoScript = () => {
const noscript = document.createElement('noscript');
const iframe = document.createElement('iframe');
iframe.src = `https://www.googletagmanager.com/ns.html?id=${gtmMeasurementId}`;
iframe.height = '0';
iframe.width = '0';
iframe.style.display = 'none';
iframe.style.visibility = 'hidden';
iframe.title = 'Google Tag Manager';
noscript.appendChild(iframe);
document.body.insertBefore(noscript, document.body.firstChild);
};
// Initial check
initializeAnalytics();
// Add GTM noscript on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addGTMNoScript);
} else {
addGTMNoScript();
}
})();