Quality engine: SEO, accessibility, performance
The quality engine checks every document and reports SEO, accessibility and performance issues, blocking publication when needed.
On this page
01Overview
The quality engine checks a Nucleo document and returns a report with scores, issues and a blocking flag.
The engine composes the document as a page and builds a profile from it: headings, images, links, color pairs, landmarks, anchors, buttons, forms and styles. The rules read this profile, not the HTML.
The check runs on every save and blocks publishing if the report is blocking. An acknowledgement (acknowledge) makes a single issue non-blocking.
The SEO part prints the title, meta description, Open Graph and JSON-LD on single pages, but only when neither Yoast SEO nor Rank Math is active.
All functions in this chapter are Free.
02Running the check
When the check runs
What it does: runs the check on the document in these cases:
- on every save, create, patch, publish and import of a document, including in
dryRun; - when the document is recompiled after a reusable section changes;
- with the ability
nucleo/quality-check; - with the ability
nucleo/doc-validate, if the document is valid; - in the site scan (
nucleo/quality-scan), for the SEO score of each page.
How to use it: no explicit action is needed when saving. To check without saving, use nucleo/quality-check with document or nucleo/doc-validate.
Parameters: None.
Permissions: those of the operation that starts it.
Edition: Free.
Known limits:
- The engine does not run at runtime on the public site.
- The check composes one document only. Header, footer and layout applied to the page are not part of the check. Reusable sections (
nc/section-ref) are included, because the document arrives already expanded. - The document must pass schema validation. If it does not,
nucleo/quality-checkreturnsinvalid_documentwithout a report.
Page profile
What it does: walks the root and the children of the document and collects the data for the rules. It uses the semantics section of each block manifest (heading, image, link, landmark) and some fixed fields of the node.
How to use it: internal. The quality engine uses it.
Parameters:
| Data collected | Source in the node | Used by |
|---|---|---|
| Headings | semantics.heading.levelFrom (integer 1–6) and textFrom | seo.h1.*, seo.heading.*, threshold of a11y.contrast |
| Images | semantics.image.srcFrom, altFrom, decorativeFrom | seo.image.alt.missing, perf.lcp.lazy |
| Links | semantics.link.hrefFrom, textFrom; content.ariaLabel or settings.ariaLabel | a11y.link.empty |
| Landmarks | semantics.landmark.tagFrom, or the fixed tag with the optional ifFrom | a11y.landmark.missing |
| Color pairs | style.color and style.background (per breakpoint) | a11y.contrast |
| Conditions | visibility.when[].is | visibility.logged_in |
| Anchors | settings.anchor (no spaces at the edges) | a11y.anchor.duplicate |
| Fixed position | style.position equal to fixed in at least one breakpoint | layout.fixed.z_index |
| Buttons | nc/button nodes: content.text, settings.href | content.button.inert |
| Styles | every string value of style, including per breakpoint | style.token.literal |
| Forms | nc/native-form nodes with external actions | form.* |
Permissions: None.
Edition: Free.
Known limits:
- The estimated LCP image is the first non-decorative image. The profile marks it with
lcp: trueandlazy: false. All other images havelazy: true. - The
visiblefield of headings always stays empty: the profile does not compute visibility per breakpoint. - An
nc/buttonwithbindonsettings.hrefis not part of the button list.
Severity and publish blocking
What it does: each issue has a severity: error, warning or info. An issue can also have publishBlocking. The report is blocking if there is at least one open issue with severity error or with publishBlocking.
How to use it: read quality.blocking in the response.
| Severity | Draft | Publishing, or saving an already published post | Score |
|---|---|---|---|
error | saves | fails with quality_blocking, unless acknowledge | −20 |
warning with blocksPublish | saves | fails with quality_blocking, unless acknowledge | −5 |
warning | saves | succeeds | −5 |
info | saves | succeeds | 0 |
Parameters: None.
Permissions: None.
Edition: Free.
Known limits:
- Saving a post with status
publish,futureorprivatecounts as publishing: the blocking check applies even withoutpublish. - A draft Nucleo page with a blocking report does not go online from the WordPress editor either: block editor, classic editor, page panel or scheduling. The REST API responds
quality_blocking. The classic editor keeps the previous status. - When the document is recompiled after a section changes, a blocking report on a published post keeps the previous version online. Nucleo keeps the issues on the post and returns
quality_blocking. The kept issues are cleared at the next successful save. - The error message is "Blocking quality issues: fix them or acknowledge them.".
Score
What it does: computes three scores from 0 to 100: seo, a11y and perf. It starts from 100 and subtracts 20 for each open error and 5 for each open warning. info issues are worth 0. The minimum is 0.
How to use it: read quality.score.
Parameters: None.
Permissions: None.
Edition: Free.
Known limits:
- The category depends on the prefix of the rule code:
seo.,a11y.orperf.. Rules with other prefixes (content.,form.,style.,layout.,visibility.,links.) do not affect any score. - Issues acknowledged with
acknowledgedo not count.
Report format
What it does: returns the quality object of the response.
How to use it:
{
"quality": {
"score": { "seo": 75, "a11y": 100, "perf": 100 },
"blocking": true,
"issues": [
{
"rule": "seo.h1.multiple",
"severity": "error",
"nodes": [ { "doc": "page:120", "id": "hero" }, { "doc": "page:120", "id": "sub" } ],
"message": "The composed page contains 2 h1: hero in page:120, sub in page:120.",
"fix": [ { "op": "set", "doc": "page:120", "id": "sub", "path": "settings.level", "value": 2 } ]
}
]
}
}Parameters:
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
rule | string | rule code | — | Use it in acknowledge. |
severity | string | error, warning, info | — | |
nodes | array | {doc, id} | [] | Empty for page rules (seo.h1.missing, a11y.landmark.missing) and for post checks, which use {doc: "page:<id>", id: "meta"}. |
message | string | — | — | Translated, except where the rules say otherwise. |
fix | array | patch operations with an extra doc key | [] | Today only seo.h1.multiple fills it. |
guide | string | R1.2, R1.5, R2.3, R4.4, R4.5 | absent | Present only for rules linked to the phase guide. |
blocksPublish | boolean | true | absent | Present only for warning issues with publishBlocking. |
Permissions: None.
Edition: Free.
Known limits: quality.issues contains only open issues. Acknowledged issues do not appear in the response.
Acknowledgement (acknowledge)
What it does: makes an issue non-blocking. The acknowledgement states the rule, the node and the reason. The engine accepts it if rule matches, node is one of the id values in the issue's nodes, and reason is not empty after trimming the edge spaces.
How to use it: pass acknowledge to the write abilities (nucleo/doc-publish, nucleo/doc-patch, nucleo/doc-create, nucleo/site-import) or to nucleo/quality-check. From the local CLI, use nucleo push --acknowledge FILE.
{ "acknowledge": [ { "rule": "form.action.consequential", "node": "f1", "reason": "The user authorized the webhook on October 30" } ] }Parameters:
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
rule | string | rule code | — | Required. |
node | string | id of a node of the issue | — | Required. An acknowledgement on the nc/section-ref node applies to the expanded nodes of the section. |
reason | string | not empty | — | Required. |
Permissions: those of the ability that receives it.
Edition: Free.
Known limits:
- Nucleo keeps the acknowledgements used on the post, with a fingerprint (SHA-1) of the node without children. They apply to later saves, imports and recompilations.
- An acknowledgement expires if the node changes (type, content, settings, style), if the node disappears, or if the issue no longer exists.
- For the same rule and node pair, the most recent acknowledgement wins.
- Issues without nodes (
seo.h1.missing,a11y.landmark.missing) cannot be acknowledged: they stay blocking until fixed. - The rules listed in
nonForcible(optionnucleo_quality, settable withnucleo/settings-setin thequalitygroup, up to 50 ids) cannot be acknowledged: they stay blocking. The list is empty by default, so every rule is forcible. - Acknowledgements are not written to the activity log.
Rules by document type
What it does: removes some rules from the report when the document is not a page.
How to use it: automatic, based on kind.
kind | Rules removed |
|---|---|
layout, popup | seo.h1.missing, a11y.landmark.missing, seo.content.thin |
section | the three above plus content.button.inert, form.action.consequential |
Parameters: None.
Permissions: None.
Edition: Free.
Known limits: nucleo/quality-check adds the post checks only with postId and only if kind is not layout, section or popup.
Third-party rules
What it does: the nucleo/quality/rules filter receives the list of rules and can add or remove some. Nucleo discards the items that do not implement the Nucleo\Quality\Rule interface.
How to use it:
add_filter('nucleo/quality/rules', function (array $rules): array {
$rules[] = new MyRule(); // implements id(), severity(), check(ComposedPage $page): array
return $rules;
});Parameters: the Rule interface has id(): string, severity(): Severity and check(ComposedPage $page): array, that is, a list of issues.
Permissions: None.
Edition: Free.
Known limits: the engine is built once per request. The filter must be added before the first check.
03Rules
Summary table. "Point" shows where the issue comes from: engine (on every save), sections (on every save, for reusable sections), post (only in nucleo/quality-check with postId and in the scan), site (only in the scan).
| Code | Severity | Category | Blocks | Point | What it checks |
|---|---|---|---|---|---|
seo.h1.missing | error | seo | yes | engine | No h1 |
seo.h1.multiple | error | seo | yes | engine | More than one h1 |
seo.heading.skip | warning | seo | no | engine | Level jump between headings |
seo.heading.empty | error | seo | yes | engine | Heading without text |
seo.image.alt.missing | warning | seo | no | engine | Non-decorative image without alt |
a11y.contrast | error | a11y | yes | engine | Contrast below 4.5:1 or 3:1 |
a11y.link.empty | error | a11y | yes | engine | Link without accessible text |
a11y.anchor.duplicate | warning | a11y | no | engine | Same anchor on several nodes |
a11y.landmark.missing | warning | a11y | no | engine | Missing main landmark |
perf.lcp.lazy | error | perf | yes | engine | LCP image with loading="lazy" |
visibility.logged_in | info | — | no | engine | Content visible only to logged-in users |
layout.fixed.z_index | warning | — | no | engine | position: fixed without zIndex |
content.button.inert | warning + blocksPublish | — | yes | engine | Button without link or action |
form.action.consequential | warning + blocksPublish | — | yes | engine | Form with external actions |
form.recipient.invalid | warning | — | no | engine | Invalid email recipient of the form |
a11y.button.as-link | warning | a11y | no | engine | Link without a real destination (empty href, # or javascript:) or <a> without href in the texts |
a11y.focus.hidden | error | a11y | yes | engine | Focus style that hides the focus indicator (outline none, transparent or alpha 0) |
a11y.target.size | warning | a11y | no | engine | Button or icon smaller than 24 px at the base breakpoint |
a11y.lang.missing | warning | a11y | no | engine | English text on a site in another language, without settings.lang. Threshold: at least 8 words, 3 distinct stopwords, at least 25% |
a11y.video.autoplay | warning | a11y | no | engine | Video that starts by itself without being muted (mute=1 missing) |
seo.link.text.generic | warning | seo | no | engine | Generic link text ("click here", "read more", "here"...) without aria-label |
seo.image.filename | info | seo | no | engine | Generic (for example img, DSC) or hexadecimal image file name |
seo.heading.style-only | info | seo | no | engine | Short text with large font (24 px or 1.5 rem) and no heading, or a heading longer than 150 characters |
seo.hidden.content | warning | seo | no | engine | Heading or text (at least 20 words) hidden on some breakpoints and visible on others |
perf.html.block | info | perf | no | engine | nc/html or nc/shortcode block: free code that the theme does not optimise |
perf.dom.size | warning | perf | no | engine | Estimated DOM elements of the page above 1500 (one issue per page) |
perf.css.budget | warning | perf | no | engine | CSS served to the page above 75 KB |
perf.js.budget | warning | perf | no | engine | JS served to the page above 100 KB |
perf.font.count | warning | perf | no | engine | More than 2 font families or more than 4 variants on the site (one issue per site) |
perf.image.oversize | warning | perf | no | engine | Image more than twice as wide as it is shown, without srcset |
perf.embed.no-facade | warning | perf | no | engine | iframe above the fold without a facade |
ds.layout.conflict | warning | — | no | engine | Two or more layouts with the same area and the same conditions |
style.token.literal | error | — | yes | engine | Style value equal to a token |
content.link.missing | warning | — | no | sections | Reference to content missing or not published |
content.link.path | info | — | no | sections | Internal link written as a path |
content.section.missing | warning | — | no | sections | Reusable section missing or in the trash |
seo.title.length | warning | seo | no | post | Title outside 30–60 characters |
seo.description.missing | warning | seo | no | post | Meta description missing |
seo.description.length | info | seo | no | post | Meta description outside 70–160 characters |
seo.content.thin | warning | seo | no | post | Fewer than 300 words |
seo.noindex | warning | seo | no | post | Page excluded from search engines |
seo.canonical.external | warning | seo | no | post | Canonical pointing to another host |
seo.links.internal.none | info | seo | no | post | No internal link in the content |
seo.title.missing | warning | seo | no | post | SEO title missing |
seo.h1.title-mismatch | info | seo | no | post | First h1 different from the SEO title |
seo.noindex.important | error | seo | yes | post | Page marked noindex but present in the main menu |
seo.canonical.conflict | warning | seo | no | post | Canonical pointing to another page of the site |
seo.title.duplicate | warning | seo | no | site | Same title on several pages |
seo.h1.duplicate | warning | seo | no | site | Same h1 text on several pages |
links.orphan | warning | — | no | site | Page that no other document links to |
seo.hreflang.missing | warning | seo | no | site | Pages missing a translation in at least one language |
seo.schema.duplicate | error | seo | no | site | The SEO plugin and Nucleo both print a BreadcrumbList on the same page |
seo.duplicate.section | warning | seo | no | site | Section repeated on the page, shown only on small screens and only on large ones: content is duplicated |
seo.h1.missing
Code: seo.h1.missing. Severity: error. Category: seo.
What it checks: no level 1 heading in the composed document.
Thresholds: at least one h1.
How to fix it: set the level of a heading to 1 (settings.level for nc/heading).
Known limits: the issue has no nodes, so it cannot be acknowledged. It does not apply to layout, section and popup.
seo.h1.multiple
Code: seo.h1.multiple. Severity: error. Category: seo.
What it checks: more than one level 1 heading. The issue lists all the h1 in nodes.
Thresholds: at most one h1.
How to fix it: apply the fix. The fix keeps the first h1 of a page: document (or the first one overall) and sets settings.level to 2 on the others. Alternatively, acknowledge the issue on one of the nodes.
Known limits: if an h1 is inside a reusable section, the report removes the fix and adds "(inside a reusable section)" to the message.
seo.heading.skip
Code: seo.heading.skip. Severity: warning. Category: seo.
What it checks: two consecutive headings in document order where the level goes up by more than one (for example h2 followed by h4). A level going down does not count.
Thresholds: maximum step +1.
How to fix it: correct the level of the second heading.
Known limits: the message is fixed in Italian, not translated ("Salto di livello: da hX a hY.").
seo.heading.empty
Code: seo.heading.empty. Severity: error. Category: seo.
What it checks: heading with empty text after removing the tags and the spaces at the edges.
Thresholds: none.
How to fix it: write the heading text or remove the node.
Known limits: none known.
seo.image.alt.missing
Code: seo.image.alt.missing. Severity: warning. Category: seo.
What it checks: non-decorative image with a missing alt, or an alt made only of spaces.
Thresholds: none.
How to fix it: write the alt, or mark the image as decorative in the field indicated by semantics.image.decorativeFrom of the block.
Known limits: none known.
a11y.contrast
Code: a11y.contrast. Severity: error. Category: a11y.
What it checks: WCAG 2.x contrast ratio between the style.color of the node and the background. The background is the style.background of the node or of the nearest ancestor. Without a declared background, the token $color.background applies.
Thresholds:
- 4.5:1 for text;
- 3:1 for level 1 and 2 headings;
- 3:1 for
nc/iconwith a label (content.labelnot empty or bound withbind), according to WCAG 1.4.11.
How to fix it: change the color or the background. The message gives the two colors, the ratio and the minimum.
Known limits:
- The check creates one pair for every breakpoint where the color or the background changes. A breakpoint without a value inherits the base value.
- It accepts only
#rgbor#rrggbbcolors, also as resolved tokens. Values such asrgba(),var()or gradients produce no issues. - It does not check blocks without text:
nc/iconwithout label,nc/image,nc/divider,nc/spacer,nc/video,nc/post-image. - For
nc/containerandnc/sectionthe check looks at the children. A child with its own color has its own pair. - The message is fixed in Italian, not translated.
a11y.link.empty
Code: a11y.link.empty. Severity: error. Category: a11y.
What it checks: node with link semantics, without visible text and without ariaLabel in content or settings.
Thresholds: none.
How to fix it: write the link text or add content.ariaLabel.
Known limits: none known.
a11y.anchor.duplicate
Code: a11y.anchor.duplicate. Severity: warning. Category: a11y.
What it checks: the same value of settings.anchor on two or more nodes. A typical case is a section with an anchor used twice.
Thresholds: one node per anchor.
How to fix it: make each anchor unique.
Known limits: none known.
a11y.landmark.missing
Code: a11y.landmark.missing. Severity: warning. Category: a11y.
What it checks: the composed page, with its layouts, has no main, nav or footer landmark.
Thresholds: at least one main, one nav and one footer.
How to fix it: set the main tag on the main container, in the field indicated by semantics.landmark.tagFrom. An nc/menu with a menu is a nav. The footer layout usually gives the footer.
Known limits: the issue has no nodes. It does not apply to layout, section and popup.
perf.lcp.lazy
Code: perf.lcp.lazy. Severity: error. Category: perf.
What it checks: image marked as LCP and as lazy.
Thresholds: none.
How to fix it: no change to the document is needed with the current profile.
Known limits: the profile always marks the LCP image with lazy: false. With the Nucleo engine the rule produces no issues. It fires only with a profile built in another way, as in the tests.
visibility.logged_in
Code: visibility.logged_in. Severity: info. Category: none (does not affect the scores).
What it checks: node with visibility.when that contains is: "logged_in" or is: "role:<role>". The content stays hidden from anonymous visitors and search engines.
Thresholds: none.
How to fix it: it is information. Check that the content is not needed for indexing.
Known limits: the message is fixed in Italian, not translated.
layout.fixed.z_index
Code: layout.fixed.z_index. Severity: warning. Category: none.
What it checks: style.position equal to fixed in at least one breakpoint, without the style.zIndex key.
Thresholds: none.
How to fix it: add style.zIndex.
Known limits: the presence of the zIndex key is enough, whatever its value.
content.button.inert
Code: content.button.inert. Severity: warning with blocksPublish. Category: none. Guide: R4.5.
What it checks: nc/button with settings.href empty, equal to # or discarded by the renderer (for example javascript:).
Thresholds: none.
How to fix it: add an href (for example page:<slug> or home) or use the right interactive block. The message suggests the block based on whole words in the text, in this order:
| Suggested block | Words (stems, case insensitive) |
|---|---|
nc/slider | text made only of arrows (← → ‹ › « » < >) |
nc/collection | filtr…, filter(s), ordina…, sort, azzera…, reset, clear, carica altr…, load more, mostra altr…, show more |
nc/tabs | tab(s), scheda, schede |
nc/accordion | faq, domand…, espandi, expand, open |
nc/slider | prev, previous, next, precedent…, successiv… |
nc/search | cerc…, search |
nc/native-form | invia…, send, iscriv…, subscribe, contatta…, contact, prenota…, book |
Known limits:
- An
hrefbound withbindexcludes the button from the check. - It does not apply to
sectiondocuments. - A button handled by a script through attributes is not recognized. In that case
acknowledgeis needed.
form.action.consequential
Code: form.action.consequential. Severity: warning with blocksPublish. Category: none. Guide: R4.4.
What it checks: nc/native-form with at least one external action in settings.actions: webhook, create-post, component, or email with to not empty and not admin.
Thresholds: none.
How to fix it: ask the user for consent. Then publish with acknowledge on the form node and write the consent in reason. The message also asks for a test submission.
Known limits: it does not apply to section documents. The acknowledgement expires when the form node changes.
form.recipient.invalid
Code: form.recipient.invalid. Severity: warning. Category: none.
What it checks: recipient of an email action of nc/native-form that is not admin, not a valid email address, and not field:<name>.
Thresholds: the <name> of field: follows ^[a-z][a-z0-9_]{0,31}$.
How to fix it: use admin, a valid address or field:<name> of an email field.
Known limits: it does not block publishing.
style.token.literal
Code: style.token.literal. Severity: error. Category: none. Guide: R1.2.
What it checks: string value of style equal to the value of a token of the property group (colors, spacing, radii, shadows, fonts). The comparison is on the whole value.
Thresholds: one issue per node, property and value, even if the value repeats across breakpoints.
How to fix it: write the token reference shown in the message (for example $color.primary). From the local CLI, nucleo push --use-tokens replaces the values in the files.
Known limits: the rule is active only if the engine receives the site tokens. Nucleo always passes them.
content.link.missing, content.link.path, content.section.missing
Codes and severity: content.link.missing (warning, guide R2.3), content.link.path (info, guide R2.3), content.section.missing (warning, guide R1.5). Category: none.
What they check:
content.link.missing: an internal reference (for examplepage:contact) points to content that is missing or not published. The link is rendered as#witharia-disabled.content.link.path: an internal link written as a path (/contact) has an equivalent reference.content.section.missing: annc/section-refcites a section that is missing or in the trash. The block renders nothing.
Thresholds: none.
How to fix them: publish the content or correct the reference; write the suggested reference shown in the message; create or restore the section.
Known limits: they do not come from the engine rules and do not pass through the nucleo/quality/rules filter. Nucleo adds them on every save, in nucleo/quality-check and in nucleo/doc-validate.
seo.title.length
Code: seo.title.length. Severity: warning. Category: seo.
What it checks: length in characters of the SEO title read by Nucleo (see Reading the SEO meta).
Thresholds: from 30 to 60 characters.
How to fix it: without an SEO plugin, write meta.seo.title of the document. With Yoast SEO or Rank Math, edit the title in the plugin.
Known limits: without an SEO plugin and without meta.seo.title, the post title is used. The issue uses the meta node of the document page:<id>.
seo.description.missing, seo.description.length
Codes: seo.description.missing (severity warning), seo.description.length (severity info). Category: seo.
What they check: meta description missing; otherwise, length outside the threshold.
Thresholds: from 70 to 160 characters.
How to fix them: write meta.seo.description, or the description in the active SEO plugin.
Known limits: the document schema accepts meta.seo.description up to 170 characters, beyond the recommended threshold.
seo.content.thin
Code: seo.content.thin. Severity: warning. Category: seo.
What it checks: number of words in the page content (compiled HTML) without tags, with entities decoded.
Thresholds: at least 300 words. Fixed value.
How to fix it: add text.
Known limits: the threshold does not change by content type. It does not apply to layout, section and popup.
seo.noindex
Code: seo.noindex. Severity: warning. Category: seo.
What it checks: the noindex value read by Nucleo (see Reading the SEO meta).
Thresholds: none.
How to fix it: remove the noindex in the SEO plugin, or in the WordPress reading settings.
Known limits: without an SEO plugin, noindex is true if the option blog_public is '0' (a site that discourages search engines). meta.seo.noindex of the document does not activate the rule. With Yoast SEO the rule reads the Yoast noindex; with Rank Math it reads the Rank Math robots setting.
seo.canonical.external
Code: seo.canonical.external. Severity: warning. Category: seo.
What it checks: host of the canonical different from the host of the site, without distinction between uppercase and lowercase.
Thresholds: none.
How to fix it: correct the canonical in the SEO plugin.
Known limits: without an SEO plugin the canonical read is always empty: the rule fires only with Yoast SEO or Rank Math.
seo.links.internal.none
Code: seo.links.internal.none. Severity: info. Category: seo (worth 0 in the score).
What it checks: no internal link in the page content. Internal means same host as the site, or an href without host and without scheme.
Thresholds: at least one internal link.
How to fix it: add a link to another page of the site.
Known limits: an href #anchor or a relative one counts as internal.
seo.title.duplicate, seo.h1.duplicate
Codes: seo.title.duplicate, seo.h1.duplicate. Severity: warning. Category: seo.
What they check: same SEO title, or same h1 text, on two or more published documents. The comparison is made after lowercasing and collapsing spaces.
Thresholds: two pages.
How to fix them: make titles and h1 unique.
Known limits: only in the site scan. The h1 is read from the HTML of the content. The issue lists the pages involved (list of IDs) instead of the nodes.
links.orphan
Code: links.orphan. Severity: warning. Category: none.
What it checks: published document that no other published document links to. The static home page is excluded.
Thresholds: at least one incoming link.
How to fix it: add a link to the page from another document.
Known limits: it reads only the links in the HTML of published Nucleo documents. Menu, header and footer do not count. It accepts only hrefs that start with / or with the home address; it ignores hrefs that start with //.
seo.hreflang.missing
Code: seo.hreflang.missing. Severity: warning. Category: seo.
What it checks: published documents without a translation in at least one language of the site. One issue lists all the pages in posts.
Thresholds: it applies with at least 2 languages.
How to fix it: create the missing translations in the multilingual plugin.
Known limits: the languages come from the supported multilingual plugin and from the nucleo/languages and nucleo/translations filters.
04Site scan
Batch scan
What it does: reads all posts with status publish and a Nucleo document, of every content type, in ID order. It computes the site rules on all documents on each call. It computes the scores only for the pages of the requested batch.
How to use it: ability nucleo/quality-scan with page. Repeat the call with page set to the value of next until next is null. Result:
{
"site": [ { "rule": "links.orphan", "severity": "warning", "message": "Page without internal links from other documents.", "posts": [42] } ],
"pages": [ { "postId": 42, "score": 85, "a11y": 100, "perf": 100 } ],
"next": 2
}Parameters:
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
page | integer | ≥ 1 | 1 | Batch number. |
Permissions: edit_posts. OAuth scope nucleo:read.
Edition: Free.
Known limits:
- The batch has 50 pages. The batch size parameter is not exposed by the ability.
- Each page reports the
seo,a11yandperfscores. It sums the engine rules and the post checks. It does not applyacknowledgeor the limit by document type. - With
page: 1the ability stores for one day, in the transientnucleo_quality_badge, the number of pages involved in site issues with severityerror. Site issues arewarning, exceptseo.schema.duplicate, which iserror.
Checking a document (nucleo/quality-check)
What it does: returns the report of a saved document (postId) or of a document passed as input (document), without writing.
How to use it: see the ability nucleo/quality-check.
Parameters: postId (integer ≥ 1) or document (object), never both; acknowledge (list of {rule, node, reason}).
Permissions: with postId, edit_post on that post; without it, edit_posts. OAuth scope nucleo:read.
Edition: Free.
Known limits:
- Acknowledgements saved on the post do not apply: only those passed in the call count.
- The post checks run only with
postId. - Errors:
invalid_input(neither or both parameters),not_found,invalid_document.
Quality and SEO admin page
What it does: the page runs the batch scan, shows the site issues, the page scores and the detail of a page with nucleo/quality-check.
How to use it: menu Nucleo AI → Quality and SEO.
Parameters: tabs issues, pages, overrides, rules.
Permissions: page edit_posts; starting the scan edit_others_posts. Tabs issues and overrides: edit_others_posts. Tab rules: edit_theme_options. The counter in the menu appears only with edit_others_posts.
Edition: Free.
Known limits:
- The
overridestab shows "Not available yet". - The Accessibility and Performance columns of the
pagestab show thea11yandperfscores from the scan. A missing value stays "—". - With the scan, the
rulestab shows the real severity of each rule. Without the scan it shows a fixed list of ten rules with a "typical" severity, which for four does not match the code:seo.image.alt.missing(error instead of warning),perf.lcp.lazy(warning instead of error),seo.noindex(info instead of warning),seo.links.internal.none(warning instead of info).
05SEO on the page
Reading the SEO meta
What it does: returns title, description, source, noindex and canonical of a post. With Yoast SEO active it reads Yoast; with Rank Math active it reads Rank Math; otherwise it reads meta.seo of the document.
How to use it: internal. The post checks and the site scan use it. The filter nucleo/seo/meta can change the result.
Parameters: None.
Permissions: None.
Edition: Free.
Known limits: without an SEO plugin, canonical is always empty and noindex follows the WordPress setting that discourages search engines (blog_public).
Title, meta description, Open Graph, robots
What it does: on single pages with a Nucleo document and a non-empty meta.seo, it replaces the title of the HTML document and prints these tags in wp_head, with priority 1:
| Tag | Value | When |
|---|---|---|
<meta name="description"> | meta.seo.description | if not empty |
og:title | meta.seo.title, otherwise the post title | always |
og:description | meta.seo.description | if not empty |
og:url | permalink | always |
og:type | website | always |
og:image | meta.seo.image | if not empty |
<meta name="robots" content="noindex"> | — | if meta.seo.noindex is true |
How to use it: fill in meta.seo in the document.
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
meta.seo.title | string | max 70 characters | post title | |
meta.seo.description | string | max 170 characters | — | |
meta.seo.image | string | URI | — | |
meta.seo.noindex | boolean | false | Also excludes from the sitemap. |
Permissions: None (frontend).
Edition: Free.
Known limits:
- It turns off if Yoast SEO or Rank Math is active.
- The canonical stays with WordPress: Nucleo does not print it.
- No Twitter Card tag.
og:typeis alwayswebsite. - Archives and the blog home do not receive these tags: they apply only to single pages.
Page JSON-LD
What it does: prints in the page footer a single application/ld+json script with @context and @graph. The graph contains, in order:
- the nodes of
meta.seo.schema, withurlequal to the permalink if missing; the value"@business", at any depth, becomes{"@id": "<home>/#business"}; - the company node, on the static home page or if a node uses
"@business"; - the nodes of the
ld+jsonscripts present in the content, removed from the HTML; a script with@graphadds each node of its own graph; - an automatic
BreadcrumbList.
How to use it: fill in meta.seo.schema and, if needed, meta.seo.breadcrumbs.
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
meta.seo.schema | array of objects | max 10 nodes; @type required with pattern ^[A-Z][A-Za-z]{2,60}$ | [] | |
meta.seo.breadcrumbs | boolean | false | — | false removes the automatic breadcrumb. |
Permissions: None (frontend).
Edition: Free.
Known limits:
- It turns off if Yoast SEO or Rank Math is active.
- It runs on every single page, even without
meta.seo: the graph may contain only the breadcrumb. - Breadcrumb: site name, ancestors of the post, page. It is missing on the static home page and if the content already contains a
BreadcrumbList. - Nucleo merges the nodes: it removes identical nodes and merges all the
FAQPagenodes into one. - Scripts with invalid JSON stay in the content.
- The output escapes
<and>: a</script>in the texts does not close the script.
JSON-LD consolidation at compile time
What it does: when the document is compiled, it removes the valid ld+json scripts from the HTML and adds one single script with @graph at the end. It merges the FAQPage nodes (for example two nc/accordion) and removes identical nodes.
How to use it: automatic, at compile time. The filter nucleo/schema/skip_types receives the list of @type values to exclude.
Parameters: None.
Permissions: None.
Edition: Free.
Known limits:
- With Yoast SEO or Rank Math active, the default list excludes
WebPage,WebSite,OrganizationandBreadcrumbList. Without an SEO plugin the list is empty. - The exclusion applies at compile time. If you activate an SEO plugin later, documents already compiled stay unchanged until the next save.
- Scripts with invalid JSON, or with a list at the top level, stay where they are.
Company data
What it does: saves the company data in the option nucleo_business and derives from it the schema.org node for the JSON-LD. The node has @id <home>/#business, url defaulting to the home page, address with @type: PostalAddress and geo with @type: GeoCoordinates.
How to use it: key business of nucleo/site-options-set. null deletes the data.
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
name | string | 1–300 characters | — | Required. |
@type | string | ^[A-Z][A-Za-z]{2,60}$ | LocalBusiness | |
legalName, vatID, taxID, description, telephone, email, priceRange | string | 1–300 characters | — | |
url, logo, image | string | URL http or https | url = home | |
sameAs | array | URL http or https | — | A single string is not valid. |
address | object | streetAddress, addressLocality, addressRegion, postalCode, addressCountry (strings) | — | Other keys rejected. |
geo | object | latitude (±90) and longitude (±180), numbers | — | Both required, no other key. |
openingHours | array | Mo-Fr 08:00-18:00 (days Mo…Su, hours 00–23) | — | |
foundingDate | string | YYYY, YYYY-MM or YYYY-MM-DD | — | |
areaServed | string or array of strings | 1–300 characters | — | |
| other keys | camelCase ^[a-z][A-Za-z]{1,40}$ | text, list of texts or object of scalars | — |
Permissions: manage_options. OAuth scope nucleo:admin.
Edition: Free.
Known limits:
- An invalid value returns
invalid_inputwith the name of the key:"<key>" is unknown or has an invalid value.. - The node appears only where Nucleo JSON-LD is active, so not with Yoast SEO or Rank Math.
Sitemap
What it does: Nucleo excludes from the WordPress sitemap the posts with meta.seo.noindex set to true. It updates the exclusion on every save and recompilation.
How to use it: set meta.seo.noindex: true in the document.
Parameters: None.
Permissions: None.
Edition: Free.
Known limits:
- The exclusion applies only to public sitemap requests, and only without Yoast SEO or Rank Math.
- Nucleo does not generate its own sitemap and does not change
robots.txt. The only robots control is thenoindexmeta of the page.
Coexistence with Yoast SEO and Rank Math
What it does: Nucleo detects the active SEO plugin. With a plugin active:
- Nucleo does not print title, description, Open Graph or robots;
- it does not print the page JSON-LD and does not remove the scripts from the content;
- in the compiled JSON-LD it excludes
WebPage,WebSite,OrganizationandBreadcrumbList; - it does not apply the sitemap exclusion;
- the post checks and the scan read title, description, noindex and canonical from the plugin.
How to use it: automatic.
Parameters: None.
Permissions: None.
Edition: Free.
Known limits: if both plugins are active, Yoast SEO wins.
Was this page helpful?