Skip to content

Frontend asset attrs silently drops type, rel and href, so JSON-LD and the documented <link> example cannot be emitted #392

Description

@asachs01

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:

{ "kind": "link", "attrs": { "rel": "preconnect", "href": "https://cdn.example.com" } }

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions