refactor: extract shared predicates, swap CLI to oxc-walker#757
Conversation
… CLI to oxc-walker Per-rule logic was previously duplicated across three sites: the runtime ValidatePlugin, @unhead/eslint-plugin rule modules, and @unhead/cli. The CLI's audit was also unusable on real projects — it built an ESLint config without parsers, so every .ts/.vue file failed with a parse error. This change extracts each rule's predicate as a parser-agnostic pure function in unhead/validate, then makes all three layers thin adapters that materialise their AST into a TagInput and call the predicate. unhead/validate - Add predicates/ module: 14 pure TagPredicate / HeadInputPredicate functions, one per existing source-level rule. Each takes a parser-agnostic TagInput and returns Diagnostic[] with optional PredicateFix descriptions. - Add runtime adapter (tagInputFromRuntime, titleInputFromRuntime) so the runtime ValidatePlugin can call the same predicates against resolved HeadTags. Handles name lowercasing, content null-coercion, innerHTML/textContent surfacing, top-level tagPriority lift. - Add 'prefer-define-helpers' to VALIDATION_RULE_IDS for the migration-only predicate. @unhead/eslint-plugin - Each rule module becomes a ~15-line adapter: import predicate, wrap with createTagPredicateRule / createHeadInputPredicateRule. - New utils/materialize.ts (ESTree ObjectExpression → TagInput), utils/applyDiagnostic.ts (Diagnostic → ctx.report), and utils/createPredicateRule.ts. - Bundle drops from 21 KB to 16 KB. Existing tests migrated from messageId assertions to message regex (predicates emit raw strings, not message templates). - README: fix broken default-import example (configs is a named export). @unhead/cli - Drop @unhead/eslint-plugin and eslint dependencies entirely. Replace with oxc-parser + oxc-walker + magic-string. - New oxc/ subdir: walker, materialize, applyFix, audit, sfc (regex <script> extractor that preserves byte offsets for Vue/Svelte SFCs). - New format.ts produces stylish-ish output without pulling in ESLint. - Audit on a real Nuxt project (~hundreds of files) runs in ~0.4s with zero parse errors instead of 600+. Vue SFCs report file-relative line/column. Migrate produces identical output to the eslint-plugin's --fix path through shared PredicateFix shapes. - README updated: notes oxc-based parsing, drops ESLint references. unhead/plugins/validate - Refactor ValidatePlugin to dispatch through shared predicates for the per-tag rules it owned inline (empty-meta-content, robots-conflict, viewport-user-scalable, twitter-handle-missing-at, possible-typo, html-in-title, preload-font-crossorigin, preload-missing-as, script-src-with-content, defer-on-module-script, deprecated-prop-*, numeric-tag-priority, non-absolute-canonical). - Cross-tag, content-size, and runtime-only checks (canonical-og-url- mismatch, render-blocking-script, charset-not-early, too-many-*, meta-beyond-1mb, …) stay inline since they need cross-tag context. - PREDICATE_SEVERITY map preserves the legacy 'info' severities for defer-on-module-script, viewport-user-scalable, numeric-tag-priority. - Plugin shrinks from 566 to 513 lines. All 121 ValidatePlugin tests pass without modification (assertions check rule.id). Tests - 42 new predicate unit tests against plain TagInput fixtures. - 13 new CLI audit/migrate tests including a Vue SFC fixture. - All existing tests pass: 799 unhead, 42 eslint-plugin, 13 CLI.
|
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:
📝 WalkthroughWalkthroughReplaces the ESLint-based CLI lint pipeline with an oxc-parser–based audit/migrate pipeline; extracts validator logic into predicate modules under Changes
Sequence Diagram(s)sequenceDiagram
participant CLI as CLI (unhead)
participant FS as File System
participant Parser as oxc-parser
participant Walker as oxc-walker
participant Validator as unhead/validate
participant Formatter as formatStylish
CLI->>FS: glob patterns -> file list
loop per file
CLI->>FS: read file
FS-->>CLI: source
CLI->>Parser: parse (with SFC extraction)
Parser-->>CLI: AST + script pieces
CLI->>Walker: walk AST -> head/tag nodes
Walker-->>CLI: nodes
CLI->>Validator: run predicates (audit or migrate)
Validator-->>CLI: diagnostics (+ optional fixes/output)
CLI->>Formatter: format diagnostics
Formatter-->>CLI: formatted output
alt migrate & output present
CLI->>FS: write migrated output
FS-->>CLI: write result
end
end
CLI->>CLI: set exit code via summarise(results)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
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 |
The exports-snapshot test asserts the public surface of every package. Since the predicates module adds 17 new named exports to unhead/validate, regenerate the snapshot. No behaviour change. The bad.vue test fixture is intentionally invalid (it triggers preload-font-crossorigin et al.) but vue-tsc was checking it during the typecheck script. Add @ts-nocheck so the fixture only exercises the CLI's parser path, not the project's typecheck.
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/cli/README.md (1)
97-100:⚠️ Potential issue | 🟡 MinorSoften "byte-for-byte identical" claim.
CLI output goes through
formatStylishand ESLint output goes through whatever formatter is configured in the consumer's pipeline; the rule IDs and messages will match, but the rendered diagnostic stream won't be byte-for-byte equal. Suggest "produce the same diagnostics" or "report identical rule IDs and messages."📝 Suggested wording
-`audit` and `migrate` invoke the same predicate functions exported from `unhead/validate` that `@unhead/eslint-plugin` registers as ESLint rules. Source-level diagnostics are byte-for-byte identical between `unhead audit` (CLI) and `pnpm lint` (your editor + CI ESLint pipeline). Use the CLI for one-shot project-wide audits and CI; use the ESLint plugin for inline editor feedback. +`audit` and `migrate` invoke the same predicate functions exported from `unhead/validate` that `@unhead/eslint-plugin` registers as ESLint rules. The same rule IDs, messages, and fixes are produced by both `unhead audit` (CLI) and `pnpm lint` (your editor + CI ESLint pipeline). Use the CLI for one-shot project-wide audits and CI; use the ESLint plugin for inline editor feedback.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/README.md` around lines 97 - 100, The README's claim that outputs are "byte-for-byte identical" is too strong; update the paragraph referencing `audit`, `migrate`, `unhead/validate`, and `@unhead/eslint-plugin` to say the CLI and ESLint plugin "produce the same diagnostics" or "report identical rule IDs and messages" instead, and note that CLI output passes through `formatStylish` while editor/CI uses the consumer's formatter so the rendered streams may differ; replace the phrase "byte-for-byte identical" with one of the suggested softer phrasings and add a brief note about formatter differences.
🧹 Nitpick comments (20)
packages/unhead/src/validate/predicates/non-absolute-canonical.ts (1)
3-5: Optional: case-insensitive scheme check.
HTTP:///HTTPS://(uppercase scheme) is technically a valid absolute URL but would currently be flagged as non-absolute. Edge case, but trivially addressed:-function isAbsolute(url: string): boolean { - return url.startsWith('http://') || url.startsWith('https://') -} +function isAbsolute(url: string): boolean { + return /^https?:\/\//i.test(url) +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/non-absolute-canonical.ts` around lines 3 - 5, The isAbsolute function currently only checks for 'http://' and 'https://' in lowercase, so URLs with uppercase schemes like 'HTTP://' will be misclassified; update the isAbsolute(url: string) implementation (function name: isAbsolute) to perform a case-insensitive scheme check—either by lowercasing the url before startsWith checks or by using a case-insensitive regex (e.g., matching /^https?:\/\//i) so both 'HTTP://' and 'http://' are treated as absolute.packages/unhead/src/validate/predicates/preload-rules.ts (1)
17-32: Minor: case-sensitive matching onrelandas.
tag.props.rel !== 'preload'andtag.props.as !== 'font'won't fire onrel="Preload"oras="FONT". HTML attribute values for these are conventionally lowercase, so this is unlikely to bite users in practice, but worth noting if other predicates in this directory normalize casing.Also, the
fix.insertvalue begins with,and relies on the consumer (applyFix) inserting it directly after the existingasproperty's value position. Worth a brief comment on the predicate type or fix shape so future predicate authors don't double up the separator.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/preload-rules.ts` around lines 17 - 32, The predicate preloadFontCrossorigin does strict case-sensitive checks on tag.props.rel and tag.props.as and uses a fix.insert that starts with ", " which couples it to the consumer; update the predicate to normalize the attribute values (e.g., coerce tag.props.rel and tag.props.as to lower-case strings before comparison) so rel="Preload" or as="FONT" match, and change the fix.insert to not include a leading separator (e.g., use "crossorigin: 'anonymous'") so applyFix can decide how to insert separators; keep references to preloadFontCrossorigin and the fix.insert field when making these edits.packages/unhead/src/validate/predicates/empty-meta-content.ts (1)
10-12: Includehttp-equivin the identifier fallback for better messages.For tags like
<meta http-equiv="refresh" content="">, the diagnostic currently readsMeta tag "meta" has empty content.— usinghttp-equivas a key candidate would produce a more useful message.♻️ Proposed change
const key = (typeof tag.props.name === 'string' && tag.props.name) || (typeof tag.props.property === 'string' && tag.props.property) + || (typeof tag.props['http-equiv'] === 'string' && tag.props['http-equiv']) || 'meta'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/empty-meta-content.ts` around lines 10 - 12, The identifier fallback for empty-meta content uses only tag.props.name and tag.props.property, causing poor messages for meta tags using http-equiv; update the const key expression in empty-meta-content.ts (the const named key) to also check for a string-valued tag.props['http-equiv'] (e.g., || (typeof tag.props['http-equiv'] === 'string' && tag.props['http-equiv']) ) before falling back to 'meta' so diagnostics show the http-equiv value when present.packages/eslint-plugin/test/rules.test.ts (1)
22-154: Regex-based message assertions look reasonable.The patterns are specific enough to avoid cross-rule collisions. Note that this couples tests to the predicate's exact wording — if the message strings in
packages/unhead/src/validate/predicates/*are reworded later, these tests will need updating in lockstep. Consider exporting the predicateruleIdconstants and asserting on those (or on a stable substring) for resilience.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/test/rules.test.ts` around lines 22 - 154, Tests currently assert error messages with regexes which are brittle; instead import the stable rule identifier constants from the corresponding predicate modules (e.g. the predicate modules under packages/unhead/src/validate/predicates that define twitterHandleMissingAt, robotsConflict, deferOnModuleScript, etc.) and replace the regex-based errors assertions with errors: [{ ruleId: THE_RULE_ID }] (or assert on a stable substring constant exported by the predicate) so tests assert on the exported ruleId symbol rather than the full message text.packages/unhead/src/validate/predicates/robots-conflict.ts (1)
11-25: Consider handlingnone/allshorthand directives.Per the robots meta spec,
noneis shorthand fornoindex, nofollowandallforindex, follow. Combinations likenone, indexorall, noindexare real-world conflicts that this predicate currently misses. Low priority for this PR, but worth tracking.♻️ Sketch
const directives = content.toLowerCase().split(',').map(d => d.trim()) + const hasIndex = directives.includes('index') || directives.includes('all') + const hasNoindex = directives.includes('noindex') || directives.includes('none') + const hasFollow = directives.includes('follow') || directives.includes('all') + const hasNofollow = directives.includes('nofollow') || directives.includes('none') const out: Diagnostic[] = [] - if (directives.includes('index') && directives.includes('noindex')) { + if (hasIndex && hasNoindex) { out.push({ ... }) } - if (directives.includes('follow') && directives.includes('nofollow')) { + if (hasFollow && hasNofollow) { out.push({ ... }) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/robots-conflict.ts` around lines 11 - 25, The predicate currently checks the raw directives array (variable directives) for conflicting pairs but ignores shorthand tokens; update the logic to expand 'none' → ['noindex','nofollow'] and 'all' → ['index','follow'] into the directives set before conflict checks. Concretely, compute a normalized Set from directives (expanding any 'none'/'all' entries), then test for the conflicting pairs ('index' vs 'noindex', 'follow' vs 'nofollow') and push the same Diagnostics (ruleId 'robots-conflict') into out when found. Ensure you keep the existing lowercase/trim normalization and return out as before.packages/unhead/src/validate/predicates/prefer-define-helpers.ts (1)
23-34: Optional: consider an autofix that also inserts the missing import.Right now the suggestion path hands back the same
wrap-tagfix as the hard-fix path and asks the user to import the helper themselves. Since the CLI/ESLint adapters know the source file, an enhancement would be afixvariant that also injects animport { defineX } from 'unhead'(or upgrades an existing import) so accepting the suggestion produces compilable code. Not required for this PR.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/prefer-define-helpers.ts` around lines 23 - 34, The suggestion should offer an autofix that also inserts or updates the import when the helper is not already imported: when building the Diagnostic in prefer-define-helpers, detect ctx.importedHelpers and, for the suggestions branch (where imported is false), provide a second-kind of fix (or extend the existing fix object) that not only sets type: 'wrap-tag' and wrapWith: helper but also includes metadata to add or update an import for the helper (e.g. import { <helper> } from 'unhead') so the adapter can apply both AST edits; modify the construction of diag.suggestions (and the exported fix shape) to carry this import-insertion variant while leaving the imported=true path unchanged.packages/eslint-plugin/src/rules/title-rules.ts (1)
5-16: ThenoHtmlInTitlepredicate is currently safe but consider adding meta flags as a defensive measure.The predicate currently returns diagnostics with no
fixorsuggestionsfields, soreportDiagnosticwill not attempt to pass either toctx.report(). However, if the predicate is updated in the future to emit fixes or suggestions, the rule's meta will be missing the requiredfixableandhasSuggestionsdeclarations, causing ESLint to throw at runtime. Either add the flags now as a safeguard or document the constraint that this predicate must not emit fixes/suggestions.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/title-rules.ts` around lines 5 - 16, The rule metadata for noHtmlInTitle is missing declarations for possible fixes/suggestions, which will cause ESLint to throw if predicate later returns a fix or suggestions; update the meta object on noHtmlInTitle to include fixable: 'code' if predicate/createHeadInputPredicateRule(predicate) might emit fixes and hasSuggestions: true if it might emit suggestions (or explicitly document that predicate will never emit fixes/suggestions), so add the appropriate fixable/hasSuggestions flags in the meta for noHtmlInTitle to match any future output from predicate.packages/cli/src/commands/migrate.ts (1)
45-62: Dry-run skips theerrorCount > 0 → exitCode = 1signal.In
--dry-runthe function returns at line 48 beforesummarise(results)is consulted, so a CI invocation likeunhead migrate --dry-runwill exit0even when audit errors are present. If that's intentional (dry-run treated as purely informational), fine — otherwise consider hoisting theerrorCountcheck above the dry-run branch so both paths agree.♻️ Suggested adjustment
+ const { errorCount } = summarise(results) + if (dryRun) { const fixable = results.reduce((n, r) => n + (r.output ? 1 : 0), 0) console.log(`unhead migrate: ${fixable} file${fixable === 1 ? '' : 's'} would be modified (dry run)`) + if (errorCount > 0) + process.exitCode = 1 return } let written = 0 for (const r of results) { if (!r.output) continue await writeFile(r.filePath, r.output) written++ } console.log(`unhead migrate: applied fixes to ${written} file${written === 1 ? '' : 's'}`) - const { errorCount } = summarise(results) if (errorCount > 0) process.exitCode = 1🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/commands/migrate.ts` around lines 45 - 62, The dry-run path in migrate (guarded by the dryRun variable) returns before checking summarise(results) so process.exitCode is never set when there are errors; move or duplicate the errorCount check so both branches consult summarise(results) and set process.exitCode = 1 when errorCount > 0. Specifically, after computing const { errorCount } = summarise(results) (or before the dryRun return), ensure the dry-run branch still prints the fixable message but then sets process.exitCode based on errorCount (or hoist the summarise call above the dryRun check) so migrate's behavior is consistent for both dryRun and real runs.packages/eslint-plugin/src/utils/applyDiagnostic.ts (1)
86-103: Optional: drop suggestions whose fixer can't be built instead of registering a no-op.When a suggestion's
PredicateFixreferences a missing property,buildFixerreturnsundefinedand the current code falls back to() => null. ESLint will still display the suggestion to the user, but selecting it is a no-op. Filtering these out gives cleaner UX and matches the defensive intent of the comment inbuildFixer.♻️ Suggested change
ctx.report({ node, message: diag.message, fix: fixer, - suggest: diag.suggestions?.map(s => ({ - desc: s.message, - fix: buildFixer(obj, s.fix, ctx.sourceCode) ?? (() => null), - })), + suggest: diag.suggestions + ?.map((s) => { + const f = buildFixer(obj, s.fix, ctx.sourceCode) + return f ? { desc: s.message, fix: f } : undefined + }) + .filter((x): x is { desc: string, fix: (fixer: Rule.RuleFixer) => Rule.Fix | null } => Boolean(x)), })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/utils/applyDiagnostic.ts` around lines 86 - 103, The reportDiagnostic function currently maps diag.suggestions to objects even when buildFixer returns undefined (falling back to a no-op), so update reportDiagnostic to call buildFixer(obj, s.fix, ctx.sourceCode) for each suggestion and only include suggestions where that fixer is defined: compute a suggestions array by mapping each s to { desc: s.message, fix: fixer } and filter out entries with undefined fixer before passing suggest to ctx.report; reference reportDiagnostic, diag.suggestions and buildFixer to locate the change.packages/cli/src/oxc/applyFix.ts (1)
28-84: Add an exhaustive default to the switch.The function is declared to return
boolean, but theswitch (fix.type)has nodefaultarm — ifPredicateFixgains a new variant inunhead/validate, the CLI silently returnsundefined(andapplyFix's caller assumesboolean). An exhaustive default withassertNever/throw will make the type system fail compilation when new variants are added.♻️ Suggested change
case 'wrap-tag': { magic.appendLeft(obj.start + off, `${fix.wrapWith}(`) magic.appendRight(obj.end + off, `)`) return true } + default: { + const _exhaustive: never = fix + throw new Error(`Unknown PredicateFix type: ${(_exhaustive as PredicateFix).type}`) + } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/oxc/applyFix.ts` around lines 28 - 84, The switch over fix.type in applyFix currently lacks a default, so add an exhaustive default arm that calls an assertion helper (e.g., assertNever) or throws a descriptive Error to ensure the function always returns a boolean and to surface new PredicateFix variants at compile time; update or add an assertNever(value: never) helper if needed and reference the switch on fix.type inside applyFix to implement the default branch that throws/asserts with the unexpected fix value.packages/cli/test/audit.test.ts (2)
90-144: Five identicalrunAuditcalls per migrate test — consider sharing the result.Each migrate
it()re-parses, re-walks, and re-rewritesinput.tsdespite asserting on the sameoutput. HoistingrunAuditintobeforeEach(or a singleitwith multipleexpects) cuts test runtime ~5×.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/test/audit.test.ts` around lines 90 - 144, Hoist the repeated runAudit invocation into a shared setup so the file is parsed only once: call runAudit({ patterns: ['input.ts'], mode: 'migrate', cwd: tmp }) in a beforeEach (or one encompassing it block) and store its return in a shared variable (e.g. results/out) that each test uses; update each it() (the tests asserting rewrites, defer removal, crossorigin, twitter prefixing, and hid→key) to read from that shared results[0].output instead of calling runAudit again, leaving existing expect assertions unchanged.
86-88: EmptyafterEachleaks a temp directory per migrate test.Each
beforeEachcreates a freshmkdtempand the no-opafterEachnever removes it, so everyrunAudit (migrate)run leaves five orphanedunhead-cli-*directories inos.tmpdir(). The comment dismisses cleanup, but the leak occurs on success too — not just failure. A best-effortrmis one line and avoids unbounded growth on dev machines / CI runners.🧹 Suggested cleanup
-import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ - afterEach(async () => { - // Best-effort cleanup; tmp dir leaking on failure isn't worth a try/catch. - }) + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }) + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/test/audit.test.ts` around lines 86 - 88, The empty afterEach in the migrate tests is leaking mkdtemp-created temp dirs; update afterEach to perform a best-effort removal of the temp directory created in beforeEach (the same variable used by the runAudit (migrate) tests), e.g. await fs.promises.rm(tempDir, { recursive: true, force: true }) or fs.rmSync with guards if needed, and swallow any errors so cleanup never fails the test; reference and use the same temp directory variable created in beforeEach and keep afterEach async to await the removal.packages/cli/src/oxc/materialize.ts (1)
6-7: LooseNode = anytyping — consider importing oxc-parser's AST types behind a type-only import.
type-onlyimports (import type { ... } from 'oxc-parser') don't add runtime weight and would catch e.g. accessingp.valueon aSpreadElement(which has novalue) or relying onstart/endif oxc renames offset fields. Today the bug surface is small becausegetKeyNamerejects non-Propertynodes early, but the loose typing is a foot-gun if the AST shape ever drifts.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/oxc/materialize.ts` around lines 6 - 7, Replace the loose "type Node = any" with precise type-only imports from oxc-parser: add an import type line (e.g., import type { Node, Property, SpreadElement, Identifier, Literal } from 'oxc-parser') and use those types in the code (or use Node as the AST root type and narrow to Property in getKeyName) so TypeScript will catch invalid accesses (e.g., accessing .value on SpreadElement) and rely on declared offset names; update any function signatures that use the old Node alias (such as getKeyName and any other helpers in this file) to use the imported oxc-parser types or narrowed unions instead.packages/unhead/src/plugins/validate.ts (1)
207-211:Object.values(tagPredicates)runs every per-tag predicate on every resolved tag, every resolve cycle.This is hot path code —
tags:afterResolvefires on every head update. Calling 10+ predicates per tag (each scanning its own conditions) is fine for typical tag counts but worth keeping an eye on for apps with large head trees. Most predicates short-circuit ontagTypemismatch on their first line, so the overhead should be modest, but consider grouping predicates bytagType(e.g.,predicatesByTagType.get(tag.tagType)) if profiling later flags this.🤖 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 207 - 211, The hot path currently iterates Object.values(tagPredicates) for every resolved tag in the tags:afterResolve loop (see tagInputFromRuntime, tagPredicates, emitFromPredicates), which runs all predicates for every tag; change this to group predicates by tagType (e.g., build a predicatesByTagType Map at predicate registration time keyed by predicate.tagType or the tag types they handle) and in the loop only fetch predicatesByTagType.get(tag.tagType) (falling back to any global predicates) and run emitFromPredicates for that filtered list; update the predicate registration/initialization code where tagPredicates is populated so the grouped map is kept in sync.packages/unhead/src/validate/predicates/no-deprecated-props.ts (1)
27-36: Hardcoded rename target & ruleId couple this predicate to today'sDEPRECATED_PROPSshape.
newKeyresolves to'key'for anything that isn'tchildren, andruleIdis fixed to'deprecated-prop-hid-vmid'for the same bucket. Adding a new entry toDEPRECATED_PROPS(e.g., a futuretagDuplicateStrategydeprecation) will silently rename tokeyand surface under the misleadingdeprecated-prop-hid-vmidrule.Consider sourcing both values from
DEPRECATED_PROPS[key](e.g., anewKeyandruleIdfield on the table) so the predicate stays data-driven.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/no-deprecated-props.ts` around lines 27 - 36, The predicate currently hardcodes newKey and ruleId (newKey = key === 'children' ? 'innerHTML' : 'key' and ruleId = key === 'children' ? 'deprecated-prop-children' : 'deprecated-prop-hid-vmid'), coupling behavior to the current DEPRECATED_PROPS shape; change the logic to read the rename target and rule id from DEPRECATED_PROPS[key] (e.g., expect each entry to provide newKey and ruleId), use those values when computing fix (keep the tag.keys.has(newKey) check) and when setting out.push.ruleId and message so future deprecations (like tagDuplicateStrategy) are data-driven rather than hardcoded.packages/eslint-plugin/src/utils/materialize.ts (1)
13-88: Optional: factor out the shared key-extraction loop.The two loops in
materializeTag(Lines 22–48) andmaterializeHeadInput(Lines 67–85) duplicate the sameProperty/computed/Identifier|Literalkey-extraction logic andkeys/propLocsbookkeeping. A smallforEachStaticKey(node, cb)helper would keep both materializers in lockstep when this is extended (e.g., to support string-keyed literals beyond the current set).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/utils/materialize.ts` around lines 13 - 88, The two functions materializeTag and materializeHeadInput duplicate the same Property/computed/Identifier|Literal key extraction and keys/propLocs bookkeeping; factor that out into a helper like forEachStaticKey(node, cb) that iterates node.properties, skips non-Property or computed entries, derives the string key from Identifier or string Literal, adds the key to a provided Set and records propLocs, and invokes cb(name, property) for further per-key processing; then replace the for-loops in materializeTag and materializeHeadInput to call forEachStaticKey and perform only the value-specific logic (type checks, getStringValue, props assignment) inside the callback so behavior and bookkeeping remain identical.packages/unhead/src/validate/predicates/types.ts (1)
50-62: Nit:PredicateFix.remove-propdoc describes adapter behavior, not the contract.The comment "Remove a property and its surrounding comma" (Line 59) is really an instruction to whoever implements the fix. Worth rewording to make it clear that comma handling is the adapter's responsibility (both
applyFixinpackages/cli/src/oxc/applyFix.tsand the ESLint adapter need to honor this consistently), so the contract isn't ambiguous when a third adapter is added.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/types.ts` around lines 50 - 62, The JSDoc for the PredicateFix variant `{ type: 'remove-prop', key: string }` currently prescribes adapter behavior ("Remove a property and its surrounding comma"); change it to a contract-style description that clearly separates responsibilities — e.g. state that `remove-prop` removes the property token(s) but NOT comma/whitespace handling, and that comma removal/normalization is the responsibility of adapters (ensure `applyFix` and the ESLint adapter honor this contract); update the comment on the `PredicateFix` union so callers and all adapters (including `applyFix` and the ESLint adapter) have an unambiguous specification.packages/cli/src/oxc/audit.ts (2)
188-218:runAuditreads and audits files serially.
for (const filePath of files) { await readFile(...); await auditFile(...) }is single-flighted. On Nuxt-sized projects this dominates wall time even though each file is independent. ConsiderPromise.allwith a small concurrency limit (e.g.,p-limitor a hand-rolled pool ofos.cpus().length).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/oxc/audit.ts` around lines 188 - 218, runAudit currently processes files serially inside the for..of loop (reading each file with readFile and calling auditFile), causing slow wall time for large projects; change it to run file processing in parallel with a bounded concurrency (e.g., use p-limit or a small worker pool sized to os.cpus().length) by mapping files to async tasks that call readFile and auditFile and submitting them through the limiter, then await Promise.all on the limited tasks and collect non-empty results into the results array; keep existing behavior for predicateNames and shouldFix, preserve filePath association in each result, and ensure error handling/ordering remains equivalent.
67-77: RepeatedlineColscans become O(file·diagnostics) on large files.Each diagnostic re-scans
sourcefrom index 0. With many diagnostics in a large generated file (Nuxt apps can produce dozens per file), this is wasteful. Consider precomputing a sorted array of newline offsets per file and using binary search, or computing line/column only after all diagnostics for the file are collected and sorted by offset so the scan is monotone.♻️ Sketch (per-file newline index, binary search)
function buildLineIndex(source: string): number[] { const nls: number[] = [] for (let i = 0; i < source.length; i++) if (source.charCodeAt(i) === 10) nls.push(i) return nls } function lineColFromIndex(nls: number[], offset: number) { // binary search greatest nl < offset let lo = 0, hi = nls.length while (lo < hi) { const mid = (lo + hi) >>> 1 if (nls[mid] < offset) lo = mid + 1 else hi = mid } const lastNL = lo === 0 ? -1 : nls[lo - 1] return { line: lo + 1, column: offset - lastNL } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/oxc/audit.ts` around lines 67 - 77, The current lineCol function scans from start for every diagnostic causing O(file·diagnostics); replace it by precomputing a per-file newline index and using binary search or a monotone scan when diagnostics are sorted. Add a buildLineIndex(source: string): number[] helper that collects newline offsets, then replace lineCol(offset) with a lineColFromIndex(nls, offset) that binary-searches nls to find the last newline and returns {line, column}; alternatively, collect all diagnostics for a file, sort by offset and compute line/column with a single linear pass over the source using the same newline index to keep complexity near O(file + diagnostics). Ensure you update callers that reference lineCol to use the new API (lineColFromIndex or the post-sorted monotone routine).packages/cli/src/oxc/walker.ts (1)
31-36: Optional: deduplicateunwrapTS/TS_WRAPPERS.The same set + helper exists in
packages/eslint-plugin/src/utils/visitor.ts(and the materializer relies on it). One copy inunhead/validate(or a tiny shared internal package) would keep the two adapters from drifting on which TS wrapper kinds are recognized.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/oxc/walker.ts` around lines 31 - 36, Duplicate logic: the TS_WRAPPERS set and unwrapTS function are defined here and also in another module; extract them into a single shared helper and import it where needed. Create a small internal util that exports TS_WRAPPERS and unwrapTS (preserve the function name and behavior), replace the local definitions in this file (and in the other module) with imports from that util, and update any callers (e.g., materializer/visitor code) to use the shared symbols so both adapters recognize the same TS wrapper kinds.
🤖 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/cli/src/oxc/audit.ts`:
- Around line 118-130: The current loop over pieces silently skips any piece
that throws in parseSync or has parsed.errors, which hides coverage gaps; update
the catch block around parseSync and the parsed.errors branch to record a
parse-error diagnostic (or populate a parseErrors field on the AuditFileResult
for that file) instead of just continuing — include the piece identifier (e.g.,
piece.lang or piece.code location) and any thrown error/message, and ensure the
rest of the audit still runs for other pieces; modify the code paths that
reference parseSync, the catch, and the parsed.errors check to attach this
informational diagnostic to the audit result returned for the file.
In `@packages/cli/src/oxc/sfc.ts`:
- Around line 1-47: Update the misleading "byte offset" documentation to state
"UTF-16 code-unit offset" wherever the ScriptBlock.offset is documented and
used: change the comment on the ScriptBlock interface (and its `offset`
description) and the inline docs in the modules that consume it to say "UTF-16
code-unit offset" instead of "byte offset" — specifically update the JSDoc for
the ScriptBlock type and any comments that describe "byte offset" in the
audit/applyFix consumers so they correctly reflect that extractScriptBlocks (and
RegExp.exec()/String.indexOf) produce UTF-16 code-unit offsets; keep all code
and behavior unchanged (symbols to locate: ScriptBlock, offset,
extractScriptBlocks, and the comments in the audit/applyFix consumers).
In `@packages/cli/src/oxc/walker.ts`:
- Around line 65-78: The collectImportedHelpers function currently records
helper names from any ImportDeclaration without checking the import source;
update it to only consider specifiers when the ImportDeclaration's source
(node.source.value) matches unhead packages (exactly 'unhead' or starts with
'unhead/' or '@unhead/' or '@unhead/'), so that only imports from unhead are
added to the Set; keep existing checks for specifier types (ImportSpecifier with
spec.imported.name in HELPER_NAMES and ImportDefaultSpecifier with
spec.local.name in HELPER_NAMES) but only execute them when the source filter
passes to avoid false positives in the helper detection used by
prefer-define-helpers.
- Around line 38-45: getCalleeName fails to detect identifiers when the callee
is wrapped in TS nodes (e.g. TSAsExpression); update getCalleeName to first
unwrap TypeScript wrappers by calling unwrapTS(node.callee) (or equivalent
helper) and then perform the existing checks (Identifier and MemberExpression
with Identifier property) against the unwrapped callee so patterns like (useHead
as typeof useHead)(...) are recognized.
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 63-67: PREDICATE_SEVERITY currently covers only three IDs causing
other predicate-emitted ruleIds to default to 'warn'; update the
PREDICATE_SEVERITY map to explicitly include the missing ruleIds from the
predicate emitters—add 'possible-typo' => 'info' (from noUnknownMeta) and add
'deprecated-prop-children' => 'error', 'deprecated-prop-hid-vmid' => 'error',
and 'deprecated-prop-body' => 'error' (from noDeprecatedProps) so
emitFromPredicates produces severities consistent with the CLI audit config.
In `@packages/unhead/src/validate/predicates/script-rules.ts`:
- Around line 19-31: The predicate scriptSrcWithContent currently flags scripts
that have a src and the keys innerHTML/textContent present even if their values
are empty; change the check to verify that tag.props.innerHTML or
tag.props.textContent is a non-empty string (e.g., typeof === 'string' and
.trim() !== '') before emitting the Diagnostic. Update logic in
scriptSrcWithContent to inspect tag.props.innerHTML and tag.props.textContent
values (not just tag.keys.has) and only return the diag when one of those values
contains actual non-whitespace content while tag.props.src is a string.
In `@packages/unhead/src/validate/predicates/twitter-handle-missing-at.ts`:
- Around line 16-21: The autofix builds newSource naively as `'@${content}'`,
which produces invalid JS when content contains quotes, backslashes, or
newlines; update the Diagnostic.fix.newSource construction in the
twitter-handle-missing-at diagnostic (ruleId 'twitter-handle-missing-at',
variable diag) to serialize the fix string safely by using a JS source literal
generator such as JSON.stringify('@' + content) (or an equivalent safe-escaping
routine) instead of interpolating content directly so the emitted
replace-prop-value fix is always valid JS.
In `@packages/unhead/src/validate/predicates/viewport-user-scalable.ts`:
- Around line 3-4: The regexes USER_SCALABLE_NO_RE and MAX_SCALE_RE miss numeric
forms; update USER_SCALABLE_NO_RE to also match "0" (e.g.,
/user-scalable\s*=\s*(?:no|0)(?:\s|,|$)/i) and update MAX_SCALE_RE to accept one
with any number of trailing zeros after the decimal (e.g.,
/maximum-scale\s*=\s*1(?:\.0+)?(?:\s|,|$)/i) so values like user-scalable=0 and
maximum-scale=1.00 are detected; modify those constant definitions
(USER_SCALABLE_NO_RE and MAX_SCALE_RE) accordingly.
---
Outside diff comments:
In `@packages/cli/README.md`:
- Around line 97-100: The README's claim that outputs are "byte-for-byte
identical" is too strong; update the paragraph referencing `audit`, `migrate`,
`unhead/validate`, and `@unhead/eslint-plugin` to say the CLI and ESLint plugin
"produce the same diagnostics" or "report identical rule IDs and messages"
instead, and note that CLI output passes through `formatStylish` while editor/CI
uses the consumer's formatter so the rendered streams may differ; replace the
phrase "byte-for-byte identical" with one of the suggested softer phrasings and
add a brief note about formatter differences.
---
Nitpick comments:
In `@packages/cli/src/commands/migrate.ts`:
- Around line 45-62: The dry-run path in migrate (guarded by the dryRun
variable) returns before checking summarise(results) so process.exitCode is
never set when there are errors; move or duplicate the errorCount check so both
branches consult summarise(results) and set process.exitCode = 1 when errorCount
> 0. Specifically, after computing const { errorCount } = summarise(results) (or
before the dryRun return), ensure the dry-run branch still prints the fixable
message but then sets process.exitCode based on errorCount (or hoist the
summarise call above the dryRun check) so migrate's behavior is consistent for
both dryRun and real runs.
In `@packages/cli/src/oxc/applyFix.ts`:
- Around line 28-84: The switch over fix.type in applyFix currently lacks a
default, so add an exhaustive default arm that calls an assertion helper (e.g.,
assertNever) or throws a descriptive Error to ensure the function always returns
a boolean and to surface new PredicateFix variants at compile time; update or
add an assertNever(value: never) helper if needed and reference the switch on
fix.type inside applyFix to implement the default branch that throws/asserts
with the unexpected fix value.
In `@packages/cli/src/oxc/audit.ts`:
- Around line 188-218: runAudit currently processes files serially inside the
for..of loop (reading each file with readFile and calling auditFile), causing
slow wall time for large projects; change it to run file processing in parallel
with a bounded concurrency (e.g., use p-limit or a small worker pool sized to
os.cpus().length) by mapping files to async tasks that call readFile and
auditFile and submitting them through the limiter, then await Promise.all on the
limited tasks and collect non-empty results into the results array; keep
existing behavior for predicateNames and shouldFix, preserve filePath
association in each result, and ensure error handling/ordering remains
equivalent.
- Around line 67-77: The current lineCol function scans from start for every
diagnostic causing O(file·diagnostics); replace it by precomputing a per-file
newline index and using binary search or a monotone scan when diagnostics are
sorted. Add a buildLineIndex(source: string): number[] helper that collects
newline offsets, then replace lineCol(offset) with a lineColFromIndex(nls,
offset) that binary-searches nls to find the last newline and returns {line,
column}; alternatively, collect all diagnostics for a file, sort by offset and
compute line/column with a single linear pass over the source using the same
newline index to keep complexity near O(file + diagnostics). Ensure you update
callers that reference lineCol to use the new API (lineColFromIndex or the
post-sorted monotone routine).
In `@packages/cli/src/oxc/materialize.ts`:
- Around line 6-7: Replace the loose "type Node = any" with precise type-only
imports from oxc-parser: add an import type line (e.g., import type { Node,
Property, SpreadElement, Identifier, Literal } from 'oxc-parser') and use those
types in the code (or use Node as the AST root type and narrow to Property in
getKeyName) so TypeScript will catch invalid accesses (e.g., accessing .value on
SpreadElement) and rely on declared offset names; update any function signatures
that use the old Node alias (such as getKeyName and any other helpers in this
file) to use the imported oxc-parser types or narrowed unions instead.
In `@packages/cli/src/oxc/walker.ts`:
- Around line 31-36: Duplicate logic: the TS_WRAPPERS set and unwrapTS function
are defined here and also in another module; extract them into a single shared
helper and import it where needed. Create a small internal util that exports
TS_WRAPPERS and unwrapTS (preserve the function name and behavior), replace the
local definitions in this file (and in the other module) with imports from that
util, and update any callers (e.g., materializer/visitor code) to use the shared
symbols so both adapters recognize the same TS wrapper kinds.
In `@packages/cli/test/audit.test.ts`:
- Around line 90-144: Hoist the repeated runAudit invocation into a shared setup
so the file is parsed only once: call runAudit({ patterns: ['input.ts'], mode:
'migrate', cwd: tmp }) in a beforeEach (or one encompassing it block) and store
its return in a shared variable (e.g. results/out) that each test uses; update
each it() (the tests asserting rewrites, defer removal, crossorigin, twitter
prefixing, and hid→key) to read from that shared results[0].output instead of
calling runAudit again, leaving existing expect assertions unchanged.
- Around line 86-88: The empty afterEach in the migrate tests is leaking
mkdtemp-created temp dirs; update afterEach to perform a best-effort removal of
the temp directory created in beforeEach (the same variable used by the runAudit
(migrate) tests), e.g. await fs.promises.rm(tempDir, { recursive: true, force:
true }) or fs.rmSync with guards if needed, and swallow any errors so cleanup
never fails the test; reference and use the same temp directory variable created
in beforeEach and keep afterEach async to await the removal.
In `@packages/eslint-plugin/src/rules/title-rules.ts`:
- Around line 5-16: The rule metadata for noHtmlInTitle is missing declarations
for possible fixes/suggestions, which will cause ESLint to throw if predicate
later returns a fix or suggestions; update the meta object on noHtmlInTitle to
include fixable: 'code' if predicate/createHeadInputPredicateRule(predicate)
might emit fixes and hasSuggestions: true if it might emit suggestions (or
explicitly document that predicate will never emit fixes/suggestions), so add
the appropriate fixable/hasSuggestions flags in the meta for noHtmlInTitle to
match any future output from predicate.
In `@packages/eslint-plugin/src/utils/applyDiagnostic.ts`:
- Around line 86-103: The reportDiagnostic function currently maps
diag.suggestions to objects even when buildFixer returns undefined (falling back
to a no-op), so update reportDiagnostic to call buildFixer(obj, s.fix,
ctx.sourceCode) for each suggestion and only include suggestions where that
fixer is defined: compute a suggestions array by mapping each s to { desc:
s.message, fix: fixer } and filter out entries with undefined fixer before
passing suggest to ctx.report; reference reportDiagnostic, diag.suggestions and
buildFixer to locate the change.
In `@packages/eslint-plugin/src/utils/materialize.ts`:
- Around line 13-88: The two functions materializeTag and materializeHeadInput
duplicate the same Property/computed/Identifier|Literal key extraction and
keys/propLocs bookkeeping; factor that out into a helper like
forEachStaticKey(node, cb) that iterates node.properties, skips non-Property or
computed entries, derives the string key from Identifier or string Literal, adds
the key to a provided Set and records propLocs, and invokes cb(name, property)
for further per-key processing; then replace the for-loops in materializeTag and
materializeHeadInput to call forEachStaticKey and perform only the
value-specific logic (type checks, getStringValue, props assignment) inside the
callback so behavior and bookkeeping remain identical.
In `@packages/eslint-plugin/test/rules.test.ts`:
- Around line 22-154: Tests currently assert error messages with regexes which
are brittle; instead import the stable rule identifier constants from the
corresponding predicate modules (e.g. the predicate modules under
packages/unhead/src/validate/predicates that define twitterHandleMissingAt,
robotsConflict, deferOnModuleScript, etc.) and replace the regex-based errors
assertions with errors: [{ ruleId: THE_RULE_ID }] (or assert on a stable
substring constant exported by the predicate) so tests assert on the exported
ruleId symbol rather than the full message text.
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 207-211: The hot path currently iterates
Object.values(tagPredicates) for every resolved tag in the tags:afterResolve
loop (see tagInputFromRuntime, tagPredicates, emitFromPredicates), which runs
all predicates for every tag; change this to group predicates by tagType (e.g.,
build a predicatesByTagType Map at predicate registration time keyed by
predicate.tagType or the tag types they handle) and in the loop only fetch
predicatesByTagType.get(tag.tagType) (falling back to any global predicates) and
run emitFromPredicates for that filtered list; update the predicate
registration/initialization code where tagPredicates is populated so the grouped
map is kept in sync.
In `@packages/unhead/src/validate/predicates/empty-meta-content.ts`:
- Around line 10-12: The identifier fallback for empty-meta content uses only
tag.props.name and tag.props.property, causing poor messages for meta tags using
http-equiv; update the const key expression in empty-meta-content.ts (the const
named key) to also check for a string-valued tag.props['http-equiv'] (e.g., ||
(typeof tag.props['http-equiv'] === 'string' && tag.props['http-equiv']) )
before falling back to 'meta' so diagnostics show the http-equiv value when
present.
In `@packages/unhead/src/validate/predicates/no-deprecated-props.ts`:
- Around line 27-36: The predicate currently hardcodes newKey and ruleId (newKey
= key === 'children' ? 'innerHTML' : 'key' and ruleId = key === 'children' ?
'deprecated-prop-children' : 'deprecated-prop-hid-vmid'), coupling behavior to
the current DEPRECATED_PROPS shape; change the logic to read the rename target
and rule id from DEPRECATED_PROPS[key] (e.g., expect each entry to provide
newKey and ruleId), use those values when computing fix (keep the
tag.keys.has(newKey) check) and when setting out.push.ruleId and message so
future deprecations (like tagDuplicateStrategy) are data-driven rather than
hardcoded.
In `@packages/unhead/src/validate/predicates/non-absolute-canonical.ts`:
- Around line 3-5: The isAbsolute function currently only checks for 'http://'
and 'https://' in lowercase, so URLs with uppercase schemes like 'HTTP://' will
be misclassified; update the isAbsolute(url: string) implementation (function
name: isAbsolute) to perform a case-insensitive scheme check—either by
lowercasing the url before startsWith checks or by using a case-insensitive
regex (e.g., matching /^https?:\/\//i) so both 'HTTP://' and 'http://' are
treated as absolute.
In `@packages/unhead/src/validate/predicates/prefer-define-helpers.ts`:
- Around line 23-34: The suggestion should offer an autofix that also inserts or
updates the import when the helper is not already imported: when building the
Diagnostic in prefer-define-helpers, detect ctx.importedHelpers and, for the
suggestions branch (where imported is false), provide a second-kind of fix (or
extend the existing fix object) that not only sets type: 'wrap-tag' and
wrapWith: helper but also includes metadata to add or update an import for the
helper (e.g. import { <helper> } from 'unhead') so the adapter can apply both
AST edits; modify the construction of diag.suggestions (and the exported fix
shape) to carry this import-insertion variant while leaving the imported=true
path unchanged.
In `@packages/unhead/src/validate/predicates/preload-rules.ts`:
- Around line 17-32: The predicate preloadFontCrossorigin does strict
case-sensitive checks on tag.props.rel and tag.props.as and uses a fix.insert
that starts with ", " which couples it to the consumer; update the predicate to
normalize the attribute values (e.g., coerce tag.props.rel and tag.props.as to
lower-case strings before comparison) so rel="Preload" or as="FONT" match, and
change the fix.insert to not include a leading separator (e.g., use
"crossorigin: 'anonymous'") so applyFix can decide how to insert separators;
keep references to preloadFontCrossorigin and the fix.insert field when making
these edits.
In `@packages/unhead/src/validate/predicates/robots-conflict.ts`:
- Around line 11-25: The predicate currently checks the raw directives array
(variable directives) for conflicting pairs but ignores shorthand tokens; update
the logic to expand 'none' → ['noindex','nofollow'] and 'all' →
['index','follow'] into the directives set before conflict checks. Concretely,
compute a normalized Set from directives (expanding any 'none'/'all' entries),
then test for the conflicting pairs ('index' vs 'noindex', 'follow' vs
'nofollow') and push the same Diagnostics (ruleId 'robots-conflict') into out
when found. Ensure you keep the existing lowercase/trim normalization and return
out as before.
In `@packages/unhead/src/validate/predicates/types.ts`:
- Around line 50-62: The JSDoc for the PredicateFix variant `{ type:
'remove-prop', key: string }` currently prescribes adapter behavior ("Remove a
property and its surrounding comma"); change it to a contract-style description
that clearly separates responsibilities — e.g. state that `remove-prop` removes
the property token(s) but NOT comma/whitespace handling, and that comma
removal/normalization is the responsibility of adapters (ensure `applyFix` and
the ESLint adapter honor this contract); update the comment on the
`PredicateFix` union so callers and all adapters (including `applyFix` and the
ESLint adapter) have an unambiguous specification.
🪄 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: 19cc33ca-942c-4b02-b66e-f9125d39f30b
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (55)
packages/cli/README.mdpackages/cli/package.jsonpackages/cli/src/commands/audit.tspackages/cli/src/commands/migrate.tspackages/cli/src/format.tspackages/cli/src/lint.tspackages/cli/src/oxc/applyFix.tspackages/cli/src/oxc/audit.tspackages/cli/src/oxc/materialize.tspackages/cli/src/oxc/sfc.tspackages/cli/src/oxc/walker.tspackages/cli/test/audit.test.tspackages/cli/test/fixtures/bad.vuepackages/cli/test/fixtures/clean.tspackages/cli/test/fixtures/migrate-input.tspackages/cli/test/lint.test.tspackages/eslint-plugin/README.mdpackages/eslint-plugin/src/rules/canonical-rules.tspackages/eslint-plugin/src/rules/empty-meta-content.tspackages/eslint-plugin/src/rules/no-deprecated-props.tspackages/eslint-plugin/src/rules/no-unknown-meta.tspackages/eslint-plugin/src/rules/numeric-tag-priority.tspackages/eslint-plugin/src/rules/prefer-define-helpers.tspackages/eslint-plugin/src/rules/preload-rules.tspackages/eslint-plugin/src/rules/robots-conflict.tspackages/eslint-plugin/src/rules/script-rules.tspackages/eslint-plugin/src/rules/title-rules.tspackages/eslint-plugin/src/rules/twitter-handle-missing-at.tspackages/eslint-plugin/src/rules/viewport-user-scalable.tspackages/eslint-plugin/src/utils/applyDiagnostic.tspackages/eslint-plugin/src/utils/createPredicateRule.tspackages/eslint-plugin/src/utils/materialize.tspackages/eslint-plugin/src/utils/visitor.tspackages/eslint-plugin/test/no-deprecated-props.test.tspackages/eslint-plugin/test/numeric-tag-priority.test.tspackages/eslint-plugin/test/rules.test.tspackages/unhead/src/plugins/validate.tspackages/unhead/src/validate/index.tspackages/unhead/src/validate/predicates/empty-meta-content.tspackages/unhead/src/validate/predicates/index.tspackages/unhead/src/validate/predicates/no-deprecated-props.tspackages/unhead/src/validate/predicates/no-html-in-title.tspackages/unhead/src/validate/predicates/no-unknown-meta.tspackages/unhead/src/validate/predicates/non-absolute-canonical.tspackages/unhead/src/validate/predicates/numeric-tag-priority.tspackages/unhead/src/validate/predicates/prefer-define-helpers.tspackages/unhead/src/validate/predicates/preload-rules.tspackages/unhead/src/validate/predicates/robots-conflict.tspackages/unhead/src/validate/predicates/runtime.tspackages/unhead/src/validate/predicates/script-rules.tspackages/unhead/src/validate/predicates/twitter-handle-missing-at.tspackages/unhead/src/validate/predicates/types.tspackages/unhead/src/validate/predicates/viewport-user-scalable.tspackages/unhead/src/validate/rules.tspackages/unhead/test/unit/predicates.test.ts
💤 Files with no reviewable changes (2)
- packages/cli/test/lint.test.ts
- packages/cli/src/lint.ts
- ValidatePlugin: PREDICATE_SEVERITY now lists every predicate-emitted
ruleId explicitly so a new predicate's severity is a deliberate
decision, not a 'warn' fall-through. Adds 'possible-typo' and the
'deprecated-prop-*' triplet that previously fell through implicitly.
- twitter-handle-missing-at: autofix used a raw template-literal
interpolation, producing invalid JS for handles containing quotes,
backslashes, or newlines (e.g. "O'Reilly" → 'O'Reilly'). Switch to
JSON.stringify so any input round-trips as a valid JS string literal.
Tests updated to reflect the resulting double-quoted output.
- prefer-define-helpers (both adapters): collectImportedHelpers
ignored the import source, so `import { defineLink } from
'some-other-lib'` would convince the predicate that the unhead helper
was already imported and emit an autofix wrapping with a foreign
symbol. Filter on `node.source.value` against an allowlist of unhead
/ @unhead/* sources.
- walker: getCalleeName now peels TS wrappers on the callee so
`(useHead as typeof useHead)({...})` is recognised as useHead.
- viewport-user-scalable regexes: also flag user-scalable=0 / =false
(equivalent to =no per WHATWG) and maximum-scale=1.x (anything below
2 still defeats WCAG 1.4.4).
- script-src-with-content: ignore empty-string innerHTML/textContent so
`{ src: '/x.js', innerHTML: '' }` doesn't produce a misleading
diagnostic about content the browser would silently drop.
- sfc / applyFix: doc comments said "byte offset" but oxc-parser spans
are UTF-16 character offsets (matching JS string indexing and
MagicString edits). Correct the comments; behaviour is unchanged.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
packages/unhead/src/validate/predicates/script-rules.ts (1)
19-36: Empty-string innerHTML/textContent is now correctly ignored.The earlier false-positive concern (flagging
innerHTML: ''/textContent: '') is addressed by gating on!== ''alongsidekeys.has(...).One minor edge worth being aware of: when a key is present but its value is not statically resolvable, it lands in
tag.keysbut not intag.props, sotag.props.innerHTMLisundefinedandundefined !== ''istrue— the diagnostic still fires. That's probably the intended behavior (the user did writeinnerHTML), but if you'd rather only flag known non-empty content, tighten the check to a string-typed value:♻️ Optional tightening
- const hasInner = (tag.keys.has('innerHTML') && tag.props.innerHTML !== '') - || (tag.keys.has('textContent') && tag.props.textContent !== '') + const inner = tag.props.innerHTML + const text = tag.props.textContent + const hasInner + = (tag.keys.has('innerHTML') && (typeof inner !== 'string' || inner !== '')) + || (tag.keys.has('textContent') && (typeof text !== 'string' || text !== ''))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/predicates/script-rules.ts` around lines 19 - 36, The predicate scriptSrcWithContent currently treats presence of innerHTML/textContent keys with non-string or undefined values as "has content" because it only checks !== ''; tighten the condition so it only counts known non-empty string content: replace the hasInner checks to require typeof tag.props.innerHTML === 'string' && tag.props.innerHTML !== '' (and likewise for textContent) while still gating on tag.keys.has('innerHTML') / tag.keys.has('textContent'); this ensures only statically-resolved non-empty string values trigger the diagnostic in scriptSrcWithContent.packages/cli/src/oxc/sfc.ts (1)
36-44: Optional:lang="typescript"/lang="javascript"fall through to'js'.Only the short forms (
ts/tsx/jsx) are recognized; the long forms (which Vue/Svelte tooling do accept) silently downgrade to plain JS, which means oxc will parse TS files without TS support and emit confusing diagnostics. If you want to be defensive, normalize the long forms too.♻️ Proposed change
- const declared = langMatch?.[1]?.toLowerCase() - const lang: ScriptBlock['lang'] = declared === 'ts' - ? 'ts' - : declared === 'tsx' - ? 'tsx' - : declared === 'jsx' - ? 'jsx' - : 'js' + const declared = langMatch?.[1]?.toLowerCase() + const lang: ScriptBlock['lang'] + = declared === 'ts' || declared === 'typescript' + ? 'ts' + : declared === 'tsx' + ? 'tsx' + : declared === 'jsx' + ? 'jsx' + : 'js'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/oxc/sfc.ts` around lines 36 - 44, The lang detection currently only maps short forms and silently treats long forms as 'js'; update the normalization around LANG_ATTR_RE / langMatch / declared (the variable assigned with declared = langMatch?.[1]?.toLowerCase()) so it also recognizes the long names "typescript" -> 'ts' and "javascript" -> 'js' (and "typescriptreact"/"javascriptreact" or similar if needed), then assign ScriptBlock['lang'] accordingly instead of defaulting to 'js' for those cases; keep the toLowerCase normalization and ensure declared aliases map to the same canonical short tokens used elsewhere.packages/unhead/src/plugins/validate.ts (1)
247-252: Title'sno-html-in-titlepath bypassesPREDICATE_SEVERITY.The whole point of the new
PREDICATE_SEVERITYtable (and the docstring above it) is that severities for predicate-emitted ruleIds live in one place. This branch hardcodes'warn'instead, so a future change toPREDICATE_SEVERITY['html-in-title']would silently fail to apply on the runtime title path while still applying everywhere else. ReuseemitFromPredicates(or look up via the table) so the source of truth stays single.♻️ Proposed refactor
if (tag.tag === 'title') { const titleInput = titleInputFromRuntime(tag) - if (titleInput) { - for (const diag of headInputPredicates['no-html-in-title'](titleInput)) - report(diag.ruleId as ValidationRuleId, diag.message, 'warn', tag) - } + if (titleInput) + emitFromPredicates(headInputPredicates['no-html-in-title'](titleInput), tag) const text = tag.textContent || ''🤖 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 247 - 252, The title branch currently calls report(...) with a hardcoded 'warn' severity; change it to use the centralized predicate-severity logic so it doesn't bypass PREDICATE_SEVERITY — obtain the emitted entries via emitFromPredicates (or look up PREDICATE_SEVERITY using the rule id) when handling the runtime title path in the title handling block (where tag.tag === 'title' and titleInputFromRuntime(tag) is used), and for each diagnostic from headInputPredicates['no-html-in-title'] call report(...) with the severity returned by emitFromPredicates (or the table lookup) rather than the literal 'warn', keeping the same ruleId/message/tag parameters.
🤖 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/cli/src/oxc/sfc.ts`:
- Around line 16-46: The opener regex (SCRIPT_OPEN_RE with /i) matches
case-insensitive tags but the closer constant (SCRIPT_CLOSE = '</script>') is
lowercase-only, so extractScriptBlocks can skip blocks like
<Script>...</Script>; fix by making the closing search case-insensitive instead
of changing the opener: when computing closeIdx in extractScriptBlocks, search
for the closing tag case-insensitively (e.g., find the index of '</script>' in a
lowercased copy of source starting at openEnd or use a case-insensitive regex to
locate the closing tag) and convert that index back to the original source
offset before slicing and pushing the ScriptBlock; keep references to
SCRIPT_OPEN_RE, SCRIPT_CLOSE, openEnd, closeIdx, and the out.push call intact.
- Around line 16-49: The SCRIPT_OPEN_RE currently uses /<script\b([^>]*)>/gi
which breaks when attribute values contain '>' (e.g., Vue generic types); update
the regex used by SCRIPT_OPEN_RE in extractScriptBlocks so it matches the whole
opening tag while ignoring '>' characters inside single- or double-quoted
attribute values (i.e., consume attributes but skip over quoted runs), keep
using SCRIPT_CLOSE as the end marker and preserve existing logic that reads
attrs = m[1] and matches LANG_ATTR_RE to determine lang; ensure
SCRIPT_OPEN_RE.lastIndex is still reset and advanced to closeIdx +
SCRIPT_CLOSE.length as before.
In `@packages/eslint-plugin/src/utils/createPredicateRule.ts`:
- Around line 86-99: The CallExpression handler in createHeadInputPredicateRule
reads node.arguments[0] directly and fails on TS wrappers; mirror
createTagVisitor by passing the raw argument through unwrapTS before the
ObjectExpression check and before calling materializeHeadInput. Update the
handler to obtain the raw arg from node.arguments[0], call unwrapTS(rawArg)
(importing/using the same unwrapTS as createTagVisitor), then verify
unwrappedArg.type === 'ObjectExpression' and pass the unwrapped node into
materializeHeadInput(name) so predicate(...) and reportDiagnostic(...) work with
TSAsExpression/TSSatisfiesExpression/TSTypeAssertion-wrapped calls (referencing
createHeadInputPredicateRule, unwrapTS, materializeHeadInput, createTagVisitor,
getCalleeName, HEAD_INPUT_CALLEES).
---
Nitpick comments:
In `@packages/cli/src/oxc/sfc.ts`:
- Around line 36-44: The lang detection currently only maps short forms and
silently treats long forms as 'js'; update the normalization around LANG_ATTR_RE
/ langMatch / declared (the variable assigned with declared =
langMatch?.[1]?.toLowerCase()) so it also recognizes the long names "typescript"
-> 'ts' and "javascript" -> 'js' (and "typescriptreact"/"javascriptreact" or
similar if needed), then assign ScriptBlock['lang'] accordingly instead of
defaulting to 'js' for those cases; keep the toLowerCase normalization and
ensure declared aliases map to the same canonical short tokens used elsewhere.
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 247-252: The title branch currently calls report(...) with a
hardcoded 'warn' severity; change it to use the centralized predicate-severity
logic so it doesn't bypass PREDICATE_SEVERITY — obtain the emitted entries via
emitFromPredicates (or look up PREDICATE_SEVERITY using the rule id) when
handling the runtime title path in the title handling block (where tag.tag ===
'title' and titleInputFromRuntime(tag) is used), and for each diagnostic from
headInputPredicates['no-html-in-title'] call report(...) with the severity
returned by emitFromPredicates (or the table lookup) rather than the literal
'warn', keeping the same ruleId/message/tag parameters.
In `@packages/unhead/src/validate/predicates/script-rules.ts`:
- Around line 19-36: The predicate scriptSrcWithContent currently treats
presence of innerHTML/textContent keys with non-string or undefined values as
"has content" because it only checks !== ''; tighten the condition so it only
counts known non-empty string content: replace the hasInner checks to require
typeof tag.props.innerHTML === 'string' && tag.props.innerHTML !== '' (and
likewise for textContent) while still gating on tag.keys.has('innerHTML') /
tag.keys.has('textContent'); this ensures only statically-resolved non-empty
string values trigger the diagnostic in scriptSrcWithContent.
🪄 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: 94d7a36f-1c76-4de9-81e7-d096e8fe2842
📒 Files selected for processing (11)
packages/cli/src/oxc/applyFix.tspackages/cli/src/oxc/sfc.tspackages/cli/src/oxc/walker.tspackages/cli/test/audit.test.tspackages/eslint-plugin/src/utils/createPredicateRule.tspackages/eslint-plugin/test/rules.test.tspackages/unhead/src/plugins/validate.tspackages/unhead/src/validate/predicates/script-rules.tspackages/unhead/src/validate/predicates/twitter-handle-missing-at.tspackages/unhead/src/validate/predicates/viewport-user-scalable.tspackages/unhead/test/unit/predicates.test.ts
✅ Files skipped from review due to trivial changes (1)
- packages/unhead/test/unit/predicates.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/cli/test/audit.test.ts
- packages/cli/src/oxc/walker.ts
- packages/cli/src/oxc/applyFix.ts
- createHeadInputPredicateRule: peel TS wrappers on the first arg the
same way createTagVisitor already does. `useHead({...} as Foo)` and
`useHead({...} satisfies Foo)` head-input rules (e.g. no-html-in-title)
were silently skipping these calls.
- sfc.ts: SCRIPT_OPEN_RE now allows `>` inside quoted attribute values,
so Vue 3.3+ `<script setup generic="T extends Foo<Bar>">` parses
instead of getting truncated at the first `>` inside the generic.
Closing tag scan switched to a case-insensitive regex so `<Script>…
</Script>` is matched consistently with the opener's `i` flag.
- New sfc.test.ts covers: basic ts script, byte-offset preservation,
generic-attribute opener, mixed-case `</Script>`, multiple blocks,
template-only files.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/eslint-plugin/src/utils/createPredicateRule.ts (1)
9-24:HELPER_SOURCESis largely subsumed by the@unhead/prefix check.
isHelperSourcereturns true for any@unhead/*source via the prefix branch, so the only entry inHELPER_SOURCESthat adds value is'unhead'. The other five entries are redundant. Consider simplifying:♻️ Optional simplification
-const HELPER_SOURCES = new Set([ - 'unhead', - '@unhead/vue', - '@unhead/react', - '@unhead/svelte', - '@unhead/solid-js', - '@unhead/angular', -]) - function isHelperSource(source: string): boolean { - if (HELPER_SOURCES.has(source)) - return true - // Be permissive within the `@unhead` namespace so framework subpaths (e.g. - // `@unhead/vue/server`) still count. - return source.startsWith('@unhead/') + return source === 'unhead' || source.startsWith('@unhead/') }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/utils/createPredicateRule.ts` around lines 9 - 24, The HELPER_SOURCES set contains entries that are already matched by the startsWith('@unhead/') check in isHelperSource, so simplify by reducing HELPER_SOURCES to only the non-@unhead entry (keep 'unhead') and let the prefix check cover all `@unhead/`* variants; update the HELPER_SOURCES declaration used by isHelperSource accordingly (referencing HELPER_SOURCES and isHelperSource) so the function logic remains correct and less redundant.
🤖 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/cli/src/oxc/sfc.ts`:
- Line 20: The regex constant SCRIPT_OPEN_RE currently uses an alternation
("[^"]*"|'[^']*'|[^>]) that makes the third branch overlap with the quoted
branches and triggers ReDoS; update SCRIPT_OPEN_RE so the fallback character
class excludes quote characters (e.g. use a class that excludes double-quote,
single-quote and >) so the alternatives become non-overlapping and backtracking
is eliminated, preserving the same matches and flags on the existing
SCRIPT_OPEN_RE.
In `@packages/eslint-plugin/src/utils/createPredicateRule.ts`:
- Around line 34-39: collectImportedHelpers currently records canonical helper
names (e.g., defineLink/defineScript) from spec.imported.name instead of the
actual local binding, so fixes applied later in applyDiagnostic.ts (uses
fix.wrapWith(...)) produce invalid code for aliased imports; update
collectImportedHelpers to store the local identifier (spec.local.name) for
ImportSpecifier cases (or store both canonical and local names and pass the
local binding into the fixer) so generated wraps use the actual local binding;
you can also remove or mark the ImportDefaultSpecifier branch (it’s dead for
these named exports) if desired.
---
Nitpick comments:
In `@packages/eslint-plugin/src/utils/createPredicateRule.ts`:
- Around line 9-24: The HELPER_SOURCES set contains entries that are already
matched by the startsWith('@unhead/') check in isHelperSource, so simplify by
reducing HELPER_SOURCES to only the non-@unhead entry (keep 'unhead') and let
the prefix check cover all `@unhead/`* variants; update the HELPER_SOURCES
declaration used by isHelperSource accordingly (referencing HELPER_SOURCES and
isHelperSource) so the function logic remains correct and less redundant.
🪄 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: 6443b38d-0757-4e69-8913-cc0599400ccc
📒 Files selected for processing (3)
packages/cli/src/oxc/sfc.tspackages/cli/test/sfc.test.tspackages/eslint-plugin/src/utils/createPredicateRule.ts
CodeQL flagged the new `(?:"[^"]*"|'[^']*'|[^>])*` regex for catastrophic backtracking — the three branches all accept `"` and `'`, so the engine explores exponentially many paths on inputs like `"""""...`. Restrict the unquoted branch to `[^>"']` so each character deterministically routes to exactly one alternative. Behaviourally identical for well-formed `<script>` openers; only malformed HTML with stray unmatched quotes is now rejected (it was ambiguous before).
`collectImportedHelpers` previously stored canonical names
(`defineLink`, `defineScript`), but the `prefer-define-helpers` autofix
references whatever the file's local binding is. For
`import { defineLink as dl } from 'unhead'`, the fix was emitting
`defineLink(...)` — an undefined symbol that breaks the file.
Change `PredicateContext.importedHelpers` from `Set<canonical>` to
`Map<canonical, local>`. Both the eslint-plugin and CLI walkers now
record the local binding via `spec.local.name`. The
`prefer-define-helpers` predicate looks up the local binding and emits
a `wrap-tag` fix using it.
Drop the dead `ImportDefaultSpecifier` branch — `defineLink` and
`defineScript` are named exports, never defaults.
New test covers the alias path: `Map([['defineLink', 'dl']])` produces
a fix with `wrapWith: 'dl'`.
Previously a piece that failed to parse (or had parser errors) was silently `continue`d, so a real-world repo with one broken file would report zero issues for that file — easy to mistake for "clean". Emit a `parse-error` warning on the first line of the affected piece when parseSync throws or returns errors. The diagnostic carries the parser's own message so users can locate the real issue. Subsequent pieces in the same file still run. This is not type-checking; it only flags inputs the parser couldn't read at all.
Two debugging-oriented improvements after running the audit on a real
Nuxt project and getting silent zero-issue output:
Coverage checks
- Track each recognised head/seo call site (`useHead`, `useSeoMeta`,
`defineLink`, `defineNuxtConfig`, …) per file via a new `onCall`
visitor hook. Surface them on `AuditFileResult.headCalls`.
- Format prints a `Scanned N files with head usage and no issues:`
block listing every clean file with a green ✓ and the call
composition (e.g. `useHead ×2, useSeoMeta`). Files with diagnostics
are unchanged.
- Without this users had no way to tell whether a clean run meant
"no problems" or "the CLI never saw your code".
nuxt.config app.head
- `defineNuxtConfig({ app: { head: {...} } })` carries a head input
that's functionally identical to a `useHead` call but was previously
invisible to the audit. Walker now recognises `defineNuxtConfig`,
finds the `app.head` ObjectExpression, and fans it out through the
existing head-input + tag-array predicates.
- New test covers an `app.head` config with `<b>` in the title and a
twitter handle missing the `@` — both fire as expected.
Pages under any pages/ directory should set page-specific head metadata for SEO; if they don't, you have a missed-opportunity bug. The check runs after the per-file pass: 1. extractCallGraph(program): for each named function defined in a file, the set of identifier names called inside its body, plus all identifier names called anywhere in the file. Cheap (one extra walk per function body). 2. After all files are scanned, fixpoint over the merged graphs to compute a "head-providing" identifier set: seeded with the unhead composables (useHead, useSeoMeta, useServerHead, …) and grown by adding any function whose body calls a name already in the set. Catches `usePageMeta() → useDefaultMeta() → useHead()` chains. 3. For each file matching `**/pages/**/*.vue` with no direct head call AND no call to any head-providing composable: emit an info-level `page-missing-head` diagnostic. Severity 'info' end-to-end: - FileDiagnostic.severity union extended to include 'info'. - summarise() returns infoCount alongside error/warning counts. - format.ts renders info in cyan, summary line uses ℹ glyph when only infos are present, and includes "N info" in the count breakdown. - audit command exit code unchanged: only errors → exit 1. Tests: page without head usage in pages/ flagged; page calling a two-hop project composable that ends in useHead is not flagged. On nuxtseo.com (8.4k files, 0.35s) the rule surfaces 34 dashboard pages missing head metadata while the 35 head-using files keep their green ✓ coverage line.
After running the audit on a dozen of my own projects the output felt boring — pages mostly clean, the rest was page-missing-head noise. Title consistency is the first concrete "Project insights" pass, since the data is already in materializeHeadInput and the recommendation reaches for a real unhead feature most users don't know about. audit pipeline - AuditFileResult gains `titles: TitleObservation[]` and `titleTemplates: TitleObservation[]` (resolvable string literals with file location). Falls out of the existing materializeHeadInput call — only literal strings are captured, dynamic expressions are ignored. analyser (oxc/title-consistency.ts) - Mixed separators across pages → "3 different separators in use" pointing at templateParams.separator + a single titleTemplate. - Common trailing suffix that ≥50% of titles share (and ≥3 pages) → "set titleTemplate + templateParams.siteName, drop the suffix from every page". Suffix candidates with %templateParams are skipped so pages already doing it right don't trip the check. - Redundant suffix variant: when titleTemplate is already set in the project AND pages still hardcode the suffix → warn that titles will render the suffix twice. - Mix of literal titles and titles using template params → suggest picking one approach. format.ts - New "Title consistency" section above the per-file diagnostics list. Each finding shows headline, ⇒ educational hint, then up to 5 sample occurrences with truncated values. sfc.ts (drive-by) - LANG_ATTR_RE now accepts unquoted (`lang=ts`) and single-quoted attribute values, not just double-quoted. Found while running the CLI on forgd: ~25 components used `<script setup lang=ts>` (no quotes — valid Vue) and were being detected as `lang: 'js'`, then the JS parser bailed on `import type` etc. The CLI dutifully surfaced these as parse-error warnings — accurate but pointing at the wrong root cause.
Previously the title-consistency analyser only saw `title:` literals
inside calls to known unhead composables (`useHead`, `useSeoMeta`,
`defineNuxtConfig`). Project-local wrappers like
`useToolSeo({ title: '…' })` were invisible even though the fixpoint
already classified them as head-providing — so on nuxtseo.com the
analyser reported "1 of 16 titles use templates" when the real number
is "9 of 24".
walker
- New `extractCandidateTitles(program)` walks every CallExpression and
captures literal/template-string `title:` and `titleTemplate:` props
along with the callee name. Template literals with `${…}` substitutions
are coerced to placeholder text and tagged `dynamic: true`.
audit
- Per-file pass collects candidate titles into pendingCandidates.
- After the fixpoint runs (already needed for page-missing-head), fold
any candidate whose callee landed in headProviding into the result's
titles/titleTemplates, deduplicated against the direct unhead-callee
captures.
This lights up consistency analysis on every project that wraps unhead
in a domain-specific helper.
When a useHead call is meta-only — title + description + an array of
{ name|property|http-equiv, content } entries — it is functionally
equivalent to a useSeoMeta call but with much weaker TypeScript
inference. The flat useSeoMeta shape gives autocomplete on every
known SEO/OG/Twitter key.
new check (oxc/prefer-use-seo-meta.ts)
- analyzeUseHeadForUseSeoMeta walks the input ObjectExpression and
bails on anything not losslessly translatable: link/script/style/
noscript/htmlAttrs/bodyAttrs blocks, computed keys, spread, shorthand
props, meta entries with extra attrs (media, key, id, hid,
tagPriority, …), duplicate flat-keys, non-literal name/property
values. Otherwise returns the new prop list with original source
text preserved for each value.
- metaTagToFlatKey converts `og:image:secure_url` → `ogImageSecureUrl`,
`theme-color` → `themeColor`, `X-UA-Compatible` → `xUaCompatible` by
splitting on `:`/`-`/`_` and camelCasing.
- renderUseSeoMetaArg materialises the new ObjectExpression source,
detecting indent from the original literal so the rewrite preserves
the file's style.
walker
- onHeadInput visitor now receives the surrounding CallExpression as a
third argument so the rewrite can locate the `useHead` identifier
and replace it.
audit pipeline
- For every onHeadInput where callee === 'useHead', run the analyser.
If eligible: emit an info diagnostic with line/column at the call
itself, and in migrate mode apply two MagicString edits — replace
the callee identifier with `useSeoMeta` and replace the input arg
with the rendered new shape.
- prefer-use-seo-meta added to RECOMMENDED_SEVERITY as 'info'. Also
recorded explicit severities for parse-error and page-missing-head
so the table is complete.
tests cover: positive case with mixed name/property/http-equiv,
bail on link/script blocks, bail on extra attrs, migrate output
shape, dynamic content preservation, no-op on title-only useHead.
Surfaces real candidates on nuxtseo.com and mdream.dev.
The original info classification framed this as a stylistic upgrade, but the underlying motivation is correctness. useHead's meta array types name/property/content as plain strings, so a typo like \`name: 'descriptipon'\` compiles cleanly and ships a broken meta tag that crawlers silently ignore. useSeoMeta's keys are typed against MetaFlat, so the same mistake fails at write time. Reframing the message and bumping severity so the diagnostic actually gets fixed instead of sitting in the info tier indefinitely.
Update both packages/cli/README.md and the docs CLI page with everything added in this branch: - coverage check output (✓ scanned files with head usage) - parse-error diagnostic - nuxt.config app.head support (audited as a head input) - Project insights section: page-missing-head, prefer-use-seo-meta, title consistency analysis - updated rule table with the four new CLI-only rule classes - migrate command description includes the useSeoMeta conversion Also drops the lingering "uses @unhead/eslint-plugin's recommended config" line from the docs page — the CLI no longer depends on ESLint after the predicate-extraction refactor.
Linked issue
N/A — follow-up to #755.
Type of change
Description
Two problems addressed in one pass:
unhead auditwas unusable on TS / Vue / Svelte projects.packages/cli/src/lint.tsbuilt an ESLint config without a parser, so every.ts/.vuefile failed withParsing error: Unexpected token <. On a real Nuxt project (~hundreds of files) this produced 600+ parse errors and zero useful diagnostics.Per-rule logic was triplicated across
unhead/plugins/ValidatePlugin,@unhead/eslint-pluginrule modules, and@unhead/cli— drift was inevitable.Fix:
TagPredicate/HeadInputPredicateinunhead/validate/predicates/. Pure functions:TagInput → Diagnostic[]with optional source-agnosticPredicateFixshapes (rename-prop,replace-prop-value,insert-after-prop,remove-prop,wrap-tag).@unhead/eslint-pluginrule modules become ~15-line adapters: ESTree →TagInputvia newmaterialize.ts, predicate dispatch via newcreatePredicateRule.ts, fix translation via newapplyDiagnostic.ts. Bundle drops 21 KB → 16 KB. All 42 existing tests pass (assertions migrated frommessageIdtomessageregex since predicates emit raw strings).@unhead/cliswaps the ESLint backend foroxc-parser+oxc-walker+magic-string. Vue / Svelte SFCs handled via a small regex-based<script>extractor that preserves byte offsets for accurate file-level line/column reporting. Dropseslintand@unhead/eslint-plugindeps. Newoxc/subdir mirrors the eslint-plugin's helper layout. Newformat.tsproduces stylish-ish output without bringing ESLint along.ValidatePluginrefactored to dispatch through the same predicates for the per-tag rules it owned inline. NewtagInputFromRuntime/titleInputFromRuntimeadapters inunhead/validate/predicates/runtime.tshandle the runtime ↔ source impedance mismatch (name lowercasing,contentnull-coercion,innerHTML/textContentkeys, top-leveltagPrioritylift). Cross-tag and content-size checks (canonical-og-url-mismatch,too-many-preloads,meta-beyond-1mb,render-blocking-script, …) stay inline since they need cross-tag context. Plugin shrinks 566 → 513 lines, all 121 existing tests pass without modification.import unhead from '@unhead/eslint-plugin'example in the eslint-plugin README (configsis a named export, not on the default).Verification
Tested against a real Nuxt project (~hundreds of files, mixed
.ts/.vue/.tsx):.tsand.vuesources.migrateround-trip rewriteschildren/hid/body/ redundantdefer/ unprefixed Twitter handles in a single MagicString pass.42 new predicate unit tests + 13 new CLI audit/migrate tests (incl. a Vue SFC fixture). Full test suite green: 799 unhead, 42 eslint-plugin, 13 CLI.
What stayed inline in
ValidatePlugincanonical-og-url-mismatch,og-image-missing-dimensions,og-missing-{title,description},missing-{title,description},too-many-preloads,too-many-preconnects,too-many-fetchpriority-high,redundant-dns-prefetch,preload-async-defer-conflict,prefetch-preload-conflict,duplicate-resource-hint,charset-not-early,preload-not-modulepreload,preconnect-missing-crossorigin,meta-beyond-1mb,non-absolute-og-url,render-blocking-script,inline-script-size,inline-style-size,unresolved-template-param,empty-title,missing-template-params-plugin,missing-alias-sorting-plugin,deprecated-option-mode— all need cross-tag, render-order, or content-size context that doesn't fit a single-tag predicate signature.Summary by CodeRabbit