Files
summercms/admin/src/app/pluginAssets.ts
Jakub Zych 83fa2f7b2c feat(16.2-01): host plugin field type elements in the admin SPA
- 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
2026-10-10 11:10:41 +02:00

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')
}