# Your Speakable schema validates and Google finds nothing: three selector mistakes

A SpeakableSpecification can pass every validator and still match nothing on the page. The Mega Analyzer flags head selectors, missing classes and the XPath class idiom.

Author: J.A. Watte
Published: September 21, 2026
Source: https://jwatte.com/blog/blog-mega-analyzer-speakable-selector-validation/

---

The Mega Analyzer row reads **SpeakableSpecification has invalid selector(s) (N)**, and it only appears when something is wrong. What it is really measuring is whether the strings inside your `cssSelector` and `xpath` arrays point at anything a reader can see on the fetched page. A Speakable block is a list of pointers, and a pointer that resolves to nothing is not a minor warning: it is the whole feature switched off while every validator you own says green.

## What the check actually tests

The row lives on the Schema tab, inside the Entity anchors block. There is no pass row: when the selectors are clean the block stays silent, and whether you have Speakable at all is covered by the separate **Speakable schema** row (a real check on editorial sites, an info line titled **Speakable schema (optional for non-editorial sites)** everywhere else).

First the analyzer walks every JSON-LD script on the page, recursing into `@graph` arrays and every nested property value, and collects each node whose `@type` is or includes `SpeakableSpecification`. It does not matter whether the node sits inside `WebPage.speakable`, `Article.speakable`, or somewhere odd. `cssSelector` and `xpath` are each accepted as a single string or an array. Then three detectors run over every entry.

**Head selector**: the trimmed `cssSelector` entry begins with `meta`, `link`, `title` or `head` as a whole word, case-insensitive. So `meta[name="description"]`, `link[rel="canonical"]`, `title` and `head > title` all trip it. `header`, `.title` and `#heading` do not, because the word has to end there and a leading dot or hash is not a match. The row's reason reads "head-element selector" followed by "Speakable requires visible body content, not head metadata".

**Missing class**: this runs only when the entry is exactly one class selector, a dot followed by letters, digits, underscores or hyphens and nothing else. The analyzer parses the HTML exactly as the server returned it with `DOMParser` and calls `querySelector` on that document; no JavaScript is executed. If nothing matches, the reason reads "class .something does not exist in the rendered DOM" and warns that Google's validator will report no matches. What does not trip it: `h1`, `#summary`, `p.speakable`, `.hero p`, `article > p:first-of-type`. None of those are resolved at all. The check is deliberately narrow so it never guesses, which means a clean row proves that none of the three known mistakes is present, not that every selector matches.

**XPath class idiom**: any `xpath` entry containing `contains(concat(' ', normalize-space(@class)` with single quotes trips it. `//h1` and `//*[@id='summary']` pass untouched. The reason names it as the standard XPath 1.0 word-boundary pattern, says Google's parser mangles it, and tells you to drop `xpath` and use `cssSelector` alone.

The row lists each offending expression truncated to 80 characters, its reason, and a Fix paragraph. N counts expressions, not nodes, so the same bad selector in two Speakable nodes counts twice. The detector is wrapped in a try/catch and skips silently if anything throws. JSON-LD is parsed one script block at a time, so a block that fails to parse is dropped and counted in the Parse errors tile at the top of the Schema tab; a Speakable node inside that block is invisible to this row, while nodes in the blocks that did parse are still checked.

## Why it matters

schema.org defines `cssSelector` as `CssSelectorType` and `xpath` as `XPathType`, both of them text, and describes a SpeakableSpecification as something that "indicates (typically via xpath or cssSelector) sections of a document that are highlighted as particularly speakable." Nothing in the vocabulary says the target has to exist, so the schema.org validator accepts any string you give it.

Google's Speakable documentation defines `cssSelector` as addressing "content in the annotated pages (such as class attribute)" and says, for both properties, "Use either cssSelector or xPath; don't use both." The feature is in beta and, in Google's words, "works for users in the U.S. that have Google Home devices set to English, and publishers that publish content in English." Google's general structured data policy adds the sentence that rules out head selectors: "Don't mark up content that is not visible to readers of the page." A meta description and the `<title>` element are tab text and a search snippet, not visible page content, so pointing Speakable at them marks up text no reader can see.

The missing-class case surprises people because it was correct when it shipped. Speakable blocks almost always live in a base template, so one layout carries the same `cssSelector` array onto every page and, for multi-site operators, onto every site. A redesign renames `.post-summary` to `.summary`, or a template copied from another property brings its class names along, and from that deploy forward every page points at a class that does not exist. The build passes. The validator passes.

The XPath idiom is the cruelest of the three because it is the textbook answer. XPath 1.0 has no function for matching one class token, and `contains(@class, 'hero')` also matches `hero-wide` and `superhero`. The correct XPath 1.0 form pads the class attribute with spaces, normalizes it, and searches for the padded token: `contains(concat(' ', normalize-space(@class), ' '), ' hero ')`. People who know XPath write it that way on purpose.

I found all three on one client site I audited right after a deploy. The schema.org validator passed the page. Google's validator reported no matches for the renamed class, and on the XPath entry it echoed the expression back with a trailing `and ]` that did not exist in the source, then reported no matches for that too. I cannot point you at documentation for that behavior; it is what the tool printed, twice. Removing the `xpath` array and keeping only `cssSelector` cleared it. The analyzer row exists so the next person catches this before Google does.

## How to fix it

Delete the `xpath` array entirely. Google says to use one property or the other, and `cssSelector` is the one that takes a plain class name with no parser risk. Then point every selector at visible body elements. This is the shape the site's tools emit, with the `speakable` property inside the page's own page-type node:

```json
{
  "@context": "https://schema.org",
  "@type": "WebPage",
  "@id": "https://example.com/guide/#webpage",
  "url": "https://example.com/guide/",
  "name": "How long a Speakable answer should be",
  "speakable": {
    "@type": "SpeakableSpecification",
    "cssSelector": ["h1", ".article-summary", ".speakable"]
  }
}
```

Now give the selectors something to find. Add `class="speakable"` to two to four short, declarative paragraphs that answer the question the page exists for. Google recommends around 20 to 30 seconds per section, roughly two to three sentences, and says to skip datelines, photo captions and source attributions because they sound confusing read aloud.

```html
<h1>How long a Speakable answer should be</h1>
<p class="article-summary">Google recommends 20 to 30 seconds of spoken content per section, about two to three sentences.</p>
<p class="speakable">Point cssSelector at visible body text only. Meta tags and the title element are not visible content and will not be read.</p>
```

Before you deploy, verify every class in the array exists in the rendered HTML. In DevTools, `document.querySelector('.speakable')` should return an element on every page that carries the block; in a static build, grep the output folder for the class name. A CSS purge step removes unused rules from the stylesheet and leaves the class attributes in the HTML alone, so purging will not break this, but a template refactor will. Put the check in your build gate rather than in your memory.

If one layout is shared across several sites, pick selectors that exist everywhere by construction (`h1`, or `article > p:first-of-type`), or make the `.speakable` class part of the layout itself so every property inherits it. The analyzer does not resolve compound selectors, so for `article > p:first-of-type` you own the verification.

Keep the `speakable` property inside the single page-type node for the URL, whether that is a `WebPage`, an `Article` or a `ProfilePage`, and do not add a second `WebPage` just to carry it. The [Speakable Generator](/tools/speakable-generator/) emits a standalone `WebPage` node; if your page already has one, merge the `speakable` property into it instead of pasting a sibling, or the analyzer's page-type dilution check will fire on the next run.

The generator is also the fastest way to regenerate a broken block. It fetches the HTML you actually shipped, finds the `h1`, `h2` and `h3` headings and the first paragraph after each, and builds selectors from real ids and class names (`#what-it-costs`, `h2.section-title`, `h2.section-title + p`), so it can only propose selectors that exist on that page. Then run the [Schema Validator](/tools/schema-validator/), which confirms the JSON parses and that the Speakable node carries `cssSelector` or `xpath`, and paste the URL into Google's Rich Results Test to see what Google parsed. The Schema Validator does not resolve selectors against the page; the Mega Analyzer row is the part of the site that does.

## When to leave it alone

If the page has no Speakable block, the row cannot appear and there is nothing to fix. On a service-business homepage the presence row is info-level and the analyzer's own copy says Speakable is optional there, so if a broken block came along with a copied template, deleting the block is a complete fix. Do not add Speakable to a page just to make a row go green.

If your speakable class is added by client-side JavaScript, the row will flag it, because the analyzer reads the server response and never runs scripts. Google's renderer may still resolve it, but the cheap fix is to put the class in the HTML, and that is the fix I would make anyway. Just know why it fired.

An `xpath` entry that is not the class idiom (`//h1`, for example) is not flagged, and Google allows `xpath` on its own as long as `cssSelector` is not also present. `header`, `.title` and `.head-office` are not flagged either, and should not be; they are body content that happens to start with a suspicious word.

Finally, keep the effort proportional. Google documents Speakable as a beta feature scoped to English content and U.S. users with Google Home devices. It is a cheap hint to AI readers that honor it, and the fix above takes about ten minutes. It is not a reason to rebuild your template.

## Fact-check notes and sources

- **Source**: https://developers.google.com/search/docs/appearance/structured-data/speakable establishes the `cssSelector` and `xpath` definitions, the sentence "Use either cssSelector or xPath; don't use both", the beta status, the U.S. and English scope with Google Home devices, the 20 to 30 seconds or two to three sentences guidance, and the advice to avoid datelines, captions and attributions.
- **Source**: https://developers.google.com/search/docs/appearance/structured-data/sd-policies establishes the general rule "Don't mark up content that is not visible to readers of the page," which is why head selectors are treated as invalid.
- **Source**: https://schema.org/SpeakableSpecification establishes the type description and that `cssSelector` is typed `CssSelectorType` and `xpath` is typed `XPathType`; neither the vocabulary nor its validator checks that a selector matches anything.
- **Source**: https://www.w3.org/TR/selectors-4/ establishes what a class selector (section 6.6) and a type selector (section 5.1) match, which is the basis for the analyzer resolving `.classname` with `querySelector`.
- **Source**: https://www.w3.org/TR/1999/REC-xpath-19991116/ defines the `contains()`, `concat()` and `normalize-space()` string functions that the XPath 1.0 class-token idiom is built from.
- **Source**: https://search.google.com/test/rich-results is the Google tool for seeing which structured data Google parsed from a live URL. The trailing `and ]` behavior described above is a first-person observation on a client site, not documented Google behavior.

## Related reading

- [SpeakableSpecification: The Schema Tag That Tells AI What to Quote](/blog/blog-speakable-schema-ai-citations/)
- [Speakable Schema, Generated From The Page You've Already Shipped](/blog/blog-tool-speakable-generator/)
- [Nine AI Mode entity-binding bugs that pass every schema validator](/blog/blog-ai-mode-binding-bugs-validators-miss/)
- [Why Google Rich Results Test only shows FAQ and Breadcrumb on your About page](/blog/blog-rich-results-test-faq-breadcrumb-only/)
- [A Schema.org Validator That Knows Google Rich-Results Requirements Per Type](/blog/blog-tool-schema-validator/)

If you run one layout across a dozen properties, the missing-class failure is the one that will find you, because a single template change breaks every site at once. The build-gate habit that catches it is the same one The $100 Network is built around: verify the shipped HTML, not the template you meant to ship.

*This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.*


---

Canonical HTML: https://jwatte.com/blog/blog-mega-analyzer-speakable-selector-validation/
RSS: https://jwatte.com/feed.xml
JSON Feed: https://jwatte.com/feed.json
Hero image: https://jwatte.com/images/blog-mega-analyzer-speakable-selector-validation.webp
