FrontendAsset.attrs is documented as the escape hatch for extra attributes, and names type first (src/core/plugin-sdk/types/frontend.ts:49):
/** Extra attributes (e.g. `type`, `crossorigin`, `integrity`, `data-*`). */
attrs?: Record<string, string>
formatAttrs silently drops four things that are not host-owned in the general case (server/publish/frontendInjections.ts:279-291):
const RESERVED = new Set(['src', 'href', 'rel', 'data-plugin-id', 'defer', 'async', 'type'])
The comment directly above it (:281-283) explains only src, href and data-plugin-id. rel, defer, async and type are not mentioned. git log --all -S"RESERVED = new Set" returns exactly one commit (a0ca2275d), so the list has never been revised since it landed. crossorigin, integrity, id and data-* do survive, so three of the doc comment's four examples work and only type does not.
Two things this breaks.
1. JSON-LD cannot be emitted at all.
strategy is the only route to a type (frontendInjections.ts:260-271), it exists only on kind: 'script' (frontend.ts:48), and its one type-bearing value is module. kind: 'script-inline' has no strategy field and its manifest schema is additionalProperties: false (src/core/plugins/manifest.ts:322-327), so declaring one is a validation error. That leaves no route to type for an inline script:
{
kind: 'script-inline',
placement: 'head-end',
attrs: { type: 'application/ld+json' },
content: '{"@context":"https://schema.org","@type":"Article"}',
}
emits (frontendInjections.ts:188-197):
<script data-plugin-id="<id>">{"@context":"https://schema.org","@type":"Article"}</script>
The browser parses the body as classic JavaScript and throws a SyntaxError on every published page, and no crawler reads the structured data. frontend.ts:21-22 names JSON-LD as an intended head-end payload, so the SDK advertises a use case its emitter cannot produce. The tag also flips hasInlineScript (frontendInjections.ts:146-147), which relaxes the page CSP to script-src 'self' 'unsafe-inline' (:373-380) — paid for a tag that was never meant to execute.
2. The documented <link> example emits an empty tag.
docs/features/plugin-system.md:613:
rel and href are both reserved, and the kind: 'link' branch (frontendInjections.ts:220-227) passes the whole bag through formatAttrs and adds nothing back, so the page gets:
<link data-plugin-id="<id>">
That is the only <link> example in the tree — no plugin under examples/ declares frontend.assets.
Suggested fix: reserve per kind instead of globally. src is host-owned on script, href and rel on link, and type only when strategy: 'module' was chosen — for every other strategy the host emits no type and the slot is free. Passing the kind (and the resolved strategy) into formatAttrs and reserving against a per-kind set would fix both cases without letting an author overwrite a tag the host actually owns.
Nothing currently pins the behaviour either way: formatAttrs and renderAsset are module-private, and src/__tests__/publisher/frontendInjections.test.ts exercises only CSP relaxation through injectFrontendAssets.
Happy to send a PR if the per-kind shape looks right to you.
Verified at 6b055cf7 (v0.0.16-3).
FrontendAsset.attrsis documented as the escape hatch for extra attributes, and namestypefirst (src/core/plugin-sdk/types/frontend.ts:49):formatAttrssilently drops four things that are not host-owned in the general case (server/publish/frontendInjections.ts:279-291):The comment directly above it (
:281-283) explains onlysrc,hrefanddata-plugin-id.rel,defer,asyncandtypeare not mentioned.git log --all -S"RESERVED = new Set"returns exactly one commit (a0ca2275d), so the list has never been revised since it landed.crossorigin,integrity,idanddata-*do survive, so three of the doc comment's four examples work and onlytypedoes not.Two things this breaks.
1. JSON-LD cannot be emitted at all.
strategyis the only route to atype(frontendInjections.ts:260-271), it exists only onkind: 'script'(frontend.ts:48), and its one type-bearing value ismodule.kind: 'script-inline'has nostrategyfield and its manifest schema isadditionalProperties: false(src/core/plugins/manifest.ts:322-327), so declaring one is a validation error. That leaves no route totypefor an inline script:emits (
frontendInjections.ts:188-197):The browser parses the body as classic JavaScript and throws a
SyntaxErroron every published page, and no crawler reads the structured data.frontend.ts:21-22names JSON-LD as an intendedhead-endpayload, so the SDK advertises a use case its emitter cannot produce. The tag also flipshasInlineScript(frontendInjections.ts:146-147), which relaxes the page CSP toscript-src 'self' 'unsafe-inline'(:373-380) — paid for a tag that was never meant to execute.2. The documented
<link>example emits an empty tag.docs/features/plugin-system.md:613:{ "kind": "link", "attrs": { "rel": "preconnect", "href": "https://cdn.example.com" } }relandhrefare both reserved, and thekind: 'link'branch (frontendInjections.ts:220-227) passes the whole bag throughformatAttrsand adds nothing back, so the page gets:That is the only
<link>example in the tree — no plugin underexamples/declaresfrontend.assets.Suggested fix: reserve per kind instead of globally.
srcis host-owned onscript,hrefandrelonlink, andtypeonly whenstrategy: 'module'was chosen — for every other strategy the host emits notypeand the slot is free. Passing the kind (and the resolved strategy) intoformatAttrsand reserving against a per-kind set would fix both cases without letting an author overwrite a tag the host actually owns.Nothing currently pins the behaviour either way:
formatAttrsandrenderAssetare module-private, andsrc/__tests__/publisher/frontendInjections.test.tsexercises only CSP relaxation throughinjectFrontendAssets.Happy to send a PR if the per-kind shape looks right to you.
Verified at
6b055cf7(v0.0.16-3).