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:
Jakub Zych
2026-09-29 02:04:34 +02:00
parent c3b76b1afb
commit 107d820109
19 changed files with 789 additions and 14 deletions

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