Background
The blog at https://blog.wheels.dev is a static Astro site that renders posts via web/sites/blog/src/layouts/PostLayout.astro. The layout auto-renders an <h1 class=\"post__title\">{data.title}</h1> from the frontmatter title field on every post.
But many existing posts ALSO start their body with a # Title markdown heading, which Astro renders as a second <h1>. Result: those posts have two top-level headings on the same page. This is bad for SEO (multiple H1s violates the single-page-topic convention), inconsistent across the corpus, and confusing in document outlines.
PR #2435 cleaned up the new Blog 09 (wheels-deploy-kamal-port). This issue is for everything else.
Spot-check of the existing 151 posts
| Post |
<h1> count |
Notes |
why-we-rebuilt-our-ci-pipeline |
2 |
Body H1 duplicates frontmatter title |
1-0-we-are-production-ready |
1 |
Clean — already follows the layout-title-only convention |
wheels-vs-code-extension-supercharge-your-wheels-development |
11 |
Uses H1 to mark sections — needs to be downgraded to H2/H3 |
wheels-deploy-kamal-port |
1 |
Fixed by #2435 |
So the corpus has at least three patterns: clean (1 H1), duplicate-title (2 H1s), and structural-misuse (many H1s used as section headings).
Scope — two tracks
A. Backfill — clean up existing posts
For every post in web/content/blog/posts/*.md:
- Read frontmatter
title and the body's leading <h1> heading (if any).
- If the body starts with
# <text> and that text matches the frontmatter title (modulo punctuation/code-fence styling), strip the body H1 + the immediate byline / --- divider that often follows it.
- If the body has multiple
<h1> headings used as section markers (the wheels-vs-code-extension pattern), downgrade them to <h2> and shift any nested <h2> → <h3>, etc., to preserve the heading tree.
Worth doing in a single PR with one commit per post (or a small batch script + a single squash commit) so the diff is reviewable. Probably 100+ files touched.
B. Enforcement — prevent recurrence
Three options, in increasing heaviness:
-
Astro content-collection schema validation — add a custom validator in web/sites/blog/src/content.config.ts (or wherever the blog collection is defined) that parses the body markdown and rejects posts whose first non-frontmatter content is a top-level # heading. Fails the build with a clear error pointing at the offending file. Cheapest to implement, runs at every build.
-
Remark plugin — wire remark-strip-leading-h1 (or a one-line custom plugin) into the Astro markdown pipeline. Silently strips a leading H1 from every post body during render. Forgiving (no build failures), but loses the audit-trail and could mask a legitimate H1 someone meant to keep.
-
Blog admin tool editor warning (in wheels-dev/blog) — add a save-time check in the Posts controller that warns if the body starts with # <text> matching the title. Doesn't help for hand-edited posts authored outside the tool. Lightest UX touch but narrowest coverage.
Recommendation: option 1 (schema validation) — fails the build, can't be bypassed, single source of truth. Option 3 is a nice-to-have on top.
Related
Acceptance criteria
Background
The blog at https://blog.wheels.dev is a static Astro site that renders posts via
web/sites/blog/src/layouts/PostLayout.astro. The layout auto-renders an<h1 class=\"post__title\">{data.title}</h1>from the frontmattertitlefield on every post.But many existing posts ALSO start their body with a
# Titlemarkdown heading, which Astro renders as a second<h1>. Result: those posts have two top-level headings on the same page. This is bad for SEO (multiple H1s violates the single-page-topic convention), inconsistent across the corpus, and confusing in document outlines.PR #2435 cleaned up the new Blog 09 (
wheels-deploy-kamal-port). This issue is for everything else.Spot-check of the existing 151 posts
<h1>countwhy-we-rebuilt-our-ci-pipeline1-0-we-are-production-readywheels-vs-code-extension-supercharge-your-wheels-developmentwheels-deploy-kamal-portSo the corpus has at least three patterns: clean (1 H1), duplicate-title (2 H1s), and structural-misuse (many H1s used as section headings).
Scope — two tracks
A. Backfill — clean up existing posts
For every post in
web/content/blog/posts/*.md:titleand the body's leading<h1>heading (if any).# <text>and that text matches the frontmatter title (modulo punctuation/code-fence styling), strip the body H1 + the immediate byline /---divider that often follows it.<h1>headings used as section markers (the wheels-vs-code-extension pattern), downgrade them to<h2>and shift any nested<h2>→<h3>, etc., to preserve the heading tree.Worth doing in a single PR with one commit per post (or a small batch script + a single squash commit) so the diff is reviewable. Probably 100+ files touched.
B. Enforcement — prevent recurrence
Three options, in increasing heaviness:
Astro content-collection schema validation — add a custom validator in
web/sites/blog/src/content.config.ts(or wherever the blog collection is defined) that parses the body markdown and rejects posts whose first non-frontmatter content is a top-level#heading. Fails the build with a clear error pointing at the offending file. Cheapest to implement, runs at every build.Remark plugin — wire
remark-strip-leading-h1(or a one-line custom plugin) into the Astro markdown pipeline. Silently strips a leading H1 from every post body during render. Forgiving (no build failures), but loses the audit-trail and could mask a legitimate H1 someone meant to keep.Blog admin tool editor warning (in
wheels-dev/blog) — add a save-time check in the Posts controller that warns if the body starts with# <text>matching the title. Doesn't help for hand-edited posts authored outside the tool. Lightest UX touch but narrowest coverage.Recommendation: option 1 (schema validation) — fails the build, can't be bypassed, single source of truth. Option 3 is a nice-to-have on top.
Related
_April 9, 2026 — Peter Amiri, Wheels Core Team_byline +---divider pattern in older posts is also redundant (PostLayout already renders author + date), so the cleanup script should drop those alongside the H1 when present.Acceptance criteria
web/content/blog/posts/renders with exactly one<h1>— verified by a test or build-time check.blog.pngshould not need updating, since the index uses excerpts not body content — but verify after).