- loadFieldTypes fetches GET /field-types after sign-in, loads each script once and adds the styles as global links activateStyles never disables - PluginField creates the type's element, sets the value property and the contract attributes, takes summer-change as the form value and never writes back a value the element just emitted - rendererForField and isRegisteredField route any field with an element to PluginField in forms, previews and the save payload - rebuilt modules/boardwalk/dist
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
// 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<string, Promise<void>>()
|
|
// 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<string, { owner: string; link: HTMLLinkElement }>()
|
|
// 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<void> {
|
|
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<void>((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<string[]> {
|
|
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')
|
|
}
|