Skip to content

Blog: backfill duplicate-H1 cleanup across existing posts + add build-time enforcement #2436

@bpamiri

Description

@bpamiri

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:

  1. Read frontmatter title and the body's leading <h1> heading (if any).
  2. 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.
  3. 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:

  1. 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.

  2. 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.

  3. 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

  • Every post in web/content/blog/posts/ renders with exactly one <h1> — verified by a test or build-time check.
  • An enforcement mechanism (option 1, 2, or 3 — your call) is in place to prevent regression.
  • The blog index page renders unchanged (the visual baseline blog.png should not need updating, since the index uses excerpts not body content — but verify after).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions