feat: ValidatePlugin for head tag validation#690
Conversation
The plugin is useful in production too, not just dev. Rename to ValidatePlugin/ValidatePluginOptions and update file names, docs, and tests to match.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a new ValidatePlugin (implementation, exports, docs) and comprehensive unit tests; the plugin inspects resolved head tags during render, applies a catalog of validation rules (URLs, content, companions, conflicts, typos), and reports findings via configurable Changes
Sequence Diagram(s)sequenceDiagram
participant App as App / Head API
participant Resolver as Head Resolver
participant Validator as ValidatePlugin
participant Reporter as onReport / Console
App->>Resolver: request head resolution (e.g., renderSSRHead)
Resolver->>Validator: provide resolved tags + context
Validator->>Validator: run per-tag and cross-tag validation rules
alt Findings exist
Validator->>Reporter: send batched report via onReport (or console.warn)
else No findings
Validator-->>Reporter: no report emitted
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Bundle Size Analysis
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/unhead/test/unit/plugins/validate.test.ts (1)
19-19: Test suite name still references legacydevValidationPluginname.The describe block uses
'devValidationPlugin'but the PR renames the plugin toValidatePlugin. Update the test suite name for consistency.Suggested fix
-describe('devValidationPlugin', () => { +describe('ValidatePlugin', () => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/test/unit/plugins/validate.test.ts` at line 19, The test suite describe still uses the old name 'devValidationPlugin' — update the describe call (describe('devValidationPlugin', ...)) in packages/unhead/test/unit/plugins/validate.test.ts to the new plugin name 'ValidatePlugin' so the test suite label matches the renamed plugin; leave all inner tests and references untouched.packages/unhead/src/plugins/validate.ts (1)
182-184: Plugin key still uses legacydev-validationname.The PR objective is to rename
DevValidationPlugintoValidatePlugin, but the plugin key is still'dev-validation'. For consistency with the new naming, consider updating this to'validate'or'validation'.Suggested fix
return defineHeadPlugin({ - key: 'dev-validation', + key: 'validate', hooks: {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/plugins/validate.ts` around lines 182 - 184, Update the plugin key from the legacy 'dev-validation' to match the new name (e.g., 'validate' or 'validation') inside the defineHeadPlugin call; specifically replace the key value in the object passed to defineHeadPlugin and search for any other usages of the string 'dev-validation' (and references to DevValidationPlugin) and update them to the new key/name so the exported/registered plugin name matches ValidatePlugin.
🤖 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/test/unit/plugins/validate.test.ts`:
- Around line 229-232: Remove the unused TypeScript suppression by deleting the
"// `@ts-expect-error` missing as" comment on the head.push call that pushes link:
[{ rel: 'preload', href: '/style.css' }]; specifically locate the head.push
invocation in validate.test.ts and remove the `@ts-expect-error` directive so the
test no longer contains an unnecessary TypeScript expect error.
---
Nitpick comments:
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 182-184: Update the plugin key from the legacy 'dev-validation' to
match the new name (e.g., 'validate' or 'validation') inside the
defineHeadPlugin call; specifically replace the key value in the object passed
to defineHeadPlugin and search for any other usages of the string
'dev-validation' (and references to DevValidationPlugin) and update them to the
new key/name so the exported/registered plugin name matches ValidatePlugin.
In `@packages/unhead/test/unit/plugins/validate.test.ts`:
- Line 19: The test suite describe still uses the old name 'devValidationPlugin'
— update the describe call (describe('devValidationPlugin', ...)) in
packages/unhead/test/unit/plugins/validate.test.ts to the new plugin name
'ValidatePlugin' so the test suite label matches the renamed plugin; leave all
inner tests and references untouched.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 05b44bd4-5f2f-4ab3-bbb0-cdb1002c9b26
📒 Files selected for processing (4)
docs/head/1.guides/plugins/validate.mdpackages/unhead/src/plugins/index.tspackages/unhead/src/plugins/validate.tspackages/unhead/test/unit/plugins/validate.test.ts
Rules can now be configured with 'off', 'warn', or 'info' to disable or override severity per rule.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/head/1.guides/plugins/validate.md`:
- Around line 23-32: The fenced code blocks following the ::code-block
directives (examples that use ValidatePlugin and createHead) are touching the
directive markers and trigger MD031; fix by adding a blank line before and after
each fenced triple-backtick block so the fence is separated from the directive
and surrounding text — update the blocks that contain ValidatePlugin() and
createHead(...) as well as the other listed ranges (37-48, 53-66, 121-130,
137-152) to ensure there is an empty line both immediately before the opening
``` and immediately after the closing ``` for each example.
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 140-143: The viewport validation treats "maximum-scale" with a
tolerant regex (MAX_SCALE_RE) but checks "user-scalable" as a raw substring, so
variants like "user-scalable = no" or different casing are missed; update the
user-scalable check to use a case-insensitive, spacing-tolerant regex (e.g.
mirror the style of MAX_SCALE_RE) that matches user-scalable\s*=\s*no (with /i)
and replace any raw substring checks for "user-scalable" (and the duplicate
logic referenced around lines 269-275) to use this regex so both directives are
normalized consistently.
- Around line 42-82: KNOWN_META_PROPERTIES contains three misspellings that
cause valid meta tags to be flagged; update the set (the KNOWN_META_PROPERTIES
constant in this file) to replace "book:release_data" with "book:release_date",
"fb:app:id" with "fb:app_id", and "og:site:name" with "og:site_name" so the
canonical spellings are allowed and avoid false positive possible-typo warnings.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ddbf7849-b2cd-41a3-a58d-017f9a7420be
📒 Files selected for processing (4)
docs/head/1.guides/plugins/validate.mdpackages/unhead/src/plugins/index.tspackages/unhead/src/plugins/validate.tspackages/unhead/test/unit/plugins/validate.test.ts
Wraps head.push() to capture stack traces, then resolves the source location from tag._p back to the originating push() call. The source is included in HeadValidationRule and printed in default console output.
When root is provided, source locations in validation rules are displayed as relative paths instead of absolute.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
packages/unhead/src/plugins/validate.ts (2)
140-144:⚠️ Potential issue | 🟡 MinorNormalize the viewport checks for consistent matching.
The
maximum-scalecheck uses a regex with spacing tolerance, butuser-scalableuses a raw substring match. This means variants likeuser-scalable = no(with spaces) or uppercase will slip through.Proposed fix
const TEMPLATE_PARAM_RE = /%\w+(?:\.\w+)?%/ -const MAX_SCALE_RE = /maximum-scale\s*=\s*1(?:\.0?)?(?:\s|,|$)/ +const MAX_SCALE_RE = /maximum-scale\s*=\s*1(?:\.0?)?(?:\s|,|$)/i +const USER_SCALABLE_NO_RE = /user-scalable\s*=\s*no(?:\s|,|$)/i const NUMERIC_RE = /^\d+$/// Viewport accessibility if (metaKey === 'viewport' && content) { - if (content.includes('user-scalable=no')) + if (USER_SCALABLE_NO_RE.test(content)) report('viewport-user-scalable', `viewport has "user-scalable=no" which prevents zooming and harms accessibility.`, 'info', tag) if (MAX_SCALE_RE.test(content)) report('viewport-user-scalable', `viewport "maximum-scale=1" limits zooming and may harm accessibility.`, 'info', tag) }Also applies to: 301-304
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/plugins/validate.ts` around lines 140 - 144, The viewport validation is inconsistent: MAX_SCALE_RE handles spacing and case variations but the code checks for "user-scalable" with a raw substring match; replace that raw check with a dedicated regex (e.g., USER_SCALABLE_RE) that mirrors MAX_SCALE_RE's tolerance for optional spaces, equals sign, values like "no"/"0", and make it case-insensitive, then use that regex in the same validation logic where "user-scalable" is currently matched so both maximum-scale and user-scalable variants (like "user-scalable = no" or uppercased) are consistently detected; update any other occurrences of the raw substring check (same logic referenced around the existing MAX_SCALE_RE usage) to use the new USER_SCALABLE_RE.
52-54:⚠️ Potential issue | 🟠 MajorFix the canonical meta-property spellings in the allowlist.
The set contains three misspelled property names that will cause false positive
possible-typowarnings for valid tags:
- Line 52:
book:release_data→book:release_date- Line 54:
fb:app:id→fb:app_id- Line 69:
og:site:name→og:site_nameProposed fix
'book:isbn', - 'book:release_data', + 'book:release_date', 'book:tag', - 'fb:app:id', + 'fb:app_id', 'og:audio','og:locale', 'og:locale:alternate', - 'og:site:name', + 'og:site_name', 'og:title',Also applies to: 69-69
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/plugins/validate.ts` around lines 52 - 54, Update the allowlist of canonical meta-property names in the validate plugin by fixing three typos: change 'book:release_data' to 'book:release_date', 'fb:app:id' to 'fb:app_id', and 'og:site:name' to 'og:site_name' where those strings appear in the allowlist array (the list of allowed meta/property names used by the validate logic).
🤖 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/plugins/validate.ts`:
- Line 199: The stacks Map declared as "stacks" retains source traces keyed by
id and never removes them, causing a memory leak; fix by either (A) switching to
a WeakMap keyed by the actual head entry object (e.g.,
WeakMap<HeadEntry,string>) so traces are GC'd with the entry, or (B) add
explicit cleanup when an entry is disposed—hook into entry.dispose() (or the
dispose callback already used where stacks are read at the spots around the
current iteration logic) to call stacks.delete(theEntryId) so entries 203-209 no
longer leave stale traces. Ensure you update lookups/keys to use the chosen key
type (object for WeakMap) and remove any code that assumes numeric-only keys if
switching to WeakMap.
---
Duplicate comments:
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 140-144: The viewport validation is inconsistent: MAX_SCALE_RE
handles spacing and case variations but the code checks for "user-scalable" with
a raw substring match; replace that raw check with a dedicated regex (e.g.,
USER_SCALABLE_RE) that mirrors MAX_SCALE_RE's tolerance for optional spaces,
equals sign, values like "no"/"0", and make it case-insensitive, then use that
regex in the same validation logic where "user-scalable" is currently matched so
both maximum-scale and user-scalable variants (like "user-scalable = no" or
uppercased) are consistently detected; update any other occurrences of the raw
substring check (same logic referenced around the existing MAX_SCALE_RE usage)
to use the new USER_SCALABLE_RE.
- Around line 52-54: Update the allowlist of canonical meta-property names in
the validate plugin by fixing three typos: change 'book:release_data' to
'book:release_date', 'fb:app:id' to 'fb:app_id', and 'og:site:name' to
'og:site_name' where those strings appear in the allowlist array (the list of
allowed meta/property names used by the validate logic).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a8ddbbf0-fc88-4e32-9ad9-f37ca8690144
📒 Files selected for processing (1)
packages/unhead/src/plugins/validate.ts
- Fix misspelled meta properties: book:release_data → book:release_date, fb:app:id → fb:app_id, og:site:name → og:site_name - Use regex for user-scalable viewport check (tolerates spacing/casing) - Clean up stacks Map on entry dispose to prevent memory leak
🔗 Linked issue
Resolves #394
❓ Type of change
📚 Description
Adds
ValidatePlugin, an optional plugin that inspects resolved head output and warns about common mistakes. Fully tree-shakeable when not imported.Rules can be configured with ESLint-style flat config:
Rules
non-absolute-canonicalwarnnon-absolute-og-urlwarnog:image,og:url,og:video,og:audio,twitter:image, etc. are not absolute URLscanonical-og-url-mismatchwarn<link rel="canonical">href differs fromog:urlmissing-titlewarn<title>tagmissing-descriptionwarn<meta name="description">and is indexableempty-titlewarnempty-meta-contentwarnhtml-in-titlewarn<or>charactersunresolved-template-paramwarn%paramName%in rendered outputog-image-missing-dimensionswarnog:imagewithoutog:image:width/og:image:heightog-missing-titlewarnog:titlemissingog-missing-descriptionwarnog:descriptionmissingpreload-font-crossoriginwarncrossoriginpreload-missing-aswarnasattributescript-src-with-contentwarnsrcand inline contentrobots-conflictwarnindex+noindex)twitter-handle-missing-atwarntwitter:site/twitter:creatormissing@prefixviewport-user-scalableinfouser-scalable=noormaximum-scale=1harms accessibilitypossible-typowarnSummary by CodeRabbit
New Features
Documentation
Tests