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

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.
context is what the check was given, so a client can re-run it locally on edits without another request: the target keyword from settings.target_keyword, 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. 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; 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.