Skip to content

fix: proper script importmap & speculationrules handling#734

Merged
harlan-zw merged 3 commits into
mainfrom
feat/importmap-speculationrules
Apr 10, 2026
Merged

fix: proper script importmap & speculationrules handling#734
harlan-zw merged 3 commits into
mainfrom
feat/importmap-speculationrules

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Apr 10, 2026

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

N/A

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

<script type="importmap"> and <script type="speculationrules"> mostly worked by accident and required casts like type: 'importmap' as unknown as 'application/json' alongside manual tagPriority hints. This PR aligns typing, normalization, escaping, capo sort and dedupe so both tags work cleanly with just { type, innerHTML } or { type, textContent }.

importmap

  • ImportMapScript now uses DataScriptTextContent<string | ImportMapConfig>, so both innerHTML and textContent are accepted (string or config object) — no more casts.
  • normalize.ts auto-JSON.stringifys objects passed for importmap.
  • resolve.ts applies the \u003C escape to importmap content, matching JSON-type scripts.
  • sort.ts pins importmap at the sync-script weight (50) so it is always emitted before modulepreload (70) and module scripts (80) as required by the HTML spec.
  • dedupe.ts treats importmap as unique per document (HTML spec allows only one).

speculationrules

  • resolve.ts applies the \u003C escape to speculationrules content alongside JSON types.
  • sort.ts pins speculationrules at weight 90, alongside <link rel=prefetch> / rel=prerender, instead of accidentally landing in the sync-script bucket (50).

Docs

  • docs/head/1.guides/1.core-concepts/2.positions.md — replaced the incomplete capo weight list with the full table. Fixes the incorrect CSP entry (0-30) and adds viewport, async/sync/defer/module scripts, importmap, speculationrules, stylesheets, preloads and the prefetch tier.

Tests

  • test/unit/plugins/capo.test.ts — importmap-before-modulepreload-and-modules ordering; speculationrules-sorted-late alongside prefetch.
  • test/unit/server/deduping.test.ts — two importmap pushes collapse to the last one.

Summary by CodeRabbit

  • New Features

    • Improved Import Maps handling: explicit ordering, SSR dedupe, and configurable replacement (merge vs last‑wins)
    • Added Speculation Rules scripts with proper priority
    • Import Maps accept either textContent or innerHTML and are safely serialized/escaped for output
  • Documentation

    • Updated head tag priority ordering and notes on Import Maps placement and replacement behavior
  • Tests

    • Added unit and XSS regression tests covering ordering, dedupe, serialization, and escaping

Align type, normalization, escaping, capo sort and dedupe for
`<script type="importmap">` and `<script type="speculationrules">`
so both work cleanly without casts or manual `tagPriority` hints.

importmap:
- ImportMapScript now uses DataScriptTextContent so both `innerHTML`
  and `textContent` are accepted, with string or ImportMapConfig
- normalize.ts auto-JSON.stringify objects passed for importmap
- resolve.ts applies the `\u003C` escape for importmap content
- sort.ts pins importmap at sync weight (50) so it always precedes
  modulepreload (70) and module scripts (80) per HTML spec
- dedupe.ts treats importmap as unique per document (HTML spec)

speculationrules:
- resolve.ts applies the `\u003C` escape alongside JSON types
- sort.ts pins speculationrules at weight 90, alongside
  `<link rel=prefetch>` / `rel=prerender`, instead of sync (50)

Docs: updated capo weight table with the full set of weights
(including the incorrect CSP value and the new importmap /
speculationrules entries).
@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Updated head tag ordering and sanitization: explicit weights introduced (CSP, viewport, importmap, speculationrules), importmap script typing now accepts either textContent or innerHTML, normalization/resolution pipeline serializes/escapes importmap/speculationrules content, and tests added for ordering, dedupe, and XSS cases.

Changes

Cohort / File(s) Summary
Documentation
docs/head/1.guides/1.core-concepts/2.positions.md
Expanded head tag priority table: removed prior CSP default, added explicit weights for CSP (-30) and viewport (-15), added explicit entries for script type="importmap", async scripts, speculations, and clarified importmap merging vs keyed replacement.
Sorting / Weights
packages/unhead/src/server/sort.ts
Added explicit type checks for <script> handling: importmap and speculationrules are assigned dedicated weights; broadened sync detection to consider textContent as inline sync content (excluding JSON-like types).
Type Definitions
packages/unhead/src/types/schema/script.ts
Changed ImportMapScript to a discriminated union requiring exactly one of textContent or innerHTML for type: 'importmap'.
Normalization & Resolution
packages/unhead/src/utils/normalize.ts, packages/unhead/src/utils/resolve.ts
normalize.ts: JSON/object stringification now applied for importmap; resolve.ts: sanitizes both innerHTML and textContent, treats importmap/speculationrules and types ending in json as JSON-like and escapes < as \u003C, with shared escape helper and re-computation of tag metadata.
Tests — plugins / server / XSS
packages/unhead/test/unit/plugins/capo.test.ts, packages/unhead/test/unit/server/deduping.test.ts, packages/unhead/test/unit/server/useHeadSafe-edge-cases.test.ts, packages/unhead/test/unit/server/xss.test.ts
Added/updated tests validating ordering (importmap before modulepreload/module scripts; speculationrules placement), normalization/serialization of object textContent, SSR dedupe semantics for importmaps (distinct vs identical vs keyed last-wins), and XSS escaping for importmap/speculationrules content.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hopped through tags and weights today,

importmaps ordered, safe in every way,
speculations tucked neatly at the end,
keys deciding which maps ascend,
a little hop for safer script-play 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main changes: adding proper handling for script importmap and speculationrules with correct typing, normalization, sorting, and escaping.
Description check ✅ Passed The description fully adheres to the template with all required sections completed: linked issue noted, type of change marked (Documentation and Enhancement), and comprehensive description of the changes including rationale and implementation details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/importmap-speculationrules

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.6 kB → 10.7 kB 🔴 +0.2 kB 4.4 kB
Server (Minimal) 10.3 kB → 10.6 kB 🔴 +0.3 kB 4.2 kB → 4.3 kB 🔴 +0.1 kB
Vue Client (Minimal) 11.6 kB → 11.8 kB 🔴 +0.2 kB 4.8 kB
Vue Server (Minimal) 11.3 kB → 11.6 kB 🔴 +0.3 kB 4.6 kB → 4.7 kB 🔴 +0.1 kB

@harlan-zw harlan-zw changed the title feat(script): proper importmap & speculationrules handling fix: proper script importmap & speculationrules handling Apr 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/unhead/test/unit/plugins/capo.test.ts (1)

161-228: Exercise the new object/textContent path in these cases.

These ordering tests still pass pre-serialized innerHTML, so they won’t catch regressions in the new normalization/typing path this PR adds. Swapping one importmap/speculationrules fixture to raw object textContent (or raw object innerHTML) would cover the user-facing API more directly.

🧪 Suggested coverage tweak
   head.push({
     script: [{
       type: 'importmap',
-      innerHTML: JSON.stringify({ imports: { '#entry': '/entry.js' } }),
+      textContent: { imports: { '#entry': '/entry.js' } },
     }],
   })
@@
   head.push({
     script: [{
       type: 'speculationrules',
-      innerHTML: JSON.stringify({ prefetch: [{ source: 'list', urls: ['/next'] }] }),
+      textContent: { prefetch: [{ source: 'list', urls: ['/next'] }] },
     }],
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/test/unit/plugins/capo.test.ts` around lines 161 - 228, The
tests 'importmap precedes modulepreload and module scripts' and
'speculationrules sorts late alongside prefetch/prerender' currently pass
pre-serialized JSON strings via innerHTML; update each push that creates the
importmap/speculationrules script to use the raw object path by replacing
innerHTML: JSON.stringify(...) with textContent: { ... } (i.e. pass the object
literal for imports/prefetch) so the new object/textContent normalization path
is exercised (look for the script pushes in those two it blocks and update the
innerHTML fields to textContent objects).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/unhead/src/types/schema/script.ts`:
- Around line 297-305: The ImportMapScript type currently uses
DataScriptTextContent<string | ImportMapConfig> which leaves both textContent
and innerHTML optional; update ImportMapScript to use the same XOR pattern as
InlineScript (lines around InlineScript) so one union arm requires textContent
and the other requires innerHTML (e.g., DataScriptTextContent<string |
ImportMapConfig> replaced by a union like { textContent: string |
ImportMapConfig; innerHTML?: never } | { innerHTML: string | ImportMapConfig;
textContent?: never }) to ensure an importmap must include one of those
properties.

In `@packages/unhead/src/utils/dedupe.ts`:
- Around line 21-23: Remove the importmap special-case from dedupeKey so
importmap scripts are not forced to a single 'script:importmap' key: delete the
conditional that checks for t === 'script' && props.type === 'importmap' inside
dedupeKey and let importmap nodes be deduplicated normally by their full key;
then update the test packages/unhead/test/unit/server/deduping.test.ts (remove
or change assertions that expect "last one wins" for importmap) and update docs
in docs/head/1.guides/1.core-concepts/2.positions.md to reflect that multiple
importmap scripts are merged by browsers rather than replaced.

---

Nitpick comments:
In `@packages/unhead/test/unit/plugins/capo.test.ts`:
- Around line 161-228: The tests 'importmap precedes modulepreload and module
scripts' and 'speculationrules sorts late alongside prefetch/prerender'
currently pass pre-serialized JSON strings via innerHTML; update each push that
creates the importmap/speculationrules script to use the raw object path by
replacing innerHTML: JSON.stringify(...) with textContent: { ... } (i.e. pass
the object literal for imports/prefetch) so the new object/textContent
normalization path is exercised (look for the script pushes in those two it
blocks and update the innerHTML fields to textContent objects).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61577b69-6468-4c59-81b3-2f78b63d8ed1

📥 Commits

Reviewing files that changed from the base of the PR and between 64b5ac0 and 94d7235.

📒 Files selected for processing (8)
  • docs/head/1.guides/1.core-concepts/2.positions.md
  • packages/unhead/src/server/sort.ts
  • packages/unhead/src/types/schema/script.ts
  • packages/unhead/src/utils/dedupe.ts
  • packages/unhead/src/utils/normalize.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/plugins/capo.test.ts
  • packages/unhead/test/unit/server/deduping.test.ts

Comment thread packages/unhead/src/types/schema/script.ts Outdated
Comment thread packages/unhead/src/utils/dedupe.ts Outdated
Address CodeRabbit review on the importmap/speculationrules PR.

- Multiple importmaps are allowed per the modern HTML spec — browsers
  merge them into a single global import map. Remove the `script:importmap`
  auto-unique dedupe key so both (or more) tags render and the browser
  merges them. Content-based fallback still collapses exact duplicates,
  and users who want replace-semantics can opt in via an explicit `key`.
- Move importmap to a dedicated capo weight (25) between preconnect (20)
  and async scripts (30). This guarantees importmap precedes async
  module scripts (`<script type="module" async>`) as required by the
  HTML spec, without touching the module/async classification.
- Tighten `ImportMapScript` type with an XOR between `textContent` and
  `innerHTML` (matching `InlineScript`) so one is always required.
- Rewrite the importmap dedupe tests to assert merging for distinct
  pushes, content-based collapse for identical pushes, and key-based
  replacement for opt-in last-wins.
- Swap capo ordering tests to push `textContent: { ... }` raw objects,
  exercising the normalize auto-serialization path end-to-end.
- Doc update: new weight 25 for importmap, note that unhead emits all
  importmaps so browsers can merge them.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves first-class handling of <script type="importmap"> and <script type="speculationrules"> across Unhead’s typing, normalization, SSR escaping, capo sorting, deduping behavior, documentation, and unit tests—so callers can provide { type, textContent/innerHTML } without casts or manual tagPriority.

Changes:

  • Update script typing + normalization so importmap/speculationrules accept object content and are auto-serialized to JSON.
  • Adjust SSR sanitize/escaping behavior and capo weights so importmaps sort before module-related resources and speculationrules sort late.
  • Expand docs + add unit tests for ordering and deduping expectations.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/unhead/src/types/schema/script.ts Refines ImportMapScript typing to allow either textContent or innerHTML (string or config object).
packages/unhead/src/utils/normalize.ts Auto-serializes object textContent/innerHTML for importmap in addition to JSON-like script types.
packages/unhead/src/utils/resolve.ts Extends JSON-like escaping behavior to importmap and speculationrules (currently innerHTML-only).
packages/unhead/src/server/sort.ts Adds capo weights and ordering logic for importmap and speculationrules.
packages/unhead/test/unit/plugins/capo.test.ts Adds assertions for importmap ordering relative to modulepreload/modules and speculationrules late sorting.
packages/unhead/test/unit/server/deduping.test.ts Adds dedupe coverage for multiple importmaps, identical content dedupe, and key-based last-wins.
docs/head/1.guides/1.core-concepts/2.positions.md Replaces partial capo weight list with a fuller table and adds importmap/speculationrules guidance.
Comments suppressed due to low confidence (2)

packages/unhead/src/utils/resolve.ts:90

  • sanitizeTags only applies the \u003C escaping logic when innerHTML is present. For type="importmap" / type="speculationrules" (and existing JSON types), the recommended path is often textContent, which currently bypasses this normalization and can lead to inconsistent SSR output depending on whether callers use textContent vs innerHTML. Consider applying the same normalization/escaping to textContent for these JSON-like script types (or normalizing to a single content field before rendering).
    if (tag === 'script' && innerHTML) {
      const type = String(props.type)
      t.innerHTML = type.endsWith('json') || type === 'importmap' || type === 'speculationrules'
        ? (typeof innerHTML === 'string' ? innerHTML : JSON.stringify(innerHTML)).replace(LT_RE, '\\u003C')
        : typeof innerHTML === 'string' ? innerHTML.replace(SCRIPT_END_RE, '<\\/script') : innerHTML
      t._d = dedupeKey(t)
    }

packages/unhead/src/server/sort.ts:46

  • capoTagWeight classifies inline sync scripts using tag.innerHTML, but inline scripts can also be provided via textContent (per the schema types). As written, <script textContent="..."> will fall through to the default weight (100) instead of the sync-script bucket, making ordering depend on whether callers chose innerHTML vs textContent. Update the sync-script condition to treat textContent the same as innerHTML for inline scripts.
    else if (isTruthy(tag.props.async))
      weight = CAPO_WEIGHTS.script.async
    else if ((tag.props.src && !isTruthy(tag.props.defer) && !isTruthy(tag.props.async) && type !== 'module' && !type.endsWith('json')) || (tag.innerHTML && !type.endsWith('json')))
      weight = CAPO_WEIGHTS.script.sync
    else if ((isTruthy(tag.props.defer) && tag.props.src && !isTruthy(tag.props.async)) || type === 'module')
      weight = CAPO_WEIGHTS.script.defer

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 6 to 10
const CAPO_WEIGHTS = {
meta: { 'content-security-policy': -30, 'charset': -20, 'viewport': -15 },
link: { 'preconnect': 20, 'stylesheet': 60, 'preload': 70, 'modulepreload': 70, 'prefetch': 90, 'dns-prefetch': 90, 'prerender': 90 },
script: { async: 30, defer: 80, sync: 50 },
script: { importmap: 25, async: 30, defer: 80, sync: 50, speculationrules: 90 },
style: { imported: 40, sync: 60 },

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions pinning importmap to the sync-script weight (50), but CAPO_WEIGHTS sets importmap: 25 (and logic depends on it to sort before async module scripts). Please align the PR description/release notes with the implemented weights to avoid confusion for reviewers and future maintainers.

Copilot uses AI. Check for mistakes.
Address Copilot review on #734.

- `sanitizeTags` (resolve.ts) now escapes both `textContent` and
  `innerHTML` for scripts. Previously only `innerHTML` received the
  `\u003C` escape for JSON-like types (importmap, speculationrules,
  json), which meant callers using the recommended `textContent` path
  got inconsistent SSR output compared to `innerHTML`.
- `capoTagWeight` (sort.ts) now classifies inline sync scripts by
  `textContent` as well as `innerHTML`. Previously a plain inline
  script using `textContent` fell through to the default weight 100
  instead of the sync-script bucket (50).
- Added a capo test for inline `textContent` sort classification.
- Added xss tests covering the importmap/speculationrules textContent
  escape path.
- Updated the existing useHeadSafe ld+json test to codify the new
  consistent behavior: `<` characters in JSON values are escaped to
  `\u003C` regardless of textContent vs innerHTML, as defense-in-depth.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/unhead/src/utils/resolve.ts (1)

85-86: Normalize script type before JSON-like matching.

Line 85 uses raw props.type, so mixed-case values (e.g. Application/LD+JSON, ImportMap) won’t take the JSON-like escape path. Normalize once before checks to keep behavior consistent.

Suggested patch
-      const type = String(props.type)
+      const type = String(props.type || '').trim().toLowerCase()
       const isJsonLike = type.endsWith('json') || type === 'importmap' || type === 'speculationrules'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/utils/resolve.ts` around lines 85 - 86, The JSON-like
detection uses the raw props.type so mixed-case values slip through; normalize
the type string to a consistent case (e.g. lower-case) before performing checks.
In resolve.ts update the handling around the type constant and isJsonLike (the
String(props.type) assignment and the isJsonLike check) to use a normalized
value (e.g. assign const type = String(props.type).toLowerCase() or create a
separate normalizedType variable) and then match against 'json', 'importmap',
and 'speculationrules' on that normalized value.
packages/unhead/test/unit/server/xss.test.ts (1)

354-380: Optional: assert script type in both new XSS tests for clearer diagnostics.

These tests are good; adding type="importmap" / type="speculationrules" assertions would make regressions easier to pinpoint if rendering behavior changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/test/unit/server/xss.test.ts` around lines 354 - 380, Add
explicit assertions that the rendered script tag includes the correct type
attribute in each new XSS test to improve diagnostics: in the "importmap
textContent is escaped like JSON types" test, assert that ctx.headTags contains
type="importmap"; and in the "speculationrules textContent is escaped like JSON
types" test, assert that ctx.headTags contains type="speculationrules". Locate
the checks after calling renderSSRHead(head) (functions
createServerHeadWithContext and renderSSRHead) and add the type assertions
alongside the existing checks for '\\u003C' and absence of the raw
closing-script pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/unhead/src/utils/resolve.ts`:
- Around line 85-86: The JSON-like detection uses the raw props.type so
mixed-case values slip through; normalize the type string to a consistent case
(e.g. lower-case) before performing checks. In resolve.ts update the handling
around the type constant and isJsonLike (the String(props.type) assignment and
the isJsonLike check) to use a normalized value (e.g. assign const type =
String(props.type).toLowerCase() or create a separate normalizedType variable)
and then match against 'json', 'importmap', and 'speculationrules' on that
normalized value.

In `@packages/unhead/test/unit/server/xss.test.ts`:
- Around line 354-380: Add explicit assertions that the rendered script tag
includes the correct type attribute in each new XSS test to improve diagnostics:
in the "importmap textContent is escaped like JSON types" test, assert that
ctx.headTags contains type="importmap"; and in the "speculationrules textContent
is escaped like JSON types" test, assert that ctx.headTags contains
type="speculationrules". Locate the checks after calling renderSSRHead(head)
(functions createServerHeadWithContext and renderSSRHead) and add the type
assertions alongside the existing checks for '\\u003C' and absence of the raw
closing-script pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1fc846e6-4d4b-45be-b6d0-af87a5fd0380

📥 Commits

Reviewing files that changed from the base of the PR and between 71932bd and b21501a.

📒 Files selected for processing (5)
  • packages/unhead/src/server/sort.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/plugins/capo.test.ts
  • packages/unhead/test/unit/server/useHeadSafe-edge-cases.test.ts
  • packages/unhead/test/unit/server/xss.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/unhead/src/server/sort.ts
  • packages/unhead/test/unit/plugins/capo.test.ts

@harlan-zw harlan-zw merged commit 8027f76 into main Apr 10, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants