// Plugin scripts and stylesheets of admin controllers (D-14, D-16). A // controller's list or form schema names its files as absolute URLs under // {base}/assets/; they load when that controller's view opens, never // earlier. Every script loads once per URL as a module script, and a script // that fails to load is forgotten, so a later navigation can try again. // Stylesheet links belong to one controller: the router disables them on // every navigation to another controller or to a screen that is not a // controller view, so plugin CSS never styles a view it was not written for. // Only the router picks the enabled controller; a schema response that // arrives after its view was left adds its links disabled. Stylesheets of // plugin field types are global instead (GLOBAL_STYLE_OWNER): a field type's // element can appear in any controller's form, so its links are never // disabled on navigation. // Files load only through script and link elements; nothing here fetches. import type { ControllerAssets } from '../api/types' import { runtime } from './runtime' /** Attribute naming the controller that owns a plugin stylesheet link. */ export const OWNER_ATTRIBUTE = 'data-summer-controller' /** Owner of stylesheet links that stay enabled on every screen (field types). */ export const GLOBAL_STYLE_OWNER = '*' const scripts = new Map>() // Links by controller and URL: two controllers of one plugin may share a // file, and each keeps its own link so disabling one never hides the other. const styles = new Map() // The controller whose links are enabled; '' enables none. let active = '' /** The URL prefix every plugin asset must start with. */ export function assetPrefix(): string { return `${runtime.base}/assets/` } /** * Whether a URL may load as a plugin asset: a same-origin path under * {base}/assets/ with no dot segment, backslash, whitespace or control * character (browsers strip or rewrite those before resolving). A * percent-encoded dot counts as a dot: the URL parser resolves %2e%2e like * .., which would climb out of the asset prefix. */ export function assetAllowed(url: string): boolean { if (!url.startsWith(assetPrefix()) || url.includes('\\')) { return false } for (const char of url) { const code = char.codePointAt(0) ?? 0 if (code <= 0x20 || code === 0x7f) { return false } } const path = url.split(/[?#]/, 1)[0] ?? '' return !path.split('/').some((segment) => { const dots = segment.toLowerCase().replaceAll('%2e', '.') return dots === '.' || dots === '..' }) } /** Loads one module script; the same URL always yields the same promise. */ export function loadScript(url: string): Promise { if (!assetAllowed(url)) { return Promise.reject(new Error(`plugin script outside ${assetPrefix()}: ${url}`)) } const known = scripts.get(url) if (known) { return known } const loading = new Promise((resolve, reject) => { const script = document.createElement('script') script.type = 'module' script.addEventListener('load', () => resolve(), { once: true }) script.addEventListener( 'error', () => { scripts.delete(url) script.remove() reject(new Error(`plugin script failed to load: ${url}`)) }, { once: true }, ) script.src = url document.head.appendChild(script) }) scripts.set(url, loading) return loading } /** Adds a stylesheet link per new URL, owned by the controller. */ export function loadStyles(controllerId: string, urls: readonly string[]): void { for (const url of urls) { const key = `${controllerId}\n${url}` if (!assetAllowed(url) || styles.has(key)) { continue } const link = document.createElement('link') link.rel = 'stylesheet' link.setAttribute(OWNER_ATTRIBUTE, controllerId) link.disabled = !styleEnabled(controllerId) link.href = url styles.set(key, { owner: controllerId, link }) document.head.appendChild(link) } } /** * Enables this controller's stylesheet links, now and as they are added, * and disables every other one; '' disables them all. The router calls it * on every navigation. */ export function activateStyles(controllerId: string): void { active = controllerId for (const { owner, link } of styles.values()) { link.disabled = !styleEnabled(owner) } } /** Whether links of this owner are enabled now: the active controller's and the global ones. */ function styleEnabled(owner: string): boolean { return owner === GLOBAL_STYLE_OWNER || owner === active } /** * Adds stylesheet links that stay enabled on every screen: the styles of * plugin field types, whose elements may render in any controller's form. */ export function loadGlobalStyles(urls: readonly string[]): void { loadStyles(GLOBAL_STYLE_OWNER, urls) } /** * Adds the controller's stylesheet links and loads its scripts. The links * are enabled only while the router shows that controller, so a response * for a view already left never restyles the current one. The promise * resolves once every script has loaded or failed, with the URLs that * failed; it never rejects. Views start this without awaiting it. */ export async function loadControllerAssets( controllerId: string, assets: ControllerAssets | null | undefined, ): Promise { loadStyles(controllerId, assets?.styles ?? []) const urls = assets?.scripts ?? [] const results = await Promise.allSettled(urls.map((url) => loadScript(url))) return urls.filter((_, index) => results[index]?.status === 'rejected') }