Skip to content

Commit 3fa0cc5

Browse files
authored
feat: ValidatePlugin for head tag validation (#690)
* chore: rename DevValidationPlugin to ValidatePlugin The plugin is useful in production too, not just dev. Rename to ValidatePlugin/ValidatePluginOptions and update file names, docs, and tests to match. * fix(lint): extract inline regexes to module scope * feat: replace disableRules with eslint-style rules config Rules can now be configured with 'off', 'warn', or 'info' to disable or override severity per rule. * fix: remove unused ts-expect-error directive * feat: add source tracing to validation rules 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. * feat: add root option for relative source paths When root is provided, source locations in validation rules are displayed as relative paths instead of absolute. * fix: prefix relative source paths with ./ for terminal clickthrough * docs: update options, add source tracing section, add source/root tests * fix: update export snapshots with ValidatePlugin * fix: address code review feedback - 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 * feat: add ValidationRuleId union type for type-safe rule config
1 parent 24cdd68 commit 3fa0cc5

9 files changed

Lines changed: 1215 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
---
2+
title: "Validate"
3+
description: "Catch common SEO and head tag mistakes. Validates URLs, meta tags, Open Graph, Twitter Cards, and detects typos with fuzzy matching."
4+
navigation.title: "Validate"
5+
---
6+
7+
**Quick Answer:** The Validate plugin catches common head tag mistakes β€” non-absolute URLs, missing OG tags, typos in meta properties, conflicting robots directives, and more. It runs only when you register it and is fully tree-shakeable.
8+
9+
## What Does This Plugin Do?
10+
11+
The Validate plugin inspects the final resolved head output and warns about issues that TypeScript can't catch:
12+
13+
- **URL problems** β€” relative canonical/OG URLs, canonical vs og:url mismatches
14+
- **Missing tags** β€” no title, no description (when indexable), missing OG companions
15+
- **Content issues** β€” empty meta content, HTML in title, unresolved template params
16+
- **Conflicts** β€” contradictory robots directives, accessibility-harmful viewport settings
17+
- **Typos** β€” fuzzy-matches unknown meta properties/names against known values with "Did you mean?" suggestions
18+
19+
## How Do I Set Up the Plugin?
20+
21+
Register the plugin when you want head tag validation β€” it's fully tree-shakeable when not imported:
22+
23+
::code-block
24+
```ts [Input]
25+
import { ValidatePlugin } from 'unhead/plugins'
26+
27+
const head = createHead({
28+
plugins: [
29+
ValidatePlugin()
30+
]
31+
})
32+
```
33+
::
34+
35+
By default, warnings are logged via `console.warn`. You can provide a custom reporter:
36+
37+
::code-block
38+
```ts [Input]
39+
ValidatePlugin({
40+
onReport(rules) {
41+
// rules: Array<{ id, message, severity, source?, tag? }>
42+
for (const rule of rules) {
43+
const loc = rule.source ? ` (${rule.source})` : ''
44+
console.warn(`[${rule.id}] ${rule.message}${loc}`)
45+
}
46+
}
47+
})
48+
```
49+
::
50+
51+
## What Options Can I Configure?
52+
53+
::code-block
54+
```ts [Input]
55+
export interface ValidatePluginOptions {
56+
/**
57+
* Callback to handle validation results. Receives all rules found per resolve cycle.
58+
* Defaults to `console.warn` for each rule.
59+
*/
60+
onReport?: (rules: HeadValidationRule[]) => void
61+
/**
62+
* Configure rule severity. Set to 'off' to disable, or 'warn'/'info' to override.
63+
*/
64+
rules?: Partial<Record<string, 'warn' | 'info' | 'off'>>
65+
/**
66+
* Project root path. When set, source locations are displayed as relative paths (e.g. ./src/components/MyPage.vue:42:5).
67+
*/
68+
root?: string
69+
}
70+
```
71+
::
72+
73+
## What Rules Are Included?
74+
75+
### URL Validity
76+
77+
| Rule ID | What it catches |
78+
|---------|----------------|
79+
| `non-absolute-canonical` | Canonical URL is not absolute (`/page` instead of `https://example.com/page`) |
80+
| `non-absolute-og-url` | `og:image`, `og:url`, `og:video`, `og:audio`, `twitter:image`, etc. are not absolute URLs |
81+
| `canonical-og-url-mismatch` | `<link rel="canonical">` href differs from `og:url` content |
82+
83+
### Content Quality
84+
85+
| Rule ID | What it catches |
86+
|---------|----------------|
87+
| `missing-title` | Page has no `<title>` tag |
88+
| `missing-description` | Page has no `<meta name="description">` and is indexable (no `noindex`) |
89+
| `empty-title` | Title tag exists but is empty or whitespace-only |
90+
| `empty-meta-content` | Meta tag has `name`/`property` but empty `content` |
91+
| `html-in-title` | Title contains `<` or `>` characters (will be escaped, not rendered as HTML) |
92+
| `unresolved-template-param` | Literal `%paramName%` found in rendered output β€” template params may be misconfigured |
93+
94+
### Missing Companion Tags
95+
96+
| Rule ID | What it catches |
97+
|---------|----------------|
98+
| `og-image-missing-dimensions` | `og:image` is set but `og:image:width` and/or `og:image:height` are missing β€” social platforms may not display the image |
99+
| `og-missing-title` | Open Graph tags are present but `og:title` is missing |
100+
| `og-missing-description` | Open Graph tags are present but `og:description` is missing |
101+
| `preload-font-crossorigin` | `<link rel="preload" as="font">` is missing `crossorigin` β€” the font will be fetched twice |
102+
| `preload-missing-as` | `<link rel="preload">` is missing the required `as` attribute |
103+
| `script-src-with-content` | `<script src="...">` also has inline content β€” the browser will ignore the inline content |
104+
105+
### Conflict Detection
106+
107+
| Rule ID | Severity | What it catches |
108+
|---------|----------|----------------|
109+
| `robots-conflict` | `warn` | Robots meta has contradictory directives (e.g., `index, noindex` or `follow, nofollow`) |
110+
| `viewport-user-scalable` | `info` | Viewport has `user-scalable=no` or `maximum-scale=1` which harms accessibility |
111+
| `twitter-handle-missing-at` | `warn` | `twitter:site` or `twitter:creator` value doesn't start with `@` |
112+
113+
### Typo Detection
114+
115+
| Rule ID | What it catches |
116+
|---------|----------------|
117+
| `possible-typo` | Unknown meta `property` or `name` that is close to a known value. Uses fuzzy matching to suggest corrections: `og:titl` β†’ "Did you mean `og:title`?" |
118+
119+
Typo detection only runs for recognized prefixes (`og:`, `article:`, `book:`, `profile:`, `fb:`, `twitter:`, or standard meta names without a colon). Custom prefixes like `custom:foo` are ignored.
120+
121+
## How Do I Configure Rules?
122+
123+
Rules can be disabled or have their severity overridden, similar to ESLint's flat config:
124+
125+
::code-block
126+
```ts [Input]
127+
ValidatePlugin({
128+
rules: {
129+
'missing-description': 'off',
130+
'viewport-user-scalable': 'off',
131+
'missing-title': 'info', // downgrade from warn to info
132+
}
133+
})
134+
```
135+
::
136+
137+
## How Does Source Tracing Work?
138+
139+
Each validation rule includes a `source` field pointing to the `head.push()` call that introduced the problematic tag. By default this is an absolute path. Set `root` to get clickable relative paths in your terminal or IDE:
140+
141+
::code-block
142+
```ts [Input]
143+
ValidatePlugin({
144+
root: process.cwd(),
145+
})
146+
// output: [unhead] Canonical URL should be absolute, received "/page". (./src/components/MyPage.vue:42:5)
147+
```
148+
::
149+
150+
## How Do I Integrate with Framework DevTools?
151+
152+
The `onReport` callback receives structured rule objects, making it easy to integrate with any UI:
153+
154+
::code-block
155+
```ts [Input]
156+
ValidatePlugin({
157+
onReport(rules) {
158+
// Example: Nuxt DevTools integration
159+
for (const rule of rules) {
160+
devtools.addWarning({
161+
id: rule.id,
162+
message: rule.message,
163+
severity: rule.severity,
164+
// rule.tag contains the full HeadTag object for inspection
165+
})
166+
}
167+
}
168+
})
169+
```
170+
::
171+
172+
## Related
173+
174+
- [Canonical Plugin](/docs/head/guides/plugins/canonical) - Auto-resolve relative URLs to absolute
175+
- [Infer SEO Meta](/docs/head/guides/plugins/infer-seo-meta-tags) - Auto-generate OG and Twitter meta tags
176+
- [useSeoMeta()](/docs/head/api/composables/use-seo-meta) - Type-safe SEO meta management

β€Žpackages/unhead/src/plugins/index.tsβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ export { InferSeoMetaPlugin } from './inferSeoMetaPlugin' // optional
66
export { PromisesPlugin } from './promises' // optional
77
export { SafeInputPlugin } from './safe' // optional
88
export { TemplateParamsPlugin } from './templateParams' // optional
9+
export { ValidatePlugin } from './validate' // optional
10+
export type { HeadValidationRule, RuleSeverity, ValidatePluginOptions, ValidationRuleId } from './validate'

0 commit comments

Comments
 (0)