packages/runtime/src/routesConfig.ts (127 lines of code) (raw):

import type { RouteMatch, LoadersData, LoaderData, RouteConfig } from './types.js'; export function getMeta( matches: RouteMatch[], loadersData: LoadersData, ): React.MetaHTMLAttributes<HTMLMetaElement>[] { return getMergedValue('meta', matches, loadersData) || []; } export function getLinks( matches: RouteMatch[], loadersData: LoadersData, ): React.LinkHTMLAttributes<HTMLLinkElement>[] { return getMergedValue('links', matches, loadersData) || []; } export function getScripts( matches: RouteMatch[], loadersData: LoadersData, ): React.ScriptHTMLAttributes<HTMLScriptElement>[] { return getMergedValue('scripts', matches, loadersData) || []; } export function getTitle(matches: RouteMatch[], loadersData: LoadersData): string { return getMergedValue('title', matches, loadersData); } /** * merge value for each matched route */ function getMergedValue(key: string, matches: RouteMatch[], loadersData: LoadersData) { let result; for (let match of matches) { const routeId = match.route.id; const data = loadersData[routeId]?.pageConfig; const value = data?.[key]; if (Array.isArray(value)) { // merge array result = result ? result.concat(value) : value; } else if (value) { // overwrite result = value; } } return result; } /** * update routes config to document. */ export async function updateRoutesConfig(loaderData: LoaderData) { const routeConfig = loaderData?.pageConfig; const title = routeConfig?.title; if (title) { document.title = title; } const meta = routeConfig?.meta || []; const links = routeConfig?.links || []; const scripts = routeConfig?.scripts || []; await Promise.all([ updateMeta(meta), updateAssets('link', links), updateAssets('script', scripts), ]); } /** * find meta by 'next-meta-count' and update it */ function updateMeta(meta: RouteConfig['meta']): void { const headEl = document.head; const metaCountEl: HTMLMetaElement = headEl.querySelector( 'meta[name=ice-meta-count]', ) as HTMLMetaElement; if (!metaCountEl) { console.warn('Can not find meta element.'); return; } const headCount = Number(metaCountEl.content); const oldTags: Element[] = []; for ( let i = 0, j = metaCountEl.previousElementSibling; i < headCount; i++, j = j?.previousElementSibling ) { if (j?.tagName?.toLowerCase() === 'meta') { oldTags.push(j!); } } const newTags = meta.map(item => { return reactElementToDOM('meta', item); }); oldTags.forEach((t) => t.parentNode!.removeChild(t)); newTags.forEach((t) => headEl.insertBefore(t, metaCountEl)); metaCountEl.content = (newTags.length).toString(); } const DOMAttributeNames: Record<string, string> = { acceptCharset: 'accept-charset', className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv', noModule: 'noModule', }; type ElementProps = RouteConfig['meta'][0] | RouteConfig['links'][0] | RouteConfig['scripts'][0]; /** * map element props to dom * https://github.com/vercel/next.js/blob/canary/packages/next/client/head-manager.ts#L9 */ function reactElementToDOM(type: string, props: ElementProps): HTMLElement { const el: HTMLElement = document.createElement(type); for (const p in props) { // we don't render undefined props to the DOM if (props[p] === undefined) continue; const attr = DOMAttributeNames[p] || p.toLowerCase(); if ( type === 'script' && (attr === 'async' || attr === 'defer' || attr === 'noModule') ) { (el as HTMLScriptElement)[attr] = !!props[p]; } else { el.setAttribute(attr, props[p]); } } return el; } const looseToArray = <T extends {}>(input: any): T[] => [].slice.call(input); /** * Load links/scripts for current page. * Remove links/scripts for the last page. */ async function updateAssets(type: string, assets: RouteConfig['links'] | RouteConfig['scripts']) { const oldTags: HTMLStyleElement[] = looseToArray<HTMLStyleElement>( document.querySelectorAll(`${type}[data-route-${type}]`), ); await Promise.all(assets.map((asset) => { return appendTags(type, asset); })); oldTags.forEach((tag) => { // In some parcel case oldTags may be removed by other routes. tag.parentNode?.removeChild(tag); }); } async function appendTags(type: string, props: ElementProps) { return new Promise((resolve, reject) => { const tag = reactElementToDOM(type, props); tag.setAttribute(`data-route-${type}`, 'true'); tag.onload = () => { resolve(null); }; tag.onerror = () => { reject(); }; document.head.appendChild(tag); }); }