Repository navigation
feat(core): SEO022/SEO023 — title & description length - #58
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds static title/description text capture, visible-length measurement, SEO022/SEO023 rule evaluation, and matching docs, tests, and release notes. ChangesStatic SEO title and description length checks
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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 |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
packages/vite/test/parse-html.test.ts (1)
103-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a decoded-description case here too.
This block proves entity decoding for
<title>, but the design makes the same guarantee formeta[name="description"]content. A case likecontent="Café & Bar"would lock that contract down and catch regressions on the description path as well.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/vite/test/parse-html.test.ts` around lines 103 - 106, The `parseHtmlHead` test for `meta[name="description"]` only covers plain text today; add a decoded-description assertion alongside `captures description content text` to verify entity decoding works for description content too. Update the test in `parse-html.test.ts` using the existing `html(...)` and `tags.find(...)` pattern, with a case such as `Café & Bar`, and assert the resulting `desc.text` is the decoded string so the `parseHtmlHead` contract matches the `<title>` decoding behavior.packages/cli/test/resolve-transitive.test.ts (1)
52-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover resolved description text too.
These assertions lock
title.textthrough transitive resolution, but SEO023 depends on the same path formeta[name="description"]. Adding one resolved description case here would catch regressions where parsing keepstextbut resolution drops it.Also applies to: 77-77
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli/test/resolve-transitive.test.ts` at line 52, The transitive resolution test in resolve-transitive.test.ts only covers resolved title text, but SEO023 uses the same path for meta description resolution as well. Update the relevant test cases around the existing assertions in the resolve-transitive suite to include one resolved description expectation (via the same resolution flow used for title) so regressions in description handling are caught; use the existing resolve/transitive assertion pattern and the r.tags expectations to locate where to add it.packages/core/test/seo-length-rules.test.ts (1)
21-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd one
visibleLength-specific case here.These tests only use ASCII literals, so they would still pass if the rules stopped using trimmed/collapsed Unicode code-point counts and fell back to raw
.length.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/test/seo-length-rules.test.ts` around lines 21 - 53, Add a visibleLength-specific test case to the SEO022/SEO023 test suite so it fails if the rules regress to raw .length behavior. Update the relevant checks in seo-length-rules.test.ts by adding a non-ASCII/whitespace-collapsed input for seo022TitleLength or seo023DescriptionLength and assert the expected pass/fail outcome from visibleLength, using the existing helpers like seo022TitleLength.check, seo023DescriptionLength.check, fails, ctx, title, and desc to locate the right spot.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/src/content/docs/ja/rules/seo022.md`:
- Around line 18-21: The example title in the SEO022 docs does not meet the
stated 30–60 character range, so update the sample inside the svelte:head block
to a title that actually falls within that range while still being concise and
descriptive. Use the existing SEO022 example section to replace the current
title text with a compliant one, keeping the surrounding structure unchanged.
In `@docs/src/content/docs/ja/rules/seo023.md`:
- Around line 18-21: The example meta description in the SEO023 doc is too short
to satisfy the rule being documented. Update the `<svelte:head>` example so the
`meta[name="description"]` `content` text is actually within the 70〜160
character range, using a compliant Japanese sentence that still reads like a
realistic page description.
In `@docs/superpowers/plans/2026-06-26-seo-title-description-length.md`:
- Around line 639-725: The docs snippet contains stray empty fenced blocks after
the example sections, which triggers markdownlint MD040 and renders empty code
blocks. Remove the extra bare fences in the SEO022/SEO023 English and Japanese
sections, and keep only the intended code fences inside the rule docs content so
the markdown stays properly balanced.
- Around line 205-223: The plan snippet for the meta tag parser currently treats
every meta[name="description"] as static by assigning descText directly from
attrText, which regresses quoted dynamic descriptions. Update the snippet around
the meta handling in the parser example so descText is only added when content
is actually static, matching the parse.ts contract that checks content
resolution before setting text; keep the conditional logic aligned with the
meta/link tag flow and the descText field.
In `@packages/core/src/rules/seo/seo022-023.ts`:
- Around line 6-8: The SEO022/023 result mapping is overwriting the matched tag
metadata with fixed values, which breaks inherited-layout cases. Update the rule
logic in the SEO rule module so the returned `presence`, `value`, and `location`
are taken from the matched `HeadTag` (for both pass and fail paths) instead of
always using the hardcoded `PENALIZED`/`PASS` objects and `head.file`. Use the
existing `HeadTag` fields and the result-building code in the SEO022/023 rule to
preserve the real source tag metadata.
In `@packages/core/test/seo-length-rules.test.ts`:
- Around line 7-19: Update the seo-length-rules tests so they exercise dynamic
tag handling, not just missing text: adjust the shared helpers like headWith,
title, and desc in seo-length-rules.test.ts to allow overriding the tag value
and create cases where value is dynamic. Then add/modify assertions in the
seo022TitleLength.check and related checks to verify that dynamic
title/description tags emit no detection, while static tags with missing text
still fail as before.
---
Nitpick comments:
In `@packages/cli/test/resolve-transitive.test.ts`:
- Line 52: The transitive resolution test in resolve-transitive.test.ts only
covers resolved title text, but SEO023 uses the same path for meta description
resolution as well. Update the relevant test cases around the existing
assertions in the resolve-transitive suite to include one resolved description
expectation (via the same resolution flow used for title) so regressions in
description handling are caught; use the existing resolve/transitive assertion
pattern and the r.tags expectations to locate where to add it.
In `@packages/core/test/seo-length-rules.test.ts`:
- Around line 21-53: Add a visibleLength-specific test case to the SEO022/SEO023
test suite so it fails if the rules regress to raw .length behavior. Update the
relevant checks in seo-length-rules.test.ts by adding a
non-ASCII/whitespace-collapsed input for seo022TitleLength or
seo023DescriptionLength and assert the expected pass/fail outcome from
visibleLength, using the existing helpers like seo022TitleLength.check,
seo023DescriptionLength.check, fails, ctx, title, and desc to locate the right
spot.
In `@packages/vite/test/parse-html.test.ts`:
- Around line 103-106: The `parseHtmlHead` test for `meta[name="description"]`
only covers plain text today; add a decoded-description assertion alongside
`captures description content text` to verify entity decoding works for
description content too. Update the test in `parse-html.test.ts` using the
existing `html(...)` and `tags.find(...)` pattern, with a case such as
`Café & Bar`, and assert the resulting `desc.text` is the decoded
string so the `parseHtmlHead` contract matches the `<title>` decoding behavior.
🪄 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: 4b5e4bd6-1024-42b4-a32a-02148ed71d3b
📒 Files selected for processing (23)
.changeset/seo-title-description-length.mddocs/src/content/docs/ja/rules/seo022.mddocs/src/content/docs/ja/rules/seo023.mddocs/src/content/docs/rules/seo022.mddocs/src/content/docs/rules/seo023.mddocs/superpowers/plans/2026-06-26-seo-title-description-length.mddocs/superpowers/specs/2026-06-26-seo-title-description-length-design.mdpackages/cli/src/providers/source/parse.tspackages/cli/test/parse-file.test.tspackages/cli/test/parse-length.test.tspackages/cli/test/resolve-transitive.test.tspackages/cli/test/resolve.test.tspackages/core/src/head.tspackages/core/src/index.tspackages/core/src/rules/index.tspackages/core/src/rules/seo/seo022-023.tspackages/core/src/rules/seo/text-metrics.tspackages/core/test/seo-length-rules.test.tspackages/core/test/text-metrics.test.tspackages/vite/src/providers/rendered/parse-html.tspackages/vite/test/collect.test.tspackages/vite/test/dev-handle.test.tspackages/vite/test/parse-html.test.ts
…mples; plan doc Address CodeRabbit review on PR #58: - SEO022/023 now report 'location' as tag.file ?? head.file, so a title/description inherited from a +layout points at the layout file rather than the route (matches the link-rule precedent). detection stays the penalize signal ({none,absent} on fail, {own,static} on pass) — using the tag's own presence/value would make a too-long static title non-penalized, defeating the rule (codebase-wide convention). - ja SEO022/SEO023 'how to fix' examples now actually fall within their documented ranges (33 chars / 78 chars) instead of violating the rule they illustrate. - length-rule tests gain a 'value' knob and assert dynamic title/description emit nothing, exercising the dynamic→silent contract directly. - plan doc: align the CLI capture snippet with the shipped static-content guard and drop the stray empty fenced blocks (markdownlint MD040). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds SEO Phase B length validation for <title> and <meta name="description"> by capturing static, SERP-visible text into the head model and introducing two new core rules (SEO022/SEO023) that evaluate trimmed/whitespace-collapsed Unicode code-point length.
Changes:
- Add
HeadTag.textcapture for title/description in both static (CLI AST) and rendered (vite HTML) collection paths. - Introduce
visibleLength()plus new core rules SEO022 (title 30–60) and SEO023 (description 70–160), and register/re-export them. - Add tests, docs pages (en/ja), and a changeset for the new rules.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/vite/test/parse-html.test.ts | Updates expectations to include captured text and adds entity-decoding coverage for rendered title/description capture. |
| packages/vite/test/dev-handle.test.ts | Adjusts rendered fixture HTML title/description to be in-range under the new rules. |
| packages/vite/test/collect.test.ts | Updates rendered collection expectation to include captured title text. |
| packages/vite/src/providers/rendered/parse-html.ts | Captures decoded rendered title/description text into HeadTag.text. |
| packages/core/test/text-metrics.test.ts | Adds unit tests for visibleLength() behavior (trim/collapse/code points). |
| packages/core/test/seo-length-rules.test.ts | Adds rule-level tests for SEO022/SEO023 (short/long/pass/dynamic/absent). |
| packages/core/src/rules/seo/text-metrics.ts | Adds visibleLength() helper (dependency-free). |
| packages/core/src/rules/seo/seo022-023.ts | Implements the SEO022/SEO023 length rules via a small factory. |
| packages/core/src/rules/index.ts | Registers new rules in allRules and re-exports them. |
| packages/core/src/index.ts | Re-exports SEO022/SEO023 from the package entry. |
| packages/core/src/head.ts | Extends HeadTag with optional text?: string for captured static visible text. |
| packages/cli/test/resolve.test.ts | Updates expectations to include captured title text from static resolution. |
| packages/cli/test/resolve-transitive.test.ts | Updates transitive resolution expectations to include captured title text. |
| packages/cli/test/parse-length.test.ts | Adds tests for static capture of title/description text and “dynamic stays undefined” behavior. |
| packages/cli/test/parse-file.test.ts | Updates parsed head tag expectations to include captured title text. |
| packages/cli/src/providers/source/parse.ts | Captures static title/description literal text into HeadTag.text during AST parsing. |
| docs/superpowers/specs/2026-06-26-seo-title-description-length-design.md | Adds approved design spec describing capture, measurement, and rule behavior. |
| docs/superpowers/plans/2026-06-26-seo-title-description-length.md | Adds implementation plan detailing steps/tests/docs/changeset. |
| docs/src/content/docs/rules/seo023.md | Adds English rule reference page for SEO023. |
| docs/src/content/docs/rules/seo022.md | Adds English rule reference page for SEO022. |
| docs/src/content/docs/ja/rules/seo023.md | Adds Japanese rule reference page for SEO023. |
| docs/src/content/docs/ja/rules/seo022.md | Adds Japanese rule reference page for SEO022. |
| .changeset/seo-title-description-length.md | Declares minor bumps and summarizes new SEO022/SEO023 behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… providers Address Copilot review on PR #58: confirm static (CLI) and rendered (vite) modes both store the SERP-visible, entity-decoded text, so visibleLength agrees across modes. Svelte's Text `.data` and attribute Text `.data` are already decoded (`&`->`&`, `é`->`é`), as are node-html-parser's `.text` and getAttribute — no code change needed; these regression tests pin the invariant so a future switch to raw text can't silently break length counts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e counting Address PR review findings: - Capture static title/description text from svelte-meta-tags & svelte-seo component props (new attrTextOf helper) so SEO022/SEO023 fire in CLI/static mode too, not only in rendered mode. A titleTemplate suppresses title measurement (the rendered title is longer). - Measure length by grapheme cluster via Intl.Segmenter so ZWJ/flag/skin-tone emoji count as one character; code-point fallback retained. - Extract shared PENALIZED/PASS detection constants into detection.ts, used by both seo016-021 and seo022-023. - Document static-vs-rendered measurement and grapheme counting (en+ja); update changeset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/cli/src/providers/source/parse.ts (1)
172-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDelegate
attrTextOftotextFromNodesto avoid divergent copies.
attrTextOf(attr)is functionally identical totextFromNodes(attr?.value)(sameExpressionTagguard, same Text-node join, same trim-emptiness check). Since both paths now feed the samevisibleLength/SEO022-023 measurement, keeping two literal copies invites drift if one is later tweaked (e.g., normalization changes).♻️ Proposed consolidation
/** Literal static text of a single attribute node (e.g. a component prop), or undefined if dynamic/absent. */ export function attrTextOf(attr: Node): string | undefined { - const v = attr?.value; - if (!Array.isArray(v) || v.some((n: Node) => n?.type === 'ExpressionTag')) return undefined; - const text = v - .filter((n: Node) => n?.type === 'Text') - .map((n: Node) => String(n.data ?? '')) - .join(''); - return text.trim().length > 0 ? text : undefined; + return textFromNodes(attr?.value); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli/src/providers/source/parse.ts` around lines 172 - 181, `attrTextOf` duplicates the same literal-text extraction logic already implemented in `textFromNodes`, so consolidate it to avoid drift. Update `attrTextOf(attr)` to delegate directly to `textFromNodes(attr?.value)` and keep the existing `ExpressionTag`/Text-node/trim behavior centralized in `textFromNodes`. This keeps the `visibleLength` and SEO022-023 measurement paths aligned and ensures future normalization changes only need to be made in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/cli/src/providers/source/parse.ts`:
- Around line 172-181: `attrTextOf` duplicates the same literal-text extraction
logic already implemented in `textFromNodes`, so consolidate it to avoid drift.
Update `attrTextOf(attr)` to delegate directly to `textFromNodes(attr?.value)`
and keep the existing `ExpressionTag`/Text-node/trim behavior centralized in
`textFromNodes`. This keeps the `visibleLength` and SEO022-023 measurement paths
aligned and ensures future normalization changes only need to be made in one
place.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f7eafa70-53a2-4f15-af77-0b7e99932143
📒 Files selected for processing (19)
.changeset/seo-title-description-length.mddocs/src/content/docs/ja/rules/seo022.mddocs/src/content/docs/ja/rules/seo023.mddocs/src/content/docs/rules/seo022.mddocs/src/content/docs/rules/seo023.mddocs/superpowers/plans/2026-06-26-seo-title-description-length.mdpackages/cli/src/providers/source/adapters/svelte-meta-tags.tspackages/cli/src/providers/source/adapters/svelte-seo.tspackages/cli/src/providers/source/parse.tspackages/cli/test/adapters-registry.test.tspackages/cli/test/adapters-smt.test.tspackages/cli/test/parse-length.test.tspackages/core/src/rules/seo/detection.tspackages/core/src/rules/seo/seo016-021.tspackages/core/src/rules/seo/seo022-023.tspackages/core/src/rules/seo/text-metrics.tspackages/core/test/seo-length-rules.test.tspackages/core/test/text-metrics.test.tspackages/vite/test/parse-html.test.ts
✅ Files skipped from review due to trivial changes (6)
- .changeset/seo-title-description-length.md
- docs/src/content/docs/rules/seo023.md
- docs/src/content/docs/ja/rules/seo023.md
- docs/src/content/docs/ja/rules/seo022.md
- docs/src/content/docs/rules/seo022.md
- docs/superpowers/plans/2026-06-26-seo-title-description-length.md
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/vite/test/parse-html.test.ts
- packages/core/test/text-metrics.test.ts
- packages/core/test/seo-length-rules.test.ts
- packages/core/src/rules/seo/seo022-023.ts
SEO Phase B (after Phase C JSON-LD, #55/#57): validate the length of the document
<title>and<meta name="description">. Both areinfo, route-scoped, and run only on static text — presence/emptiness stays owned by SEO001/SEO002.New rules
infoinfoBoth flag too short and too long. A dynamic title/description emits nothing (no false positives).
How it works
HeadTag.textfield holds the literal visible title/description text, set only when static (mirrorsHeadTag.jsonld).{expr}and quoted"{expr}"both leavetextundefined.title.text/getAttribute('content')—<title>is RCDATA andcontentis an attribute, so entities are decoded (the SERP-visible text). This is deliberately the opposite of JSON-LD'srawText(raw-text<script>).visibleLength()— trim, collapse internal whitespace, count Unicode code points (astral emoji = 1). No deps.lengthRulefactory;len < min/len > max, so exactly 30/60/70/160 are in-range. Follows the seo016-021 pattern.Surfaces & docs
@svelte-vitals/core+svelte-vitals+@svelte-vitals/vite+@svelte-vitals/mcpminor (MCPanalyze/explain_rulesurface them automatically viaallRules).Validation
pnpm -r test(385: core 175 / vite 60 / cli 141 / mcp 9),pnpm -r typecheck,pnpm lint,pnpm --filter docs build(71 pages incl. the 4 new) — all green.Two pre-existing "clean page" test fixtures gained in-range title/description (they were genuinely out-of-range under the new rules); deep-equality assertions on title tags gained the now-captured
text— additive, no assertions loosened.Process
Built spec → plan → subagent-driven (6 tasks, each spec + quality reviewed) + a whole-branch review on Opus (verdict: ready to merge). One review-loop fix landed: quoted dynamic
content="{desc}"had capturedtext:''(would false-positive SEO023) — now correctly left undefined.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
<title>anddescriptionvalues are now captured as visible text for more accurate length validation.Bug Fixes
Documentation