import { type ElementWithXAttributes, type Alpine } from 'alpinejs' import { type Context } from './context' import { addBasePath, reloadScript } from './utils' import { settings } from './settings' const inMakeProgress = new Set() const cache = new Map() const loading = new Map>() const preloads = new Map() export const fetchError = (error: string, url: string) => { document.dispatchEvent( new CustomEvent('pinecone:fetch-error', { detail: { error, url } }) ) } /** * Creates a unique instance of a template with the given expression and target * element. * @param Alpine Alpine.js instance * @param template The template element to be processed. * @param expression The expression on the x-template directive. * @param targetEl The target element where the template will be rendered. * @param urls Template urls * @returns void */ export const make = ( Alpine: Alpine, template: ElementWithXAttributes, routePath: string, targetEl?: HTMLElement, // the target element where the template will // be rendered urls?: string[] // template urls ) => { // prevent concurrent makes for the same routePath if (inMakeProgress.has(routePath)) return inMakeProgress.add(routePath) const contentNode = template.content // pre-allocates the array with the children size const clones: HTMLElement[] = Array(contentNode.childElementCount) // clone all children and add the x-data scope const children = Array.from(contentNode.children) for (let i = 0; i < children.length; i++) { clones[i] = children[i].cloneNode( true ) as ElementWithXAttributes Alpine.addScopeToNode(clones[i], {}, template) // add the Alpine data scope of the target element if one is specified. // the scope of the template element will overshadow it. if (targetEl) Alpine.addScopeToNode(clones[i], Alpine.$data(clones[i]), targetEl) if (clones[i].tagName === 'SCRIPT') { const newScript = reloadScript( clones[i] as ElementWithXAttributes, i, routePath, Alpine ) if (newScript) clones[i] = newScript else clones[i].remove() } } Alpine.mutateDom(() => { if (targetEl) { targetEl.replaceChildren(...clones) } else template.after(...clones) clones.forEach((clone) => { Alpine.initTree(clone) }) }) template._x_PineconeRouter_template = clones // keep track of the currently rendered template urls template._x_PineconeRouter_templateUrls = urls template._x_PineconeRouter_undoTemplate = () => { // remove clone elements safely Alpine.mutateDom(() => { clones.forEach((clone: ElementWithXAttributes) => { Alpine.destroyTree(clone) clone.remove() }) }) delete template._x_PineconeRouter_template } Alpine.nextTick(() => inMakeProgress.delete(routePath)) } // Hide content of a template element export const hide = (template: ElementWithXAttributes) => { template._x_PineconeRouter_undoTemplate?.() delete template._x_PineconeRouter_undoTemplate } export const show = async ( Alpine: Alpine, template: ElementWithXAttributes, routePath: string, urls?: Array, targetEl?: HTMLElement ) => { // case: template already rendered, params changed. // if the template is rendered but the template url parameters have changed // hide the content and remove the content inside the template // this will trigger the template to be loaded again with new urls bellow. if ( template._x_PineconeRouter_templateUrls != undefined && template._x_PineconeRouter_templateUrls != urls ) { hide(template) template.innerHTML = '' } // case: template already rendered, route didn't change. // the template is already inserted into the page // leave it as is and return. if (template._x_PineconeRouter_template) { return } // case: template not rendered, but template content exists. if (template.content.childElementCount) { make(Alpine, template, routePath, targetEl, urls) return } // case: template content doesn't exist, load it from urls if (urls) { // if templates are not loaded, load them return load(urls, template).then(() => make(Alpine, template, routePath, targetEl, urls) ) } } /** * Interpolates params in URLs. * @param urls Array of template URLs. * @param params Object containing params to inject into URLs. * @returns Array of interpolated URLs. */ export const interpolate = ( urls: string[], params: Context['params'] ): string[] => { return urls.map((url) => url.replace(/:([^/.]+)/g, (_, name) => params[name] || name) ) } /** * Load a template from a url and cache its content. * @param url Template URL. * @param priority Request priority ('high' | 'low'), default: 'high'. * @returns {Promise} A promise that resolves to the content of * the template as a string. */ export const loadUrl = async ( url: string, priority: RequestPriority = 'high' ): Promise => { url = addBasePath(url) // Return from cache if available if (cache.has(url)) return cache.get(url)! // Return existing promise if already loading if (loading.has(url)) return loading.get(url)! const fetchPromise = fetch(url, { ...settings.fetchOptions, priority }) .then((r) => { if (!r.ok) { fetchError(r.statusText, url) return '' } return r.text() }) .then((html) => { if (html) cache.set(url, html) loading.delete(url) return html || '' }) .catch((error) => { if (error instanceof TypeError) { fetchError(error.message, url) } return '' }) loading.set(url, fetchPromise) return fetchPromise } /** * Add urls to the preload queue * @param urls Array of template URLs to preload * @param el Optional target element where to put the content of the urls * @returns void */ export const preload = (urls: string[], el?: HTMLElement): void => { preloads.set(urls, el) } /** * Load all preloaded templates and removes them from the queue. * It is called when the router is initialized and the first page * finishes loading. * @returns void */ export const runPreloads = (): void => { for (const [urls, el] of preloads) { if (el) { load(urls, el, 'low') } else { urls.map((url: string) => loadUrl(url, 'low')) } preloads.delete(urls) } } /** * Load templates from urls into a target element. * @param urls array of urls to load. * @param el target element where to put the content of the urls. * @param priority Request priority ('high' | 'low'), default: 'high'. * @returns {Promise} */ export const load = ( urls: string[], el: HTMLTemplateElement | HTMLElement, priority: RequestPriority = 'high' ): Promise => Promise.all(urls.map((url) => loadUrl(url, priority))).then((htmlArray) => { el.innerHTML = htmlArray.join('') })