Theme wiring for headings
For developers. How a theme opts in to RankGrep's H1 and opening-paragraph repairs, and how to stop it fighting the connector's title and description.
On this page
- Why themes need wiring at all
- 1. Diagnose first
- 2. The two fields that break because of the theme
- Meta description: the theme's tag wins
- SEO title: only a conflict if the theme filters late
- 3. The wiring recipe
- Step 1: functions.php
- Step 2: the templates
- Step 3: what not to wire
- 4. Symptom to cause
- 5. Mistakes that cost real time
- 6. Verify
- 7. Packaging on Windows
RankGrep writes four fields: SEO title, meta description, H1, and opening paragraph. Two of them work on any theme. Two only work if the theme asks for them. And a theme can break the first two by printing its own tags. This page is the checklist, written for whoever maintains the theme.
Why themes need wiring at all
| Field | How the connector delivers it | Needs the theme? |
|---|---|---|
| SEO title | Document-title filter at priority 99 | Only if the theme filters at 100 or higher |
| Meta description | Prints <meta name="description"> on wp_head | Only if the theme prints its own |
| H1 | rankgrep_h1() called from the template | Always |
| Opening paragraph | rankgrep_intro() called from the template | Always |
The H1 and the opening paragraph are printed by the template. There is no WordPress hook to override them, and on a theme-driven site they are not in the page content either. The template has to ask for the value.
1. Diagnose first
Does the connector see the theme?
curl -s https://example.com/wp-json/rankgrep/v1/status
- No
"active":trueat all: the plugin is not installed. Install it first. "supports_headings":false: the theme has not opted in. Section 3, step 1."supports_headings":truebut headings still do not apply: the templates are not calling the helpers. Section 3, step 2.
Does the theme print its own SEO tags?
grep -rn 'name="description"' --include=*.php .
grep -rn 'pre_get_document_title\|document_title_parts\|wp_title' --include=*.php .
Any hit is a conflict that will make repairs verify against the theme's tag and roll themselves back. Section 2.
2. The two fields that break because of the theme
Meta description: the theme's tag wins
The connector prints its description on wp_head at priority 10. Themes often print their own at priority 5, which puts it first in the document, and first is what crawlers read.
Symptom. A repair applies, then rolls itself back: "WordPress accepted the new meta description and the RankGrep Connector printed it, but the live page still shows … the theme prints its own description tag ahead of the connector's." The write genuinely succeeded; the page has two description tags, and the theme's comes first. (The older, vaguer wording, "a theme or SEO plugin is likely overriding it", means the new value is not on the page at all, which points to an SEO plugin or a theme that prints the description from somewhere other than RankGrep's field.)
Fix. When RankGrep has a description for the page, the theme must print nothing. Ask the connector:
if ( ! function_exists( 'rankgrep_renders_description' ) || ! rankgrep_renders_description() ) {
// print the theme's own <meta name="description"> here
}
That check is the whole decision. Do not add a second one that reads the field yourself: get_post_meta( get_queried_object_id(), 'rankgrep_meta_description', true ) returns nothing on a category or tag archive, because the queried object there is a term and RankGrep stores an archive's fields as term meta. A theme that stepped aside only when its own read found a value kept printing its tag on every archive, and every description fix on a category page rolled itself back. If the theme needs the value, for an og:description say, ask the connector, which reads the right store for the request:
$description = function_exists( 'rankgrep_meta' ) ? rankgrep_meta( 'rankgrep_meta_description' ) : '';
rankgrep_meta() arrived in connector 0.9.1; rankgrep_seo_title is the other key it reads.
SEO title: only a conflict if the theme filters late
The connector filters the document title at priority 99, which beats any theme filtering at the default 10. Only a theme filtering at priority 100 or higher wins. Lower it.
The other title trap is a theme that builds the <title> from the page name. Without the connector, RankGrep's title write has to go to the page name, and that name also feeds get_the_title(): the H1, the breadcrumb, the JSON-LD all inherit it. Install the connector so the SEO title has its own field. RankGrep guards against this, but the guard is a safety net.
3. The wiring recipe
Replace mytheme with the theme's function prefix.
Step 1: functions.php
Inside the theme's after_setup_theme callback, next to the other add_theme_support() calls:
add_theme_support( 'rankgrep-headings' );
Then, anywhere in functions.php:
/**
* Templates call these rather than the plugin's functions directly, so the
* site keeps rendering if the connector is ever deactivated. An undefined
* function would be a fatal error on every page.
*/
function mytheme_h1( $fallback_html ) {
return function_exists( 'rankgrep_h1' ) ? rankgrep_h1( $fallback_html ) : $fallback_html;
}
function mytheme_intro( $fallback_html ) {
return function_exists( 'rankgrep_intro' ) ? rankgrep_intro( $fallback_html ) : $fallback_html;
}
Step 2: the templates
Pass the theme's existing markup as the fallback. The page renders exactly as before until a repair is applied.
<!-- before -->
<h1 class="hero__title">Emergency Plumber in <span>Fourways</span></h1>
<!-- after -->
<h1 class="hero__title">
<?php echo mytheme_h1( 'Emergency Plumber in <span>Fourways</span>' ); ?>
</h1>
The opening paragraph is the first paragraph of 20 or more words between the page's H1 and the next heading, ignoring navigation, header and footer, and never a paragraph inside a card, a FAQ or glossary list, a quote, a form or a table. A page whose H1 is followed straight by a heading, or only by cards, has no opening paragraph, and RankGrep does not raise opening-paragraph issues for it. Wire that paragraph, usually the hero sub-paragraph. Wiring a different one makes repairs apply and then fail verification.
<p class="hero__sub">
<?php echo mytheme_intro( 'Plumbing emergencies do not wait. Our licensed plumbers reach you in 60 minutes.' ); ?>
</p>
Titles printed with the_title():
<h1><?php echo mytheme_h1( esc_html( get_the_title() ) ); ?></h1>
Step 3: what not to wire
- Archive templates (
index.php,archive.php,category.php). Their H1 is usually the archive title, not a page's heading. - 404, search results, paginated listings.
- Page-builder content (Elementor, Divi, WPBakery). The builder stores content in its own fields, which neither path reaches. This is a known gap.
4. Symptom to cause
| What RankGrep says | What is wrong | Fix |
|---|---|---|
| "…comes from your theme, not its content — install the RankGrep Connector plugin" | The connector is not installed | Install it |
| "…comes from your theme — add RankGrep heading support to the theme" | Plugin installed, theme not opted in or templates not calling the helpers | Section 3 |
| "WordPress accepted the new meta description, but the live page still shows …" | Theme prints its own description tag | Section 2 |
| "Writing this title also changed the page's heading to …" | No connector, so the title went to the page name and the theme decorates it | Install the connector |
| Heading applies but the next audit reopens the issue | Wrong paragraph wired | Section 3, step 2 |
| Fatal error after deactivating the plugin | Templates call rankgrep_h1() directly | Use the wrapper |
5. Mistakes that cost real time
- Never let an SEO title reach the page name. It feeds every place the theme prints the title.
- Decode HTML entities when moving a hardcoded
content="…"intoesc_attr().&passed throughesc_attr()ships as&amp;. The opposite holds for H1 and intro fallbacks: they are echoed as HTML, so leave entities as written. - Never print a description tag when RankGrep has one. Two tags is worse than a stale one.
- Always wrap the plugin calls.
- Watch for a byte-order mark or text before
<?php.php -ldoes not catch it, and it emits output before headers on every request.
6. Verify
# Theme support is live
curl -s https://example.com/wp-json/rankgrep/v1/status
# Exactly one description tag, bypassing caches
curl -s "https://example.com/some-page/?cb=$RANDOM" | grep -c 'name="description"'
# The heading reads exactly as before wiring
curl -s "https://example.com/some-page/?cb=$RANDOM" | tr -d '\n' | grep -o '<h1[^>]*>.\{0,120\}'
Wiring must be a no-op until a repair is applied. If any page reads differently before RankGrep has written anything, a fallback was transcribed wrongly. Then apply one real repair of each type and confirm it verifies rather than rolling back.
7. Packaging on Windows
Do not use PowerShell's Compress-Archive for a theme zip. It writes backslash separators into entry names, which WordPress turns into files literally named assets\css\main.css. Use a zip tool that writes forward slashes, and put the theme contents at the zip root with no wrapper folder.