HookStack
Back to catalogue
ValidationPostToolUse· Write|EditPostToolUseAfter tool execution · non-blocking· non-blocking

Single H1 heading guard

One <h1> per page — the clean outline crawlers trust

Ensures a single <h1> per file in components (src/**/*.tsx). Two top-level titles confuse crawlers and screen readers about what the page is really about, diluting the keyword signal you worked to earn. Non-blocking. Deep h2-h6 nesting is a render-time concern, left to a full audit.

What does the Single H1 heading guard hook do?

Single H1 heading guard is a Claude Code PostToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. One <h1> per page — the clean outline crawlers trust.

As a PostToolUse hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Catch a duplicate <h1> after a copy-paste
  • Keep one clear topic signal per page for SEO

Tags

#seo#accessibility#headings#html#semantics

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/seo-heading-hierarchy-guard.mjs"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/seo-heading-hierarchy-guard.mjs

#!/usr/bin/env node
// Garde la hiérarchie de titres après écriture d'un composant (PostToolUse Write|Edit).
// Cible : src/**/*.tsx. Règle à très faible faux-positif : un seul <h1> par fichier.
// Plusieurs <h1> dans une même vue cassent l'outline SEO/accessibilité (un document =
// un titre principal). La hiérarchie h2→h6 fine, elle, dépend du rendu cross-composant
// → couverte au runtime par le skill seo-geo-aeo, pas en statique.
// Non bloquant : signale le compte de <h1>.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';

// <h1 …> ou <h1>, mais pas un composant <H1…>.
const H1_RE = /<h1(?=[\s/>])/g;

export function run(input, { readFile = readFileSync } = {}) {
  const filePath = input.tool_input?.file_path ?? '';
  if (!/\/src\/.*\.tsx$/.test(filePath)) return null;

  let content;
  try {
    content = readFile(filePath, 'utf8');
  } catch {
    return null;
  }

  const count = (content.match(H1_RE) ?? []).length;
  if (count <= 1) return null;

  return {
    message:
      `[seo-heading] ${filePath} has ${count} <h1> tags.\n` +
      `  → Keep a single <h1> per page (the main title). Demote the others to <h2>/<h3> ` +
      `to preserve the document outline.\n`,
  };
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'));
  const result = run(input);
  if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks