import { Prefetcher, initPrefetcher } from "./prefetch"; import { handlePartials, hidePartials, showPartials } from "./partials"; import { camelToKebab } from "./utils"; export interface XrefOptions { debug?: boolean; updateHead?: boolean; transition?: TransitionOptions; prefetch?: PrefetchOptions; head?: HeadOptions; scripts?: ScriptOptions; } export interface ScriptOptions { retriggerDOMContentLoaded?: boolean; excludeSelectors?: string[]; includeSelectors?: string[]; } export interface HeadOptions { update?: boolean; retrigger?: { css?: boolean; js?: boolean; include?: string | RegExp; exclude?: string | RegExp; }; } export interface PrefetchOptions { active: boolean; delay: number; event: string; selector?: string; media?: boolean; css?: boolean; js?: boolean; } export interface TransitionOptions { duration?: number; delay?: number; easing?: string; timeline?: "sequential" | "parallel"; in?: TransitionState; out?: TransitionState; callback?: TransitionCallbacks; state?: AnimationState; swapHtml?: string; partials?: PartialTransition[]; } export interface PartialTransition { element: string; duration?: number; delay?: number; easing?: string; in?: TransitionState; out?: TransitionState; } export interface TransitionState { from?: Record; to?: Record; } export interface AnimationState { started: boolean; playing: boolean; paused: boolean; finished: boolean; } export interface TransitionCallbacks { onEnter?: () => void; onStart?: () => void; onPlay?: () => void; onPause?: () => void; onFinish?: () => void; } /** * The main Xref class that handles * navigation and transitions. * * @returns The Xref instance. * * @description This is the main class that handles navigation and transitions. * It intercepts clicks on internal links, fetches the content of the linked page, * updates the document head and body, and performs transitions between the * old and new content. It also handles popstate events to support back * and forward navigation. */ class Xref { private options: XrefOptions; private styleElement: HTMLStyleElement; private transitionCounter: number = 0; private prefetcher: Prefetcher | null = null; private animationState: AnimationState; private domContentLoadedScripts: Set = new Set(); /** * @description This is the constructor of the Xref class. * It initializes the Xref instance with the given options, * creates a style element to store the keyframes for transitions, * and sets the initial animation state. */ constructor(options: XrefOptions = {}) { this.options = { updateHead: true, prefetch: { active: false, delay: 0, event: "mouseover", media: false, css: false, js: false, }, scripts: { retriggerDOMContentLoaded: true, excludeSelectors: [], includeSelectors: [] }, ...options, }; this.styleElement = document.createElement("style"); this.styleElement.setAttribute("data-xref", "true"); document.head.appendChild(this.styleElement); this.animationState = { started: false, playing: true, paused: false, finished: false, ...(this.options.transition?.state as Partial), }; this.init(); this.storeDOMContentLoadedScripts(); } private storeDOMContentLoadedScripts() { document.addEventListener('DOMContentLoaded', () => { const scripts = document.querySelectorAll('script'); scripts.forEach(script => { const content = script.textContent || ''; if (content.includes('DOMContentLoaded')) { this.domContentLoadedScripts.add(this.normalizeScript(content)); } }); }); } private normalizeScript(content: string): string { // Remove whitespace and comments to normalize script content return content .replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') // Remove comments .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } private shouldRetriggerScript(script: HTMLScriptElement): boolean { const { includeSelectors, excludeSelectors } = this.options.scripts || {}; // Check exclusions first if (excludeSelectors?.some(selector => script.matches(selector))) { return false; } // If includes are specified, script must match at least one if (includeSelectors?.length) { return includeSelectors.some(selector => script.matches(selector)); } return true; } private async retriggerDOMContentLoadedScripts() { if (!this.options.scripts?.retriggerDOMContentLoaded) return; const scripts = document.querySelectorAll('script'); const promises: Promise[] = []; scripts.forEach(script => { if (!this.shouldRetriggerScript(script)) return; const content = script.textContent || ''; const normalizedContent = this.normalizeScript(content); if (this.domContentLoadedScripts.has(normalizedContent) || content.includes('DOMContentLoaded')) { promises.push(new Promise((resolve) => { // Create and execute a new script const newScript = document.createElement('script'); newScript.textContent = ` (() => { const event = new Event('DOMContentLoaded'); document.dispatchEvent(event); })(); ${content} `; newScript.onload = () => resolve(); script.parentNode?.replaceChild(newScript, script); })); } }); await Promise.all(promises); this.options.debug && console.log('Re-triggered DOMContentLoaded scripts'); } /** * @description This method initializes the Xref instance * by intercepting clicks on internal links, handling popstate events, * and initializing the prefetcher if prefetching is enabled. */ public init() { this.options.debug ? console.log("started -> init() Method") : null; this.interceptClicks(); this.handlePopState(); if (this.options.prefetch && this.options.prefetch.active) { this.prefetcher = initPrefetcher(this.options.prefetch, this.options); } } private currentKeyframeName: string | null = null; /** * @description This method creates keyframes * for the given transition state * and direction. */ private createKeyframes(transitionState: TransitionState, direction: "in" | "out"): string { const { from, to } = transitionState; const keyframeName = `xref-${direction}-${++this.transitionCounter}`; let keyframeCSS = `@keyframes ${keyframeName} { from { ${Object.entries(from || {}) .map(([key, value]) => `${camelToKebab(key)}: ${value};`) .join(" ")} } to { ${Object.entries(to || {}) .map(([key, value]) => `${camelToKebab(key)}: ${value};`) .join(" ")} } }`; this.options.debug ? console.log("Creating keyframe:" + keyframeName) : null; this.options.debug ? console.log("Keyframe CSS:" + keyframeCSS) : null; // Remove the previous keyframe if it exists if (this.currentKeyframeName) { this.removeKeyframes(this.currentKeyframeName); } // Append the new keyframe to the style element's content this.styleElement.textContent = keyframeCSS; this.currentKeyframeName = keyframeName; this.options.debug ? console.log("Keyframe " + keyframeName + "appended to