feat(10.1-02): render header and form partials through an allowlisted node renderer

- partialNodes rebuilds the server node tree with h() under the server's tag, attribute and URL lists
- PartialHost owns the skeleton, empty and failure states and keeps nodes visible on refetch
- type: partial is a valueless group-labelled field rendered on create and update
- ListView shows headerPartial above the list card and refetches it after bulk delete
- summer-partial and summer-stats style kit in main.css, documented in the cabana README; dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-29 02:10:18 +02:00
parent 107d820109
commit 9df9fae930
20 changed files with 870 additions and 22 deletions

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref, watch, type FunctionalComponent } from 'vue'
import { api } from '../../api/client'
import type { ControllerParams, PartialNode } from '../../api/types'
import { t } from '../../app/i18n'
import ExtensionFailure from '../ui/ExtensionFailure.vue'
import { renderPartialNodes } from './partialNodes'
// A server-rendered controller partial (D-17; UI-SPEC S1, S2): a header
// strip above a list or a `type: partial` form row. The node tree from
// GET .../partials/{name} is built with h() under the client allowlist.
// The first load shows a skeleton; a reload (reloadKey change) keeps the
// current nodes visible and only marks the host busy. Zero nodes render
// nothing; a failure shows the extension failure box. No live region: the
// toast of the action that caused a reload is the announcement.
const props = withDefaults(
defineProps<{
source: ControllerParams
name: string
/** Form partials on an existing record pass it as ?id=. */
recordId?: number | null
variant: 'header' | 'field'
/** Bumped by the parent to refetch. */
reloadKey?: number
}>(),
{ recordId: null, reloadKey: 0 },
)
const nodes = ref<PartialNode[] | null>(null)
const failed = ref(false)
const busy = ref(false)
let generation = 0
const PartialNodes: FunctionalComponent<{ nodes: PartialNode[] }> = (content) => renderPartialNodes(content.nodes)
PartialNodes.props = ['nodes']
async function load(): Promise<void> {
const current = ++generation
busy.value = true
let next: PartialNode[] | null = null
try {
const result = await api.GET('/{vendor}/{plugin}/{controller}/partials/{name}', {
params: {
path: { ...props.source, name: props.name },
query: props.recordId != null ? { id: props.recordId } : undefined,
},
})
next = result.data?.data.nodes ?? null
} catch {
next = null
}
if (current !== generation) {
return
}
busy.value = false
failed.value = next === null
nodes.value = next
}
watch(() => props.reloadKey, () => void load())
void load()
</script>
<template>
<ExtensionFailure v-if="failed" data-partial-failed :text="t('backend::lang.extension.partial_failed')" />
<div v-else-if="nodes === null" data-partial-loading aria-busy="true">
<div
data-partial-skeleton
:class="variant === 'header' ? 'h-[80px] w-full rounded-card' : 'h-[44px] w-full rounded-control'"
class="bg-skel"
aria-hidden="true"
/>
</div>
<div
v-else-if="nodes.length > 0"
class="summer-partial"
:data-partial="variant"
:aria-busy="busy ? 'true' : undefined"
>
<PartialNodes :nodes="nodes" />
</div>
</template>

View File

@@ -0,0 +1,131 @@
// Client half of the partial allowlist (D-17, T-10.1-14). cabana renders a
// plugin partial on the server and sends it as a tree of element and text
// nodes, already walked through its allowlist (modules/cabana
// partial_render.go). The SPA checks every node again against the same
// lists and builds it with Vue's h(): an allowed element keeps only its
// allowed attributes, a removed element goes with its subtree, any other
// element is unwrapped (its children stay) and text is always text. No
// string is ever parsed as markup.
import { h, type VNodeArrayChildren } from 'vue'
import type { PartialNode } from '../../api/types'
/** Elements a partial may render; the same list as the server. */
export const PARTIAL_TAGS: ReadonlySet<string> = new Set([
'div', 'span', 'p', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark',
'code', 'pre', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'section', 'header', 'footer',
'figure', 'figcaption', 'blockquote', 'q', 'abbr', 'time', 'data', 'meter', 'progress', 'sup',
'sub', 'a', 'img',
])
/** Elements removed together with everything inside them. */
export const PARTIAL_DROPPED_TAGS: ReadonlySet<string> = new Set([
'script', 'style', 'template', 'iframe', 'object', 'embed', 'noscript', 'textarea', 'title',
'xmp', 'svg', 'math', 'form', 'input', 'button', 'select', 'link', 'meta', 'base',
])
/** Deepest element nesting rendered, and the most nodes of one partial. */
export const PARTIAL_MAX_DEPTH = 32
export const PARTIAL_MAX_NODES = 2000
const globalAttributes = new Set(['class', 'title', 'lang', 'dir', 'role'])
const tagAttributes: Record<string, ReadonlySet<string>> = {
a: new Set(['href']),
img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan', 'scope']),
th: new Set(['colspan', 'rowspan', 'scope']),
time: new Set(['datetime']),
data: new Set(['value']),
meter: new Set(['value', 'min', 'max', 'low', 'high', 'optimum']),
progress: new Set(['value', 'max']),
}
const prefixedAttribute = /^(aria|data)-[a-z0-9_-]+$/
/**
* A same-origin path starting with exactly one slash (never "//" or "/\",
* which resolve to another host), or for links a #fragment. Whitespace and
* control characters, which browsers strip before resolving, are refused.
*/
function safeUrl(raw: string, allowFragment: boolean): boolean {
if (raw === '') {
return false
}
for (const char of raw) {
const code = char.codePointAt(0) ?? 0
if (code <= 0x20 || code === 0x7f) {
return false
}
}
if (raw.startsWith('#')) {
return allowFragment
}
if (!raw.startsWith('/')) {
return false
}
return raw.length === 1 || (raw[1] !== '/' && raw[1] !== '\\')
}
/** Whether an attribute may stay on an allowed element. Names are lower case. */
export function partialAttrAllowed(tag: string, name: string, value: string): boolean {
const allowed = globalAttributes.has(name) || tagAttributes[tag]?.has(name) === true || prefixedAttribute.test(name)
if (!allowed) {
return false
}
if ((tag === 'a' && name === 'href') || (tag === 'img' && name === 'src')) {
return safeUrl(value, tag === 'a')
}
return true
}
function allowedAttributes(tag: string, attrs: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = {}
for (const [rawName, value] of Object.entries(attrs ?? {})) {
const name = rawName.toLowerCase()
if (typeof value === 'string' && partialAttrAllowed(tag, name, value)) {
out[name] = value
}
}
return out
}
interface Budget {
left: number
}
function build(nodes: readonly PartialNode[] | undefined, depth: number, budget: Budget): VNodeArrayChildren {
const out: VNodeArrayChildren = []
for (const node of nodes ?? []) {
if (budget.left <= 0) {
break
}
if (node.tag === undefined || node.tag === '') {
if (typeof node.text === 'string' && node.text !== '') {
budget.left -= 1
out.push(node.text)
}
continue
}
const tag = node.tag.toLowerCase()
if (PARTIAL_DROPPED_TAGS.has(tag)) {
continue
}
if (!PARTIAL_TAGS.has(tag)) {
out.push(...build(node.children, depth, budget))
continue
}
if (depth + 1 > PARTIAL_MAX_DEPTH) {
continue
}
budget.left -= 1
const children = build(node.children, depth + 1, budget)
out.push(h(tag, allowedAttributes(tag, node.attrs), children.length > 0 ? children : undefined))
}
return out
}
/** Vue nodes for a partial's node tree, allowlisted again on the client. */
export function renderPartialNodes(nodes: readonly PartialNode[] | undefined): VNodeArrayChildren {
return build(nodes, 0, { left: PARTIAL_MAX_NODES })
}