feat(10.1-02): mount plugin widget elements and run their actions from the form
- pluginAssets loads controller scripts and stylesheets from {base}/assets/ only, once per URL
- WidgetField mounts the custom element with attributes only and posts summer-action through the typed client
- Only declared fill keys returned by the server are patched; the form turns dirty and nothing saves
- widget is a registered valueless type rendered on create and update, labelled as a group
- backend::lang.extension strings in en and pl; embedded dist rebuilt
This commit is contained in:
110
admin/src/app/pluginAssets.ts
Normal file
110
admin/src/app/pluginAssets.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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: opening another controller
|
||||
// disables them, so plugin CSS never styles a view it was not written for.
|
||||
// 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'
|
||||
|
||||
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 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).
|
||||
*/
|
||||
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) => segment === '.' || segment === '..')
|
||||
}
|
||||
|
||||
/** 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.href = url
|
||||
styles.set(key, { owner: controllerId, link })
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
}
|
||||
|
||||
/** Enables this controller's stylesheet links and disables every other one. */
|
||||
export function activateStyles(controllerId: string): void {
|
||||
for (const { owner, link } of styles.values()) {
|
||||
link.disabled = owner !== controllerId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the controller's styles and loads its files. 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[]> {
|
||||
activateStyles(controllerId)
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user