CommunityWriting & Editinggithub.com

Doc-Scripter/rankmath-skill

Use when writing or auditing a WordPress/WooCommerce listing that must score 70+ in RankMath - product listings, focus-keyword choice, snippet fields, expanded descriptions, internal linking, hub and collective phrases ("greenhouse construction resources"), orphan pages, inbound links, or diagnosing a low RankMath score. Works on any site given as a base URL, e.g. https://shop.example.com. Triggers: RankMath score, product listing SEO, WooCommerce SEO, focus keyword, meta description, internal links, hub page, cluster links.

What is rankmath-skill?

rankmath-skill is a Claude Code agent skill that use when writing or auditing a WordPress/WooCommerce listing that must score 70+ in RankMath - product listings, focus-keyword choice, snippet fields, expanded descriptions, internal linking, hub and collective phrases ("greenhouse construction resources"), orphan pages, inbound links, or diagnosing a low RankMath score. Works on any site given as a base URL, e.g. https://shop.example.com. Triggers: RankMath score, product listing SEO, WooCommerce SEO, focus keyword, meta description, internal links, hub page, cluster links.

Works with~Claude Code~Codex CLI~Cursor
npx skills add Doc-Scripter/rankmath-skill

Installed? Explore more Writing & Editing skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation


name: rankmath-product-listing description: Use when writing or auditing a WordPress/WooCommerce listing that must score 70+ in RankMath - product listings, focus-keyword choice, snippet fields, expanded descriptions, internal linking, hub and collective phrases ("greenhouse construction resources"), orphan pages, inbound links, or diagnosing a low RankMath score. Works on any site given as a base URL, e.g. https://shop.example.com. Triggers: RankMath score, product listing SEO, WooCommerce SEO, focus keyword, meta description, internal links, hub page, cluster links.

RankMath WooCommerce Product Listing

Overview

Two jobs, and only one of them is scored.

  1. Make RankMath happy. The six inputs it actually reads, plus the body-copy spec that moved a live page from 41/100 to 84/100. RankMath scores fields, not prose: a 1,000-word page with empty snippet fields scores worse than a 700-word page with them filled.
  2. Make the page reachable. Every collection the copy highlights must resolve to real links, and the page itself must be linkable from inside the platform. RankMath does not score inbound links or hub existence. Steps 3b and 4 change Google's view of the page, not the RankMath number. Do not expect a score bump.

Nothing here is hardcoded to one site. Give a base URL; the host, the URL inventory, the sitename budget, and whether WooCommerce exists are all derived from it (Step 0). The one worked example at the end is anonymized - an illustration, never an input.

Step 0 - Derive the platform from the base URL

Every job starts with a base URL, e.g. https://shop.example.com or https://blog.example.com. Derive these; never assume them.

FactHow to derive it
Is it WooCommerce?curl -sL "$BASE/wp-json/wc/store/products?per_page=1" - 200 means yes, rest_no_route means posts and pages only. Do not assume a /product/ URL structure exists
What content types existcurl -sL "$BASE/wp-json/wp/v2" then list the route names. Custom types (farm, produce) show up here and must be included
Full URL inventoryscripts/build_link_inventory.sh <base> - it paginates every collection through x-wp-total and writes _audit/inventory/<host>/{nodes,edges,nav,meta}.tsv
Sitename append lengthFetch 3 live pages of the same content type as the page you are writing, take the longest common suffix of the <title> values, count its characters. That is the budget to subtract. A suffix of 0 chars does not mean "no append" - it means the templates differ, so re-measure within one type
Which hrefs count as internalHost equals the derived host, or the href is relative / protocol-relative. Anything else is external
Which nodes are editorialRead the node kinds out of nodes.tsv. If the site exposes navigation/menu-like types, their "body" is the menu, so they are chrome, not writing - exclude them from inbound and cluster counts (see H5)

curl needs -L everywhere. WordPress sitemaps and REST routes 301 once Rank Math takes over, and curl -s without -L returns an empty body, which is how phantom URLs get invented: an empty list looks like "no such page", not like "fetch failed".

Step 1 - Focus keyword

  • One keyphrase per page, unique across the whole shop. Free RankMath scores a single keyphrase; a second page on the same phrase cannot rank.
  • The checkers count the keyphrase with grep -oiF — a fixed string — so a hyphen in the copy breaks the match. 10-hectare greenhouse does NOT count toward the keyphrase 10 hectare greenhouse. Witnessed 2026-10-14: an article about a 10-hectare project, house-style hyphenated, would have scored density off a phrase it never literally contained. Decide at Step 1 which spelling the copy will use everywhere, write it unhyphenated when the keyphrase is unhyphenated, and say so in the pack so a later copy edit does not "fix" it back.
  • The keyphrase must appear contiguously. HDPE Female Tees does NOT match the keyphrase HDPE tee. This single mistake costs more points than any other check.
  • Prefer a phrase already inside the live slug. Never change a published slug to match a keyword; that loses existing ranking and links.
  • RankMath's "URL is long" check counts the WHOLE permalink, not the slug. Witnessed on a page whose slug is 41 chars in 6 words: https:// + host = 37, + / + slug + trailing / = 80, and the panel printed URL is 80 characters long - the number matching the full URL rather than the 41-char slug is the proof of what it measures. Target: 75 characters or fewer (owner-supplied from the panel, 2026-10-09). So usable slug ceiling = 75 - host overhead, and on that 29-char host the ceiling is 36 chars. Compute the host's overhead once per site and state it in the pack.
  • On a long-brand domain this check collides with keyword-in-URL, exactly as the title length rule collides with a long keyphrase. A keyword-exact phrase longer than the ceiling cannot both stay contiguous in the slug and pass length. Default on a published page: record the length RED as accepted, never rename. A keyword-exact slug is one of the few permalink items that scores; shortening an advisory line forfeits ranking, inbound links, and often creates a collision-suffixed duplicate. If the owner prefers the shorter URL, name which check they bought and which they gave up, and handle it as a site-wide permalink policy - count the affected nodes from the inventory (on that host 22 of 83 URLs exceeded 75, all keyword-exact or county-named) instead of editing one page.
  • Read the focus keyword already saved in the page before writing anything, and keep its exact form, including singular or plural. Rewriting HDPE elbows as HDPE elbow dropped one live page from 73 to 37.
  • When the admin is unreachable, the panel's density line recovers the stored phrase - do not guess it from the slug. One panel line, "Keyword Density is 0.15 which is low, the Focus Keyword and combination appears 2 times", is two unknowns and one equation: the denominator is uses / (density/100), so 2 / 0.0015 = ~1,333 words. Count the contiguous occurrences of each candidate phrase in the live body and keep the one whose count reproduces the panel's number. Witnessed 2026-10-13 on /installation-of-pop-up-sprinklers/: the slug-derived guess pop up sprinklers (17 chars) measured 10 uses / 0.74%, while installation of pop up sprinklers (33) measured 2 uses / 0.15% - the panel's figure to the decimal. The stored phrase was the long one, and the inference had already cost the pack its title budget, its density target and its H2 rule.
  • A long stored phrase changes which checks are satisfiable, so re-price every downstream field before recommending one. A 33-char keyphrase plus a 30-char sitename append cannot produce a compliant title at all; the slug-derived 17-char phrase can. Never paste a title built on the short phrase onto a page storing the long one - RankMath prints "Focus keyword not found in your title".
  • Plural text satisfies a singular keyphrase, because HDPE elbows contains HDPE elbow. The reverse is false. When unsure, write the plural form in the title, the H1, and the first sentence.
  • When the natural long keyphrase cannot fit the title window, store its shorter contiguous tail. Witnessed 2026-10-14 on high volume low pressure irrigation (35 chars) against a 30-char sitename append, which leaves a typed window of 15-30: the long phrase makes keyphrase in title and title length mutually unsatisfiable before a single word is written. Storing low pressure irrigation (23) costs nothing in copy, because containment runs that way - every long-form mention still contains the stored phrase, so it counts as a use. Prove the choice, do not argue it: score the SAME finished file twice, once per phrase. Measured on one draft: short = density 0.8%/15 uses and 5 of 14 H2s (both PASS), long = 0.2%/4 uses and 2 of 14 (both RED). The density gap is the arithmetic, not style - a 6-word phrase needs ~10 repetitions to clear 0.5% and reads as spam. State the numbers for both in the pack and let the owner see why.
  • Before assigning, check the site's focus-keyword map (default ./rankmath-focus-keywords.md, override with RANKMATH_KEYWORDS=). If the page sits in a duplicate cluster, resolve that first; new copy will not help.
  • When the requested keyword is already claimed by another page, do not write the listing on that phrase and do not quietly swap in a near-synonym. Report the collision - the phrase, both URLs, and the owner's actual options (301 the weaker page, differentiate one page's angle, or move the phrase) - and write only after that is decided. Swapping HDPE elbows to HDPE elbow to dodge a collision is the same regression as bullet 4, just chosen for the wrong reason.

Step 2 - The field pack

Fill every one of these. They are the difference between the 40s and the 80s.

FieldRuleWhere
Product name (H1)Keyphrase contiguous, near the frontWooCommerce product title. Re-check the slug afterwards, renaming can rewrite it
SEO titleTotal rendered length 45-60 chars after the sitename append, keyphrase in first 5 words, plus the number / power word / sentiment trio priced by check_title.cjsRankMath, Edit Snippet, Title. See the sitename trap below
Meta description120-160 chars, keyphrase in first 5 words, ends with a click reasonRankMath, Description. If empty, RankMath silently falls back to the short description. That is the most common hidden failure
Short description2-3 sentences, keyphrase in sentence one, must not repeat the long description openingWooCommerce "Product short description". Feeds the auto meta, the excerpt check, and the text above the tabs
Featured image altfocus keyword + a market qualifier, e.g. " for sale in kenya"Media library
BodySee spec belowWooCommerce description, Text (HTML) tab

Check whether an image exists before promising an alt. curl -sL "$BASE/wp-json/wp/v2/media?search=<topic>&per_page=20" is public on most WordPress installs and returns source_url and alt_text for each hit. Witnessed 2026-10-13 on /installation-of-pop-up-sprinklers/: pop up, popup and lawn returned 0 items, sprinkler returned two rain-gun photos - a different product from a buried lawn head. The correct pack row was therefore "no compliant featured image exists; upload a real site photo", not an alt string written for a picture nobody has. Never invent an image, and never recycle a same-category-but-different-product photo just to turn a check green.

The sitename trap

RankMath appends a sitename template to whatever you type in the Title field, so the field length and the rendered length differ. Measure the append on the target site before budgeting the title: fetch three live pages of the same content type and take the longest common suffix of their <title> strings. Its character count is what the template costs you; typed budget = 60 minus that number.

Two measured failure modes, so treat the number as per-type and never as a site constant:

  • Mixed templates collapse the suffix to 0 chars. On one live shop, products render … - Example Online Shop while posts are broken (next bullet), so a cross-type sample finds no common suffix at all. That reads as "free budget" and is wrong. Sample within the content type you are writing.

  • A template can be broken and render literally. The shop's post /greenhouse-construction/ titles itself Greenhouse Construction: Framing & Netting Installation - %s - an unsubstituted printf placeholder. When the suffix looks wrong, read the raw <title> of the exact page before trusting any budget.

  • Three same-type pages can show three different appends. The template only governs pages whose RankMath title field is empty; on any page where someone typed a title, that string renders verbatim - including a wrong brand. Four products on that shop measured 23, 29, 11 and 0 chars of suffix, one of them carrying the corporate sitename. So per-type sampling gives you the template, not the page. Budget against the page you are writing: fetch its own <title> and diff it against the field content.

Example: on that shop the product template appends 23 chars, so a 48-char typed title renders at 71 and fails the length check - but only for pages still using the template. See the worked example. If the append is long enough to starve every title, the structural fix beats working around it: shorten the template under Rank Math, Titles & Meta, Products.

Title Readability needs three separate things

RankMath scores the title against three independent checks, and they are not interchangeable. All three are now testable locally: scripts/check_title.cjs is a line-by-line port of assets/admin/js/analyzer.js, and check_listing.sh prints them as three more scored gates.

  1. a number - the plugin's rule is literally /\d+/.test(title). Any digit counts: 90 Degree, 4 Pumps, a bare 10. Never invent one.
  2. a power word - the lowercased title is split on spaces and each word is matched against rankMath.assessor.powerWords (the PHP list assets/vendor/powerwords/en.php, 1,103 entries) as word === entry or startsWith(word, entry) with at most 4 extra characters. Verified members: best, easy, proven, affordable, amazing, excellent, essential, ultimate, free, price, quality, expert. top is not in the list - it only carries sentiment, so Top never cleared the power check no matter what this skill used to say.
  3. a sentiment word - the bundled sentiment lexicon (9,196 labelled words once RankMath's add-on and extras are merged) is summed over the title's tokens, with the sign flipped when the previous token is a negation (not, dont, isnt, ...). PASS iff the total is non-zero, so a positive and a negative can cancel out and print RED.

One word can clear checks 2 and 3 at the same time, and that rewrites the budget arithmetic. best is a power word and sentiment +5; so are easy (+4), proven (+2), affordable (+2), amazing (+4), excellent (+5); cheap is a power word and negative −3. Witnessed 2026-10-14: a pack pronounced power and sentiment unavoidable REDs for a 21-char keyphrase inside a 15-30 typed window - "9 characters left, the trade is bought" - and recommended 10 Hectare Greenhouse Costs. Best 10 Hectare Greenhouse is 26 typed → 56 rendered and takes all four gates green: five of those nine characters were enough. Never write "jointly unsatisfiable" into a pack until the double-duty words have been run through the checker.

RETRACTION: the line this skill used to carry - "a title containing only Best Price failed all three checks, so Best is not doing the work" - is contradicted by the port, which scores Best Price as power PASS and sentiment PASS (+5), and by the corrected top finding above. It was written from a panel reading, not from the plugin, and the two cannot both be right. Until someone re-reads that panel, do not use it to justify dropping a word from a title; HDPE Elbows: 90 Degree, Top Quality is kept here as a worked example because the port confirms it (number PASS via 90, power PASS via quality, sentiment PASS score 6).

When the budget cannot hold the keyphrase plus all three - and only after the double-duty words above have been priced - the rules are jointly unsatisfiable, so drop in this fixed order and say which you dropped:

  1. the sentiment word,
  2. the number - only if a real figure exists elsewhere in the meta to carry it,
  3. the power word, last.

Dropping one is a prediction of a specific RankMath error, and the owner sees it the moment they paste. The panel prints the literal line "Your title doesn't contain a positive or a negative sentiment word." (same shape for the number and power word). So the drop must be written in the same table row as the title it applies to, never in a paragraph further down - and never as a bare "PASS". Witnessed 2026-10-09: a pack labelled Greenhouse Construction in Kenya: Costs as "the only option that passes today" with the note "no power word". It passed the length check only; it fails number, power and sentiment. The owner pasted it, got the sentiment error, and reasonably read it as a skill miss. The rule was captured; the reporting shape was wrong.

"PASS" is per-check, so name the check. Write length PASS / sentiment RED, not PASS. A green length bar and three readability errors appear on the same screen at the same time; a title that is compliant on the one axis the local script measures is not a passing title.

  • Every title string you emit carries its rendered length, including one-off fixes for a page you are not writing. Witnessed the same day: a pack correctly priced its own five title options, then recommended Pumps in Parallel: 4 Pump Sizing as the fix for another page's 90-char title. That string is 32 typed, so it renders 62 - the same RED it was sent to cure. A suggestion without typed + append = rendered is an unmeasured claim. Count gates from the script output too: check_listing.sh prints 18 scored gates as of 2026-10-14 (the title trio was added), so a pack that quotes 15/15 is reading an older run - take the number from the output you just got, not from this file.

Never truncate or split the keyphrase to fit: a broken contiguous match costs more than a missing power word. If all four still cannot fit, the template is the problem, not the title - shorten it under Rank Math, Titles & Meta.

Never paste HTML comments into the description. They can become the first paragraph and break the "keyphrase in first 10 percent" check.

Step 3 - Body copy spec

RankMath thresholds to clear:

  • 600+ words; aim 900-1100
  • Open the description with an H2 carrying the exact product name, so the body matches the H1 and the snippet title
  • Keyphrase in the first sentence
  • Keyphrase or partial match in 5 or more H2s (or 30%+ of the H2s a page actually has, which is what a short page can satisfy), one H2 every 250-300 words
  • Density 0.5-1.5 percent, roughly one use per 90 words
    • A table of contents that repeats each subheading verbatim raises exact-phrase density for free, and breaks no other gate. Measured 2026-10-13 on a 1,349-word draft: a 10-item Contents list of <a href="#slug"> links took the contiguous keyphrase count from 2 to 4 (0.15% -> 0.3%) and the word count from 1,349 to 1,432. The checker's internal-link gate ignores same-page anchors (it printed 8 (want 3+) [abs 8 / rel 0 / 10 same-page anchors not counted]), and sentences/transition ratios move by ~1 point, so the list neither inflates the link count nor trips the prose gates. It buys density, it does not clear it - long phrases still need real prose repetitions. A block-generated Contents list is not in the draft, so a draft converted with rm_toc.cjs --drop-toc scores its density and word count without it (measured on the HVLP draft: 1,949 -> 1,881 words once the 12-item list went). The saved post does contain it - the block is static - so the live page scores higher than the draft, never lower.
    • The TOC is a scored check, so both checkers print a table of contents gate. Use Rank Math's own block, not a hand-written list. The mechanism was read out of the plugin source on 2026-10-14 (includes/modules/schema/blocks/toc/), which settles what earlier packs could only guess:
      • block.json - block name rank-math/toc-block, inserter title "Table of Contents by Rank Math", attributes title, headings[], listStyle, titleWrapper (default h2), excludeHeadings.
      • utils.js:136 - inserting the block writes an anchor attribute onto every heading block for you. That is the whole reason 8 of this host's own posts have <h2 class="wp-block-heading" id="..."> and the three that were hand-pasted had none.
      • utils.js:23 - a heading with no anchor gets kebabCase(heading text), lodash rules, not WordPress slugs. Live fingerprints: pto-driven-chaff-cutter, fa-qs-on-greenhouse-construction-in-ol-kalou from "FAQ's on…", and on post 5279 a-four-pump-example-at-400-m-per-hour from a heading reading "400 m³ per hour". Non-ASCII units are dropped, not transliterated - ³, µ, ° simply vanish, so an anchor you quote for a deep link must be read off the live page, never derived from the heading text by eye. Odd-looking is not broken.
      • utils.js:15 - an existing anchor always wins over the generated one. utils.js:130 - a repeat becomes anchor-2, anchor-3.
      • save.js - it is a static block storing a headings array, and it returns nothing when that array is empty. So never hand-author a <!-- wp:rank-math/toc-block … --> comment into a draft: WordPress will call it unvalidated. The owner inserts it with two clicks; the script below only has to get the heading anchors right.
    • scripts/rm_toc.cjs <draft.html> [--drop-toc] does the conversion (added 2026-10-14). It rewrites each heading as <!-- wp:heading {"level":2,"anchor":"x"} --> plus the exact markup core serialises, keyed off the id the draft already carries, and with --drop-toc replaces the hand-written Contents heading and its in-page list with an RM-TOC marker line. Run it on every draft that ships a Contents list, and put this in the runbook: paste the converted file in the Code editor, click the marker, switch to the Visual editor, insert Table of Contents by Rank Math, delete the marker, publish. It replaces a per-heading editing session that was being skipped three posts running.
    • check_draft.sh prints PEND, never PASS, for a TOC it cannot prove. PEND means "this is not settled by the local file" - either the block placeholder is present, or the list's ids are free HTML that WordPress will strip on import. check_listing.sh tags a real one as [Rank Math toc-block]: measured /chaff-cutter-operations-in-kenya/ 4 anchor links, 0 dead [Rank Math toc-block], /greenhouse-construction-in-ol-kalou/ 8, 0, and post 5290 after the owner's fix 20, 0.
    • WordPress still strips a hand-written id attribute when free HTML is imported as heading blocks, which is why the draft passed and the live page printed 10 anchor links, 10 dead - every Contents link going nowhere. If you must set anchors by hand instead of using the block, the field is Advanced -> HTML anchor, and it only appears with a heading block selected in the Visual editor; the Code editor shows no block sidebar at all, which is how an owner came to report the field as missing on 2026-10-14. Say switch back to the Visual editor in any runbook step that uses it.
    • This recurs even when the runbook prints the step. Third time witnessed 2026-10-14: post 5290 was published from a draft that passed the TOC gate, with a pack whose step 7 listed all twelve anchor values, and the live page still renders 12 anchor links, 12 dead (verified: 12 href="#..." in the article, 0 id attributes, 0 targets that resolve). Two causes, both fixable in the pack's shape, not by repeating the instruction louder:
      • Ordering. An anchor table buried at step 7 reads like a later cleanup task. Put the table in the same step as the paste, under a line saying do not leave the editor until every heading has its anchor - the payoff is invisible in the editor, so a step that looks optional gets skipped. rm_toc.cjs removes the need for either.
      • The trade is never named. Dead anchors are a user-visible defect: a reader clicks a Contents line and nothing happens. A page with N dead links is worse for that reader than a page with none, and RankMath's TOC check is not worth shipping a broken UI. So offer the real choice up front: fill the N anchors, or delete the Contents list and accept that one RED. Decide it in the pack, in writing, before publishing; do not leave a broken TOC as the default outcome of nobody choosing.
    • The values are case-sensitive and must match the href strings already in the list - so when the draft is the source of those strings, hand over the exact (heading text -> anchor) pairs from the draft's own id attributes, never a re-derived guess. rm_toc.cjs prints that table for the pack, including any -2 collision suffix it added.
  • 25 percent or fewer sentences longer than 20 words
  • 25 percent or more sentences carrying a transition word. The exact list both checkers score against is scripts/transitions.txt (Also, In addition, Meanwhile, However, For example, First, Next, Therefore, Finally and about 25 more) - edit that file rather than letting the prose and the code drift apart again.
  • Under 10 percent passive voice
  • Paragraphs of 120 words or fewer
  • 3 or more internal links, 1 external authoritative link. A 200 status does not make a citation valid. Fetch the external page and grep its text for the term you are attributing to it, then write the anchor from what the document actually says. Witnessed twice on the same job: an FAO URL that returned 200 and was 3. THE HYPOTHALAMUS, and - 2026-10-13 - a FAO Paper 56 chapter fetched to back a sprinkler-uniformity claim that held 0 occurrences of "sprinkler" and 11 of "reference crop evapotranspiration". The honest anchor was the ET title, and the claim was narrowed to the water-need figure it does support. Cite what the page says, not what its URL looks like.
  • FAQ block, 5-6 questions, keyphrase in the FAQ heading and in one question

Two of the rules above still have no checker - verify them in RankMath's panels, not in the scripts: under 10% passive voice, and one H2 every 250-300 words (the scripts count H2s, never their spacing). The Title Readability trio now has one: scripts/check_title.cjs, and check_listing.sh scores it against the live <title>. The full coverage table is in Resources.

A gate that reads across a tag boundary can be satisfied by somebody else's markup. Found 2026-10-13 while adding the TOC check: check_draft.sh built "keyphrase in first paragraph" from the text after the first <p up to 500 bytes, with no stop at </p>. The draft printed PASS keyphrase in first paragraph while its real opening sentence read "Installing pop up sprinklers is the surest way..." - the match came from the Contents list that followed, whose items repeat the H2 wording. Both checkers now cut the record at </p>. Consequence for any pack written before this fix: a first-paragraph PASS is only trustworthy if the intro sentence itself holds the phrase. Re-read the printed paragraph, not just the flag.

Both prose gates share one denominator, and the sentence counter is block-blind. The sentences over 20 words and transition words gates divide by the same sentence count, which is produced by splitting flattened text on .!?. Two effects follow, measured on the parallel-pump draft 2026-10-13:

  • Splitting long sentences to clear the length gate raises the sentence count and therefore lowers the transition percentage. That draft went 36% long / 35% transitions, and after cutting the long sentences read 12% long / 24% transitions - one point under the gate. Fix: add transition words in the same edit that splits sentences, then re-run.
  • Heading text, table cells and Contents items have no terminal punctuation, so each one merges with its neighbour into a single multi-word "sentence". The same file measured 36% flattened and 17% counted block by block (heading, list item, cell and paragraph separately). Read a RED on that gate as "check which of my sentences are real prose" before rewriting anything: the artifact rows are not defects, and RankMath's own sentence extraction may differ. The panel is the arbiter.

Copy pasted from a book, a PDF or a course export arrives in three shapes that must be converted before publishing - not pasted, not deleted. Write the conversion into the draft and name each change in the pack:

  • LaTeX (\[ Q_T=Q_1+Q_2 \], \boxed{}, \sqrt{\frac{a}{b}}) prints literally in WordPress, which has no math renderer by default. Convert to plain text with unicode operators - total flow = 100 + 100 + 100 + 100 = 400 m³/hour, D = √(4 × 0.1111 ÷ (π × 1.2)) = 0.343 m - keeping every figure identical.
  • Interactive widget exports ("3 of 4", ON/OFF rows, slider states) are controls, not content. Replace with a static table whose values you can derive from the source's own numbers, and keep the source's caveat sentence next to it.
  • Chapter numbering (15.1, 16, 17) reads as an error on a standalone post. Drop the numbers, keep the order.
  • A caption that says a figure is omitted for clarity is not permission to invent one. Check the media library first (below), and if nothing exists, leave the sentence as prose.

Structure that works for irrigation fittings:

  1. H2 with the exact product name, then the intro with the keyphrase in sentence one
  2. What Is X?
  3. Uses, type A
  4. Uses, type B
  5. A vs B comparison table
  6. Specifications (sizes, material, pressure)
  7. How to Choose the Right X
  8. Quality Features
  9. FAQ
  10. Where to Buy X in Kenya, with phone numbers and CTA

Where the URL list actually comes from

Never guess a site URL, and never pull URLs from curl -s on a sitemap: those files now 301 to Rank Math's own sitemap index, so curl -s without -L returns an empty body and you get no URLs with no error. That is the mechanism by which phantom hubs get invented.

Build the inventory once per site instead:

bash scripts/build_link_inventory.sh https://example.com     # writes ./_audit/inventory/<host>/*.tsv

It enumerates wp-json/wp/v2 routes, paginates every content type by x-wp-total, adds the Woo Store API when WooCommerce is present, and writes:

FileHolds
nodes.tsvkind url name words - every product, post, page and taxonomy archive on the site
edges.tsvsource_url target_url anchor - links found inside body content only
nav.tsvchrome links (menu, header, footer), flagged off-site. Excluded from inbound counts
meta.tsvbase, generation date, node and edge counts

nav.tsv is separate on purpose: menus are on every page, so counting them makes every page look well-linked. Inbound links only count when a human wrote a sentence containing the link.

Two parsing traps worth knowing if you write your own tooling: REST JSON escapes quotes, so unescape \/ and \" before grepping href="; and gawk treats a single-character | as a regex in split(), which silently shreds strings into characters - use commas.

Link to complementary parts, not the same part. A tee page links to couplings, adapters, reducer tees, and the drip category.

Step 3b - Collective phrases and cluster links

The rule: a highlighted collection must link each item, or link one article that lists and links them. There is no third option - the two arms are Branch B and Branch A, and the only thing outside them is a named item the site has no page for, which stays in the copy and is reported (see Retain and report below).

Copy loves collective wording - "our greenhouse construction resources", "the full range of drip kits", "everything about solar pumping". Each such phrase is a promise to the reader that the platform holds a collection. check_draft.sh counts hrefs without resolving them, which is why a draft can print every check PASS while pointing at pages that do not exist. This step resolves the promise.

Trigger: any phrase implying a collection - resources, resource, guide, series, range, kits, solutions, hub, tips, how-to, everything about. Run H0 on every one, then H1-H5, before the sentence is allowed into the draft.

  • H0 - decide whether the phrase is a promise at all. Collective wording is also ordinary English, and judging a description as an unmet promise makes the writer delete accurate copy. A phrase commits the page only when one of these is visible:

    • it is an anchor's own text (<a href="...">greenhouse construction resources</a>), or
    • it is a heading (<h2>/<h3>), or
    • its sentence sends the reader somewhere - see, browse, explore, check out, read, view, visit, shop, buy, order, compare, find, covers, includes, offers, supplies, stock, available, below, following, listed, "start with".
    • None of the three: the phrase is a modifier, not a promise. Keep the wording, link nothing, judge nothing. check_links.sh prints INFO MODIFIER, NOT PROMISE and does not count a RED. Witnessed on the corporate About page: "a Kenyan agricultural solutions company" and "a leading agricultural solutions provider in Kenya" triggered a full RED PHRASE UNBACKED "agricultural solutions" with one member page, and the only correct fix was to stop judging the sentence.
    • H0 releases a phrase from judgement, never from a link check: a dead href is still RED HUB GAP whatever the anchor text says.
  • H1 - resolve. Normalise the phrase to its significant tokens (stopwords and the collective nouns themselves are dropped - resources, guide, range, tips, buy, price, kenya are all stoplisted) and look for pages carrying them in nodes.tsv. Matching reads the URL path and the page name only, never the domain.

    • A hub holds every token in its path and is mostly that path: tokens / path_segments >= 0.4. /greenhouse-construction/ qualifies; a 6-segment article that happens to contain the words does not.
    • A member holds all but one token (all of them when the phrase has fewer than 3) in its path or its name, and is not itself a hub. A post merely titled with the phrase is a member.
    • The thresholds live in check_links.sh:match_nodes(). If you change them there, change them here.
    • A category archive is a hub by definition. /product-category/.../pipe-fittings/ exists to list and link every member, so it discharges the promise when the copy links it. Density cannot judge it - the path is padded with /product-category/, and that mis-scoring once told a writer to delete a true link to the shop's pipe-fittings category. Its own member links are rendered by the theme rather than written into post_content, so edges.tsv holds none and H5 prints THIN HUB for it. Treat that THIN HUB as a blind spot of the inventory, not a defect of the archive.
  • H2 - hub exists → link it. Use the hub's own words as the anchor text, then run H5.

    • H2b - the hub exists but is thin. A resolving hub is still the right link even at 300 words: the reader gets somewhere to go and the alternative is a second page competing for the same phrase. Run check_listing.sh on the hub URL, report its own reds as a separate job, and never mint a competing hub for a phrase that already has one.
  • H3 - no hub → count the members already on the platform that cover the topic, then take one of two branches. State in the output which branch and why.

    • Branch A - write the hub page. Taken when 3 or more real member pages exist and the phrase is not already a claimed focus keyword. One job then produces two deliverables: the listing you were asked for, plus a hub-page draft carrying the collective phrase as its own focus keyword, the full field pack, and an outbound link to every member found. This is the "one article that lists and links them" answer.
    • Branch B - inline the list. Taken when fewer than 3 members exist, or the phrase is already claimed. Rewrite the sentence to name and link the individual pages that actually exist ("our greenhouse film, aluminium profiles and insect nets") and delete the collective framing. Nothing is promised that the platform cannot deliver.
    • Zero matching pages, and the copy names nothing specific: the phrase goes away, because there is no list to keep - only a promise the platform cannot back.
  • H4 - hub exists only on another domain → cross-domain conflict; see that section. Report and stop.

  • H5 - cluster integrity report. For hub H and its member set M, walk edges.tsv both ways and print exactly two lists:

    • EXISTING - H -> m and m -> H edges already present, with source URLs.
    • OMITTED - the missing edges, listed for future consideration.

    Only editorial edges count. WordPress exposes menus as a content type on some themes, so the inventory holds pseudo-pages like /navigation/ and /main-menu/ whose "body" is the whole menu - on the corporate site /navigation/ alone contributed 11 edges and made a page with one real inbound link look like it had three. check_links.sh drops rows whose source node kind is navigation, categories, tags or category from every edge query and prints edges: N editorial; inbound counts you have not filtered that way are not evidence of reachability.

The skill reports omitted edges; it never schedules or performs live-page edits. Branch A still writes the hub's own outbound links, because that page is being created now.

Retain and report: named items with no page on the site

Branch B deletes a framing, never content the writer chose. When copy lists specific things - bullets, table rows, an enumeration in a sentence - classify each named item. The item stays in the copy in every case.

Item situationFindingWhat changes
Links a real on-site pageno findingnothing
An on-site page exists but the item is not linkedINFO LISTED UNLINKEDadd the link
No page on the site at allINFO LISTED NO PAGEnothing in the copy - the site owes a page

A missing page is a content gap, not a copy defect. Never delete a reader-useful list because the platform has not caught up with it; report the list and let the owner decide what to build. check_links.sh prints both findings and puts the counts in the VERDICT line; check_draft.sh and check_listing.sh stay silent on them.

An item qualifies as "named" when it carries no href, has no sentence-ending punctuation, and runs to 7 words or fewer - that is what separates a resource bullet from ordinary prose.

The claimed-phrase guard exists because free RankMath scores one keyphrase per page: minting a hub for a phrase already owned elsewhere creates a tenth duplicate cluster instead of fixing anything. check_links.sh refuses Branch A in that case and falls to Branch B.

Run it before publishing:

bash scripts/check_links.sh --draft page.html --base https://example.com \
  --as https://example.com/the-page-url/ --phrase "greenhouse construction resources"

Findings: RED HUB GAP (an internal link that resolves to nothing), RED PHRASE UNBACKED (a collective phrase with no hub and no inline list), INFO MODIFIER, NOT PROMISE (H0 - the phrase is description, not commitment), INFO ORPHAN (zero inbound editorial links), INFO CLUSTER (the H5 report), INFO LISTED NO PAGE / INFO LISTED UNLINKED (the retain-and-report table), INFO CROSS-DOMAIN (nav sends the phrase off-platform). --url mode audits a live page instead of a draft.

Pass --phrase once per collective phrase you wrote. Without it the script only prints INFO PHRASE SPOTTED hints, which is a reminder to decide, not a verdict: hints are keyword-guesses over the copy and can name a phrase you never promised. A phrase passed with --phrase is the one that gets the RED/BRANCH treatment.

Declare a phrase only when the copy really sends the reader to a collection. A --phrase carrying no collective noun is judged as a hub promise, so declaring a service name produces a RED for a promise nobody made - witnessed twice on the corporate About page: "agricultural solutions" (a description of the company) and "borehole drilling in Kenya" (a service line, 2 member pages linked) both came back RED PHRASE UNBACKED, and both times the copy was correct. H0 covers the first automatically; the second is avoided by not declaring it.

RankMath does not score inbound links or hub existence. Step 3b changes Google's view of the page, not the RankMath number. Do not expect a score bump.

Branch A - hub page spec

The hub is a normal listing job with a different subject. It gets the whole field pack:

  • Focus keyword: the collective phrase itself, verbatim, unique across the site.
  • SEO title / meta / short description / alt: same rules as Step 2, measured sitename budget included.
  • The body is the resource list. One H2 per member page, each H2 named after that member and each linking it once with descriptive anchors. Do not summarise a member you fail to link.
  • State the count it covers in the intro ("these 7 pages cover ..."), so a future reader can tell when the hub has gone stale.
  • FAQ block, 5-6 questions, per the normal spec.
  • Deliver the hub as a draft with its field pack filled. Its outbound links to every member go in now, because this page is being created. The inbound edges (member pages linking back to the hub) are the OMITTED list from H5: report them with their source URLs and stop. The owner applies them; the skill does not edit live pages, and the runbook in Step 4 tells them where to paste.

Cross-domain conflicts

When site chrome sends a phrase to another domain while an on-platform page also exists (verified case: a product-page nav anchor "Greenhouse Construction" pointing at the corporate site while the shop holds its own /greenhouse-construction/ post), the on-platform page has zero inbound links even though the anchor text appears on every page of the site.

This is the owner's routing decision, not an SEO fix. Report it and stop. Never edit a menu, nav, or live page. Report it actionably, with all three parts:

  1. the conflicting anchor label and both URLs (on-platform page, off-platform target);
  2. where to change it: WP admin → Appearance → Menus → <menu> → <item> when the menu is dynamic;
  3. if that screen does not hold the string, it is hardcoded in the theme header - say so and point at the Theme File Editor rather than guessing.

Step 4 - Verify on the live page

RankMath's editor score and Google's view can disagree. So can the page's own links. After publishing:

  1. Run bash scripts/check_listing.sh <slug-or-full-url> "<focus keyword>" with RANKMATH_SITE=<base>. It reads the rendered HTML, so it catches fields RankMath reports as fine, and prints INFO inbound editorial links n from the inventory. Pass a full URL to audit a blog post or hub page instead of a product.
  2. Run bash scripts/check_links.sh --url <page-url> --base <base> --phrase "<each collective phrase>". A live page can pass every field check and still be an orphan.
  3. Purge LiteSpeed Cache from the WP admin bar. Stale cache keeps serving the old meta description even after the field is correct.
  4. Re-check the score. If the Products list looks frozen, run Rank Math, Status & Tools, Tools, Re-Scorer.

Rebuild the inventory when check_links.sh prints INVENTORY_AGE_DAYS over 7, or right after publishing a page that should receive links - a stale inventory reports live pages as dead.

Step 5 - Score triage

BandLikely causeFix
40sKeyphrase not contiguous in the title, or SEO title and meta left blankField pack, Step 2
55-65Under 600 words, density off, keyphrase missing from the introExpand body, re-place keyphrase
70-80Readability reds onlySentence length, transition words, passive voice
Basic SEO all green but score lowOpen Title Readability and Content Readability, the deductions are there and not in the bodyAdd a number or power word to the title, add a table of contents
Score DROPPED after a rewriteThe keyphrase form changed (singular vs plural), or the old title's number and power words were lostRestore the stored keyphrase form in the title, H1 and first sentence, then put the number back and re-add a word that is both a power word and a sentiment word (Best, Easy, Proven)

Before replacing any title that already scores well, read the stored focus keyword and the current title first, and keep every element that was passing. A rewrite that lowers a live score is a regression, not an improvement.

Output format

Deliver as a paste-ready block, in this order: slug, focus keyword, product name, SEO title with char count, meta description with char count, short description, image alt, then the description HTML.

  • Slug is always stated, with the full URL, even though it is never a field to fill. Do not change a published slug; it carries the existing ranking and inbound links, and renaming a WooCommerce product can rewrite it silently.
  • Focus keyword is always stated, and always tagged: STORED when it came from the RankMath editor or the keyword map, INFERRED (from slug + title + H1) otherwise. An untagged keyword reads as measured data and is how a guess ends up pasted over a live keyphrase.
  • SEO title with char count and its per-check status - rendered N, length PASS/RED, and which of the number / power word / sentiment items it gives up, in that same line. The owner reads this row before the paragraph explaining it.

That HTML must begin with an H2 holding the exact product name, word for word as entered in the WooCommerce title field, before the intro paragraph. Flag anything not verified against the live site.

Cluster link report

Required final block, and the only place these facts appear:

  • Phrase decision - one line per collective phrase in the copy: the phrase, the outcome and why. There are five outcomes, not two: MODIFIER (H0 - the phrase describes the business and never sends the reader anywhere, so nothing is owed and the wording stays), H2 (a hub already existed and the copy links it - no branch is taken), H2b (hub exists, reported thin), A (new hub page drafted), B (list inlined, framing deleted). Naming only "A or B" hides the common case, and skipping MODIFIER turns accurate company descriptions into fake defects.
  • EXISTING - the two-way edges already present between a hub and its members, with source URLs.
  • OMITTED - the missing edges, as a list for future consideration only. Reporting them is not a commitment to edit live pages, and the skill does not edit them.
  • LISTED, NO PAGE - named items the copy keeps but the site has no page for, item by item. Stated as content gaps for the owner to decide on, never as a reason to cut the list.
  • Reachability - inbound editorial link count for the page under audit, plus any cross-domain conflict with its WP admin location.

If Branch A was taken, the deliverables are two: the requested listing and the hub-page draft. Say so at the top.

Resources

Four scripts, all site-derived. Nothing is hardcoded to one shop: RANKMATH_SITE (or --base) is required, and the host, the URL inventory and the content types all come from it.

  • scripts/build_link_inventory.sh <base-url> [out-dir] - writes nodes.tsv, edges.tsv, nav.tsv, meta.tsv under ./_audit/inventory/<host>/. Run once per site, rebuild when INVENTORY_AGE_DAYS exceeds 7.
  • scripts/check_links.sh --draft <file.html>|--url <live-url> --base <url> [--phrase "<collective phrase>"] [--as <url>] - Step 3b. Resolves collective phrases, checks hub existence, cluster integrity, orphan status, retained-list gaps, cross-domain chrome. Counts editorial edges only: rows sourced from navigation/categories/tags pseudo-pages are dropped and the run prints edges: N editorial. Needs the inventory. Exits 1 when the VERDICT has any RED, so it can gate a run; INFO findings never change the exit code.
  • scripts/check_listing.sh <slug-or-full-url> "<focus keyword>" - the RankMath thresholds against the rendered page (18 scored gates since 2026-10-14, when the title trio was added), so it exposes stale cache and short-description fallback the editor hides. Prints NOT_LIVE HTTP 404 instead of a score for a page that is not published yet. Tags a real plugin TOC [Rank Math toc-block]. Prints PEND for the title trio when node or the lexicons are absent.
  • scripts/check_draft.sh <file.html> "<focus keyword>" ["<exact product name>"] - the same body thresholds on a local file BEFORE publishing. Third argument also checks the description opens with an H2 holding that name word for word. Its table of contents gate prints PEND (not PASS) when the anchor proof only exists post-import.
  • scripts/check_title.cjs "<title>" ["<title 2>" ...] [--keyword "<focus keyword>"] [--append <chars>] [--plugin <seo-by-rank-math dir>] - the Title Readability trio plus rendered length, ported from the plugin's own assets/admin/js/analyzer.js. Pass --append 30 to price a typed field as the live page will render it, or pass several candidate titles at once. Reads ../lexicons/*.json; --plugin <dir> rebuilds those from a local copy of the plugin once (power words from assets/vendor/powerwords/en.php, sentiment labels from the three sources the bundle merges, negations from the same file). The sentiment gate scores a sum, so a positive and a negative word can cancel to 0 and print RED.
  • scripts/rm_toc.cjs <draft.html> [--out FILE] [--drop-toc] - Step 3b/TOC. Converts headings to <!-- wp:heading {"level":N,"anchor":"x"} --> so the anchor survives block import, prints the anchor table for the pack, and with --drop-toc swaps a hand-written Contents list for an RM-TOC marker to insert Rank Math's block over. Run it after the prose is final, then re-run check_draft.sh on the output.
  • scripts/transitions.txt - the one transition-word list both checkers read, so a draft cannot pass here and fail there on a differently-worded list.

Run everything from the directory holding _audit/inventory/ (the SEO working directory). The inventory path is resolved as $RANKMATH_INVENTORY, else ./_audit/inventory/<host>, else $PWD/..., else $HOME/..., and the INFO inbound editorial links line prints whichever path it used - read it, because an unset inventory silently reports 0 inbound. check_links.sh exits with INVENTORY_MISSING rather than guessing.

What the scripts do and do not check

Skill ruleEnforced by
Keyphrase in title / meta / intro / body, content length, meta and title length, image altcheck_listing.sh only - these are rendered-page fields a draft file cannot show
Comparison table, paragraph length, no pasted HTML commentscheck_draft.sh only - a draft is where you can still fix them
Keyphrase density 0.5-1.6%check_draft.sh gates it; check_listing.sh prints the live density but only REDs on absence
Keyphrase in the opening of the body, content length, subheadings, internal and external links, transition words, long sentences, FAQboth, with the same thresholds
Keyphrase in subheadings5+ H2s, or 30%+ of the H2s present, same expression in both
Internal links >= 3Both; check_draft.sh additionally excludes same-page #anchors and tel:/mailto: from the count
Hub exists, phrases resolve, inbound links, list gapscheck_links.sh only
Under 10% passive voicenothing - check it in RankMath's Content Readability panel
One H2 every 250-300 wordsnothing - subheading distribution only counts H2s, never their spacing
Title readability (number / power / sentiment)check_title.cjs - a port of the plugin's own analyzer.js; check_listing.sh scores it against the live <title> and prints PEND when node or the lexicons are missing
URL / permalink lengthnothing - it counts scheme + host + slug, so no script here can reproduce it without the host's own length

check_draft.sh counting hrefs is not the same as links being valid. It prints every check PASS on a draft whose 20 internal links all 404 (verified against a fixture). Always run check_links.sh on the same file.

Order of work: check_draft.sh + check_links.sh --draft before pasting, check_listing.sh + check_links.sh --url after publishing, and run check_listing.sh on a known-good page as a baseline before trusting a new result. Requires curl, grep, sed and awk, all present in Git Bash. No browser automation.

Worked example (41 to 84)

Anonymized: a real WooCommerce product page on a live shop, focus keyword HDPE tee.

Before: SEO title unset, so the template produced HDPE Female Tees for Sale in Kenya - Example Online Shop, which has no contiguous match. Meta unset, so RankMath auto-pulled 243 chars from the stale short description. Short description still held the old copy. Image alt was HDPE Tees. The body was already 1,006 words with 9 H2s and 6 links, and the page still scored 41.

After: product name HDPE Tee Fittings: Male and Female Tees, title HDPE Tee Fittings for Sale in Kenya | Best Prices (48 typed, but see the sitename trap: 37 or fewer typed is the real target), meta Buy HDPE tee fittings in Kenya - male tee and female tee, 20mm to 110mm, UV-stabilised. Best prices and countrywide delivery from Example Shop. (142 chars), alt HDPE tee fittings for sale in Kenya. Score: 84/100.

Benchmark: another product on the same shop at 79 with only 732 words and one internal link - proof that fields outweigh content volume.

Related Skills