Skip to main content
Nothing between the writer and the live page used to look at the copy. Publishing validated the collection schema (required fields, slug shape, field types) and the editor showed 60/160 character counters, so a post with no meta description, two H1s, a keyword-less slug and an alt-less hero image published with a 200, and the customer learned about it from a later SEO audit, if ever. The pre-publish check closes that gap with one deterministic function that every surface runs: the same inputs always give the same findings, so the editor can re-run it on every keystroke, the API can run it on the stored row, and the Content department can quote it verbatim. It has no network and no clock. It never blocks a publish: errors ask for a confirmation in the editor and come back as warnings on the API, and the decision stays with the person publishing.

Where it runs

The marketing editor

A Pre-publish check section sits above Status in the item’s sidebar, so its findings are above every publish control: a score chip out of 100, errors under Fix before publishing, warnings under Worth a look. Each row names the field and clicking it jumps there (title, slug, meta title, meta description, target keyword, alt text, body). The report is fetched when the item opens and re-run locally on every edit, so it tracks what is being typed.

The publish gate

Every publish entry point in the editor (the header action, Publish changes in the sidebar, the Publish to dialog, the status flip on an unlinked item) goes through one gate: unsaved edits are saved, the server check runs again, and errors open Publish anyway? listing them, with Go back and Publish anyway. Warnings never interrupt, and a check that cannot run never blocks.

The APIs

GET /api/olympus/marketing/content/:contentId/seo-check under an Olympus API key, and its dashboard twin GET /api/marketing/content/{id}/seo-check for a signed-in user with marketing.content read. Same loader, same response; a row outside the account is a 404. From an MCP client the content_seo_check tool calls the Olympus route.

The publish route

POST /api/olympus/marketing/content/:contentId/publish-to-site (content_publish_to_site) runs the check on the row as it is about to go out and returns warnings: string[] (one line each, Error (field): ... or Warning (field): ...) plus the structured seo_check. The note tells the department to state each one before treating the publish as done. Unpublish is not checked.
The Content department also states the list with a pass or fail per item in its reply before it calls the publish tool, so a reviewer sees the check twice: once as the department’s own statement, once as the route’s verdict.

What it checks

Two levels. An error is something the site will visibly get wrong: a missing meta description, a body with two H1s, an image with no alt text, a banned phrase, an empty body. A warning is a ranking or editorial miss the writer should look at: keyword placement, heading order, few internal links, a figure with no source. Four rules behind the table are worth knowing:
  • One H1 is the page title. Site templates render the item’s title as the page H1, so a body with no H1 is the normal, correct shape. A body with exactly one H1 is accepted (some imports carry the title inside the body); two or more is an error.
  • Markdown and HTML are both read. Rows hold either, and the stored format hint is not reliable (the editor converts markdown to HTML on load and saves the HTML back), so the format is detected from the content and the hint only breaks ties. Code blocks are ignored; image syntax and links are reduced to prose before words are counted.
  • Internal is relative to the site. A site-relative path counts as internal, and so does an absolute URL on the linked project’s production host, so a link back to https://www.example.com/blog/... counts on www.example.com. Anchors, mailto: and tel: links count as neither.
  • Proof follows the stage. The item’s journey_stage (free text, spelled the way the journey map spells it) is classified by keyword into awareness, consideration, decision or retention, and stats.journey_stage_kind says which. An awareness or retention piece, or one with no stage, gets the ordinary rules; a consideration or decision piece must carry proof under every H2; at the decision stage a figure without a source is an error, because that is the page the reader is deciding on. A source is a link, a bare URL or the [source: <label>:<id>] citation the proof pack hands writers; the marker is stripped from the prose before words are counted and phrases matched, so it never counts as copy.
The eleven rules added on 2026-09-12 read the account beside the row, and five things about them are worth knowing:
  • The author and the FAQ come from the row. author_missing looks at author_id and the account’s default author (see Authors); faq_schema_mismatch compares the faq field the FAQPage markup is generated from with the FAQ section the body carries, so the markup never promises questions the page does not answer. answer_block_missing checks the 40 to 60 word band only, on the answer_block field or the lead paragraph; whether the paragraph restates the query is the writer’s job.
  • Roles are read, never guessed. money_link_missing reads the pages marked money and pillar_link_missing the cluster’s pillar URL, both stored by the site architecture. An account that has marked nothing gets the rule skipped and a note, not a false error. A url= on a ::cta shortcode counts toward both.
  • A rival claim needs a source and a date. competitor_claim_unsourced reads settings.comparison_table.rows[].source and checked_at (source_url and verified_at are accepted spellings), or, for a table in the body, a link, a bare URL or a [source: ...] marker in the row plus a date in the row, the header, a caption or the line right under the table (Prices checked 2026-09-01).
  • The call to action is placed on a stage table. The verbs and destinations of the built-in five-rung table (unaware, problem aware, solution aware, product aware, most aware, plus retention) are merged with settings.cta_stage_table ([{ stage, verbs?, destinations?, journey_stages? }], where journey_stages names the journey map’s own stage names for a rung), and the piece’s funnel position allows a set of rungs: awareness allows unaware and problem aware; consideration problem, solution and product aware; decision product and most aware; retention retention and most aware. A link to the offer’s cta_url fits by definition when the offer’s stages[] names the piece’s stage. A form is placed by its action (a /newsletter form is an unaware ask, a /book form most aware); a form with no classifiable action counts for cta_missing and never raises cta_stage_mismatch. See the five-stage CTA table.
  • Shortcodes are not prose. A ::cta{...} or ::upgrade line is taken out of the body before words, claims and answer blocks are counted, so a label such as Save 20% today never raises claims_without_source. The check accepts two or three colons anywhere a space precedes them; the site renders only ::cta and ::upgrade with two colons on a line of their own, so that is the grammar to write.

The score and the response

ok is true when there are no errors; warnings never flip it. score is 100 minus 15 per error and 5 per warning, floored at 0. checks lists errors first, then warnings, each in the order above.
stats.h2_count and stats.sections_without_proof are counted at every stage, and stats.journey_stage_kind says how the row’s stage was read (null when it has none); the proof_per_section finding itself is raised only for consideration and decision. context is what the check was given, so a client can re-run it locally on edits without another request: the target keyword from the item’s target_keyword column (or settings.target_keyword on a row written before 2026-09-12, which readers fall back to), the forbidden phrases from the account’s default active brand guide (empty when there is none), the linked project’s production host (its active primary production domain, else its custom domain, else its hiveku.com host), the stored format hint, and linked, which is true when the item is bound to a website project and a CMS collection. Since 2026-09-12 it also echoes what the elite rules were given: the row’s journey_stage, content_type, author (null when the account has none), answer_block and faq, the money_page_urls the account has marked, the item’s cluster (id, name, role and pillar URL, or null), the keyword_collision the loader found, the comparison_table rows as { subject, source, checked_at }, the offer named by the item and the account’s cta_stage_table. field can now also be author_id, answer_block or faq. The slug checked is the site entry’s slug once the entry exists, otherwise the library slug.

What it does not do

  • It never blocks. The editor asks Publish anyway? on errors, a decision-stage figure without a source included; the publish route publishes and reports. A check that fails to run yields an empty list, not a failed publish.
  • It checks the item, not the collection. A meta_title that has no home in the collection (no seoTitle or metaTitle field) is dropped at publish time and named in the publish response’s unmapped list. The check cannot see that from the row, so read unmapped as well.
  • It does not read the live page. Word counts, links and images are those of the stored body. The rendered template, the blog index and the deploy are outside its view.
  • Unpublish is not checked. Taking a page down never waits on a meta description.