Skip to content

Commit 0abb6a7

Browse files
authored
feat(validate): add performance validation rules (#691)
Add 8 performance hint rules inspired by webperf-snippets: preload-fetchpriority-conflict, too-many-preloads, too-many-preconnects, redundant-dns-prefetch, preload-async-defer-conflict, prefetch-preload-conflict, inline-style-size, inline-script-size. Add type-safe ESLint-style rule configuration with per-rule options support via discriminated union types (e.g. `['warn', { max: 10 }]` tuples).
1 parent 3fa0cc5 commit 0abb6a7

File tree

4 files changed

+407
-4
lines changed

4 files changed

+407
-4
lines changed

docs/head/1.guides/plugins/validate.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ export interface ValidatePluginOptions {
118118

119119
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.
120120

121+
### Performance Hints
122+
123+
Rules inspired by [webperf-snippets](https://webperf-snippets.nucliweb.net/) that catch common performance anti-patterns in head tags:
124+
125+
| Rule ID | Severity | What it catches |
126+
|---------|----------|----------------|
127+
| `preload-fetchpriority-conflict` | `warn` | `<link rel="preload" fetchpriority="low">` is contradictory — preload signals critical, low priority contradicts that |
128+
| `too-many-preloads` | `warn` | More than 6 `<link rel="preload">` tags compete for bandwidth and hurt performance |
129+
| `too-many-preconnects` | `warn` | More than 4 `<link rel="preconnect">` tags — each initiates a TCP+TLS handshake, competing for limited connections |
130+
| `redundant-dns-prefetch` | `info` | Same origin has both `<link rel="preconnect">` and `<link rel="dns-prefetch">` — preconnect already includes DNS resolution |
131+
| `preload-async-defer-conflict` | `warn` | A script is preloaded but also has `async` or `defer` — preload escalates the priority, defeating the purpose |
132+
| `prefetch-preload-conflict` | `warn` | Same resource has both `preload` and `prefetch` — use preload for current page, prefetch for future navigation |
133+
| `inline-style-size` | `info` | Inline `<style>` exceeds 14KB (the critical CSS budget for the first TCP round-trip) |
134+
| `inline-script-size` | `info` | Inline `<script>` exceeds 2KB — consider moving to an external file for cacheability |
135+
121136
## How Do I Configure Rules?
122137

123138
Rules can be disabled or have their severity overridden, similar to ESLint's flat config:
@@ -134,6 +149,23 @@ ValidatePlugin({
134149
```
135150
::
136151

152+
Some rules accept an options object as an ESLint-style `[severity, options]` tuple:
153+
154+
::code-block
155+
```ts [Input]
156+
ValidatePlugin({
157+
rules: {
158+
'too-many-preloads': ['warn', { max: 10 }],
159+
'too-many-preconnects': ['warn', { max: 6 }],
160+
'inline-style-size': ['info', { maxKB: 20 }],
161+
'inline-script-size': ['info', { maxKB: 5 }],
162+
}
163+
})
164+
```
165+
::
166+
167+
The configuration is fully type-safe — only rules that support options accept the tuple form, and options are typed per-rule.
168+
137169
## How Does Source Tracing Work?
138170

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

packages/unhead/src/plugins/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ export { PromisesPlugin } from './promises' // optional
77
export { SafeInputPlugin } from './safe' // optional
88
export { TemplateParamsPlugin } from './templateParams' // optional
99
export { ValidatePlugin } from './validate' // optional
10-
export type { HeadValidationRule, RuleSeverity, ValidatePluginOptions, ValidationRuleId } from './validate'
10+
export type { HeadValidationRule, RuleConfig, RulesConfig, RuleSeverity, ValidatePluginOptions, ValidationRuleId, ValidationRuleOptions } from './validate'

packages/unhead/src/plugins/validate.ts

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export type ValidationRuleId
88
| 'empty-meta-content'
99
| 'empty-title'
1010
| 'html-in-title'
11+
| 'inline-script-size'
12+
| 'inline-style-size'
1113
| 'missing-description'
1214
| 'missing-title'
1315
| 'non-absolute-canonical'
@@ -16,14 +18,33 @@ export type ValidationRuleId
1618
| 'og-missing-description'
1719
| 'og-missing-title'
1820
| 'possible-typo'
21+
| 'prefetch-preload-conflict'
22+
| 'preload-async-defer-conflict'
23+
| 'preload-fetchpriority-conflict'
1924
| 'preload-font-crossorigin'
2025
| 'preload-missing-as'
26+
| 'redundant-dns-prefetch'
2127
| 'robots-conflict'
2228
| 'script-src-with-content'
29+
| 'too-many-preconnects'
30+
| 'too-many-preloads'
2331
| 'twitter-handle-missing-at'
2432
| 'unresolved-template-param'
2533
| 'viewport-user-scalable'
2634

35+
export interface ValidationRuleOptions {
36+
'inline-script-size': { maxKB: number }
37+
'inline-style-size': { maxKB: number }
38+
'too-many-preloads': { max: number }
39+
'too-many-preconnects': { max: number }
40+
}
41+
42+
export type RuleConfig<Id extends ValidationRuleId> = Id extends keyof ValidationRuleOptions
43+
? RuleSeverity | [severity: RuleSeverity, options: ValidationRuleOptions[Id]]
44+
: RuleSeverity
45+
46+
export type RulesConfig = { [K in ValidationRuleId]?: RuleConfig<K> }
47+
2748
export interface HeadValidationRule {
2849
id: ValidationRuleId
2950
message: string
@@ -39,9 +60,19 @@ export interface ValidatePluginOptions {
3960
*/
4061
onReport?: (rules: HeadValidationRule[]) => void
4162
/**
42-
* Configure rule severity. Set to 'off' to disable, or 'warn'/'info' to override severity.
63+
* Configure rule severity and options. Accepts a severity string or an ESLint-style
64+
* `[severity, options]` tuple for rules that support configuration.
65+
*
66+
* @example
67+
* ```ts
68+
* rules: {
69+
* 'missing-description': 'off',
70+
* 'too-many-preloads': ['warn', { max: 10 }],
71+
* 'inline-style-size': ['info', { maxKB: 20 }],
72+
* }
73+
* ```
4374
*/
44-
rules?: Partial<Record<ValidationRuleId, RuleSeverity>>
75+
rules?: RulesConfig
4576
/**
4677
* Project root path. When set, source locations are displayed as relative paths.
4778
*/
@@ -206,6 +237,23 @@ function isAbsoluteUrl(url: string): boolean {
206237
return url.startsWith('http://') || url.startsWith('https://')
207238
}
208239

240+
function resolveSeverity(config: RuleSeverity | [RuleSeverity, unknown] | undefined, fallback: RuleSeverity): RuleSeverity {
241+
if (config == null)
242+
return fallback
243+
return Array.isArray(config) ? config[0] : config
244+
}
245+
246+
function resolveOptions<Id extends keyof ValidationRuleOptions>(
247+
config: RulesConfig,
248+
id: Id,
249+
defaults: ValidationRuleOptions[Id],
250+
): ValidationRuleOptions[Id] {
251+
const entry = config[id]
252+
if (Array.isArray(entry))
253+
return { ...defaults, ...entry[1] }
254+
return defaults
255+
}
256+
209257
function captureSource(root?: string): string | undefined {
210258
const stack = new Error('source').stack
211259
if (!stack)
@@ -252,7 +300,7 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
252300
const rules: HeadValidationRule[] = []
253301

254302
function report(id: ValidationRuleId, message: string, defaultSeverity: 'warn' | 'info', tag?: HeadTag) {
255-
const severity = ruleConfig[id] ?? defaultSeverity
303+
const severity = resolveSeverity(ruleConfig[id] as RuleSeverity | [RuleSeverity, unknown] | undefined, defaultSeverity)
256304
if (severity === 'off')
257305
return
258306
const entryIndex = tag?._p != null ? tag._p >> 10 : undefined
@@ -382,6 +430,31 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
382430

383431
if (tag.tag === 'script' && props.src && (tag.innerHTML || tag.textContent))
384432
report('script-src-with-content', `Script has both "src" and inline content — the browser will ignore the inline content.`, 'warn', tag)
433+
434+
// === Performance Hints ===
435+
// Inspired by webperf-snippets (https://webperf-snippets.nucliweb.net/)
436+
437+
// Preload + fetchpriority="low" is contradictory
438+
if (tag.tag === 'link' && props.rel === 'preload' && props.fetchpriority === 'low')
439+
report('preload-fetchpriority-conflict', `Preload with fetchpriority="low" is contradictory — preload signals critical, low priority contradicts that.`, 'warn', tag)
440+
441+
// Inline style size check (14KB critical CSS budget)
442+
if (tag.tag === 'style' && (tag.innerHTML || tag.textContent)) {
443+
const content = tag.innerHTML || tag.textContent || ''
444+
const sizeKB = new TextEncoder().encode(content).byteLength / 1024
445+
const { maxKB: styleMaxKB } = resolveOptions(ruleConfig, 'inline-style-size', { maxKB: 14 })
446+
if (sizeKB > styleMaxKB)
447+
report('inline-style-size', `Inline <style> is ${sizeKB.toFixed(1)}KB — exceeds ${styleMaxKB}KB critical CSS budget. Consider moving to an external stylesheet for cacheability.`, 'info', tag)
448+
}
449+
450+
// Inline script size check (2KB threshold)
451+
if (tag.tag === 'script' && !props.src && (tag.innerHTML || tag.textContent)) {
452+
const content = tag.innerHTML || tag.textContent || ''
453+
const sizeKB = new TextEncoder().encode(content).byteLength / 1024
454+
const { maxKB: scriptMaxKB } = resolveOptions(ruleConfig, 'inline-script-size', { maxKB: 2 })
455+
if (sizeKB > scriptMaxKB)
456+
report('inline-script-size', `Inline <script> is ${sizeKB.toFixed(1)}KB — consider moving to an external file for cacheability.`, 'info', tag)
457+
}
385458
}
386459

387460
// === Cross-tag Validation ===
@@ -411,6 +484,64 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
411484
if (!hasDescription && isIndexable)
412485
report('missing-description', `Page is missing a meta description and is indexable by search engines.`, 'warn')
413486

487+
// === Performance Cross-tag Checks ===
488+
// Inspired by webperf-snippets (https://webperf-snippets.nucliweb.net/)
489+
490+
// Too many preloads compete for bandwidth
491+
const { max: maxPreloads } = resolveOptions(ruleConfig, 'too-many-preloads', { max: 6 })
492+
const preloadCount = tags.filter((t: HeadTag) => t.tag === 'link' && t.props.rel === 'preload').length
493+
if (preloadCount > maxPreloads)
494+
report('too-many-preloads', `Found ${preloadCount} preload links — more than ${maxPreloads} preloads compete for bandwidth and can hurt performance.`, 'warn')
495+
496+
// Too many preconnects waste connections
497+
const { max: maxPreconnects } = resolveOptions(ruleConfig, 'too-many-preconnects', { max: 4 })
498+
const preconnectCount = tags.filter((t: HeadTag) => t.tag === 'link' && t.props.rel === 'preconnect').length
499+
if (preconnectCount > maxPreconnects)
500+
report('too-many-preconnects', `Found ${preconnectCount} preconnect links — each initiates a TCP+TLS handshake, more than ${maxPreconnects} compete for limited connections.`, 'warn')
501+
502+
// Redundant dns-prefetch when preconnect exists for same origin
503+
const preconnectOrigins = new Set<string>()
504+
const dnsPrefetchTags: HeadTag[] = []
505+
for (const tag of tags) {
506+
if (tag.tag === 'link' && tag.props.href) {
507+
if (tag.props.rel === 'preconnect')
508+
preconnectOrigins.add(tag.props.href)
509+
else if (tag.props.rel === 'dns-prefetch')
510+
dnsPrefetchTags.push(tag)
511+
}
512+
}
513+
for (const tag of dnsPrefetchTags) {
514+
if (preconnectOrigins.has(tag.props.href))
515+
report('redundant-dns-prefetch', `dns-prefetch for "${tag.props.href}" is redundant — preconnect already includes DNS resolution.`, 'info', tag)
516+
}
517+
518+
// Preload + async/defer script conflict (priority escalation anti-pattern)
519+
const preloadScriptHrefs = new Map<string, HeadTag>()
520+
for (const tag of tags) {
521+
if (tag.tag === 'link' && tag.props.rel === 'preload' && tag.props.as === 'script' && tag.props.href)
522+
preloadScriptHrefs.set(tag.props.href, tag)
523+
}
524+
for (const tag of tags) {
525+
if (tag.tag === 'script' && tag.props.src && (tag.props.async || tag.props.defer)) {
526+
const preloadTag = preloadScriptHrefs.get(tag.props.src)
527+
if (preloadTag) {
528+
const attr = tag.props.async ? 'async' : 'defer'
529+
report('preload-async-defer-conflict', `Script "${tag.props.src}" is preloaded but has "${attr}" — preload escalates priority, defeating the purpose of ${attr}. Remove the preload or add fetchpriority="low" to the script.`, 'warn', preloadTag)
530+
}
531+
}
532+
}
533+
534+
// Prefetch + preload conflict (should be one or the other)
535+
const preloadHrefs = new Set<string>()
536+
for (const tag of tags) {
537+
if (tag.tag === 'link' && tag.props.rel === 'preload' && tag.props.href)
538+
preloadHrefs.add(tag.props.href)
539+
}
540+
for (const tag of tags) {
541+
if (tag.tag === 'link' && tag.props.rel === 'prefetch' && tag.props.href && preloadHrefs.has(tag.props.href))
542+
report('prefetch-preload-conflict', `"${tag.props.href}" has both preload and prefetch — use preload for current page resources, prefetch for future navigation.`, 'warn', tag)
543+
}
544+
414545
// Dispatch
415546
if (rules.length) {
416547
if (options.onReport) {

0 commit comments

Comments
 (0)