Skip to content

feat: ValidatePlugin for head tag validation#690

Merged
harlan-zw merged 11 commits intomainfrom
chore/rename-dev-validation-to-validate
Mar 11, 2026
Merged

feat: ValidatePlugin for head tag validation#690
harlan-zw merged 11 commits intomainfrom
chore/rename-dev-validation-to-validate

Conversation

@harlan-zw
Copy link
Collaborator

@harlan-zw harlan-zw commented Mar 11, 2026

🔗 Linked issue

Resolves #394

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore

📚 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:

ValidatePlugin({
  rules: {
    'missing-description': 'off',
    'viewport-user-scalable': 'off',
    'missing-title': 'info',
  }
})

Rules

Rule ID Default What it catches
non-absolute-canonical warn Canonical URL is not absolute
non-absolute-og-url warn og:image, og:url, og:video, og:audio, twitter:image, etc. are not absolute URLs
canonical-og-url-mismatch warn <link rel="canonical"> href differs from og:url
missing-title warn Page has no <title> tag
missing-description warn Page has no <meta name="description"> and is indexable
empty-title warn Title tag exists but is empty
empty-meta-content warn Meta tag has name/property but empty content
html-in-title warn Title contains < or > characters
unresolved-template-param warn Literal %paramName% in rendered output
og-image-missing-dimensions warn og:image without og:image:width/og:image:height
og-missing-title warn OG tags present but og:title missing
og-missing-description warn OG tags present but og:description missing
preload-font-crossorigin warn Font preload missing crossorigin
preload-missing-as warn Preload link missing as attribute
script-src-with-content warn Script has both src and inline content
robots-conflict warn Contradictory robots directives (index + noindex)
twitter-handle-missing-at warn twitter:site/twitter:creator missing @ prefix
viewport-user-scalable info user-scalable=no or maximum-scale=1 harms accessibility
possible-typo warn Fuzzy-matched unknown meta property/name with "Did you mean?" suggestion

Summary by CodeRabbit

  • New Features

    • Added a Validate plugin to detect URL issues, content-quality problems, missing companion tags, cross-tag conflicts and typos, with configurable severities and reporting.
  • Documentation

    • Added a comprehensive Validate plugin guide covering setup, configuration options, full rule catalog, examples and integration notes.
  • Tests

    • Added extensive unit tests covering validation rules, edge cases, reporting behavior and option handling.

The plugin is useful in production too, not just dev. Rename to
ValidatePlugin/ValidatePluginOptions and update file names, docs,
and tests to match.
@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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 onReport or console.warn.

Changes

Cohort / File(s) Summary
Documentation
docs/head/1.guides/plugins/validate.md
New guide describing the Validate plugin, tree-shakeable setup, configuration (onReport, rules), full rule catalog, examples, and DevTools integration notes.
Plugin Exports
packages/unhead/src/plugins/index.ts
Exports added: ValidatePlugin, and types HeadValidationRule, RuleSeverity, ValidatePluginOptions.
Plugin Implementation
packages/unhead/src/plugins/validate.ts
New plugin implementation: captures source locations, hooks into tag resolution, accumulates per-resolve rule sets, runs extensive per-tag and cross-tag validations (URL absoluteness, content/templating checks, missing companion tags, conflicts, typo suggestions, preload/script checks), supports rule-level severity/disablement, and reports batched results via onReport or console.warn.
Unit Tests
packages/unhead/test/unit/plugins/validate.test.ts
Extensive tests covering URL rules, content/edge cases, missing companions, conflict detection, typo suggestions, options behavior (disableRules, onReport), batching, and console fallback.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through head tags, sniffed each line,

found sly typos and URLs that didn't shine,
nudged missing metas and missing size hues,
bundled my notes and bounced them back to you.
Carrots for tidy heads and clearer views! 🎋

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly and specifically describes the main feature being added: the ValidatePlugin for validating head tags.
Description check ✅ Passed Description includes linked issue, correct type selection (New feature), and detailed explanation of the ValidatePlugin with configuration examples and comprehensive rule documentation.
Linked Issues check ✅ Passed All objectives from issue #394 are met: plugin validates head output, detects unknown meta tags, checks non-absolute URLs, validates Twitter handles, and surfaces common development mistakes with configurable rules.
Out of Scope Changes check ✅ Passed All changes are in scope: ValidatePlugin implementation, exports, comprehensive tests, and documentation directly address the validation plugin requirements from issue #394.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/rename-dev-validation-to-validate

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.

@harlan-zw harlan-zw changed the title chore: rename DevValidationPlugin to ValidatePlugin feat: add ValidatePlugin for head tag validation Mar 11, 2026
@github-actions
Copy link
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.5 kB 4.3 kB
Server (Minimal) 10.2 kB 4.2 kB
Vue Client (Minimal) 11.4 kB 4.7 kB
Vue Server (Minimal) 11.1 kB 4.6 kB

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

19-19: Test suite name still references legacy devValidationPlugin name.

The describe block uses 'devValidationPlugin' but the PR renames the plugin to ValidatePlugin. 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 legacy dev-validation name.

The PR objective is to rename DevValidationPlugin to ValidatePlugin, 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

📥 Commits

Reviewing files that changed from the base of the PR and between cb39b45 and 8526efb.

📒 Files selected for processing (4)
  • docs/head/1.guides/plugins/validate.md
  • packages/unhead/src/plugins/index.ts
  • packages/unhead/src/plugins/validate.ts
  • packages/unhead/test/unit/plugins/validate.test.ts

Rules can now be configured with 'off', 'warn', or 'info' to disable
or override severity per rule.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8526efb and 32bd8b5.

📒 Files selected for processing (4)
  • docs/head/1.guides/plugins/validate.md
  • packages/unhead/src/plugins/index.ts
  • packages/unhead/src/plugins/validate.ts
  • packages/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.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/unhead/src/plugins/validate.ts (2)

140-144: ⚠️ Potential issue | 🟡 Minor

Normalize the viewport checks for consistent matching.

The maximum-scale check uses a regex with spacing tolerance, but user-scalable uses a raw substring match. This means variants like user-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 | 🟠 Major

Fix the canonical meta-property spellings in the allowlist.

The set contains three misspelled property names that will cause false positive possible-typo warnings for valid tags:

  • Line 52: book:release_databook:release_date
  • Line 54: fb:app:idfb:app_id
  • Line 69: og:site:nameog:site_name
Proposed 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

📥 Commits

Reviewing files that changed from the base of the PR and between 676c9bc and 611716f.

📒 Files selected for processing (1)
  • packages/unhead/src/plugins/validate.ts

@harlan-zw harlan-zw changed the title feat: add ValidatePlugin for head tag validation feat: ValidatePlugin for head tag validation Mar 11, 2026
- 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
@harlan-zw harlan-zw merged commit 3fa0cc5 into main Mar 11, 2026
6 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.

Development Validation Plugin

1 participant