DEV Community

Jangwook Kim
Jangwook Kim

Posted on • Originally published at jangwook.net

The markup said 'read this aloud' and pointed at 13 paragraphs

article p:first-of-type looks like it selects one paragraph. It selected thirteen.

That was the median across my sample. On the widest page it grabbed twenty-four. And this selector wasn't sitting in a stylesheet where a wrong match would be visible. It was inside structured data, telling any voice assistant that reads my pages which parts to speak.

Two kinds of structured data, two ways to rot

Most structured data carries its own values. You write the title into headline, the date into datePublished, the name into author. Whatever you wrote is what ships, so mistakes surface: an empty title ships empty, a malformed date trips a format error.

speakable doesn't work that way. A SpeakableSpecification holds no text at all. It holds a cssSelector or an xPathan address into the document. The value lives in the DOM; the markup only points at it. The idea is that an author, not a heuristic, decides which sentences a voice surface should read out.

Pointers have a different failure profile from values. When the target disappears, the markup stays perfectly healthy: valid JSON, correct type, all required properties present. Rename one CSS class and the instruction starts aiming at empty space, while your build stays green and every test passes. Nothing in a normal pipeline is watching that relationship. Among the structured data types Google documents, speakable is effectively the only one built this way.

And a pointer can rot in two directions. It can reach nothing, or it can reach far too much. My site had managed both at once.

What Google actually promises about speakable

Before touching anything I reread the speakable documentation. It sets its terms in the first paragraph:

This feature is in beta and subject to change. We're currently developing this feature and you may see changes in requirements or guidelines.

The audience is narrower than most people assume:

The speakable property works for users in the U.S. that have Google Home devices set to English, and publishers that publish content in English.

A one-person technical blog shipping Korean, Japanese, English and Chinese editions is not in that sentence. The selector guidance is one line:

Use either cssSelector or xPath; don't use both.

And the usual disclaimer, which I'd rather quote than paraphrase:

Google does not guarantee that features that consume structured data will show up in search results.

Meanwhile Speakable is still on the supported list in the Search Gallery. Those are separate facts, and blurring them is how a site ends up carrying markup on the theory that it might pay off someday. Worth remembering that Google has been shrinking that list, not growing it: the documentation update log shows a deprecation notice added to the FAQ rich result on 2026-05-08 ("This feature will no longer appear in Google Search starting May 7, 2026.") and the docs removed on 2026-06-15, with practice problem docs deleted back on 2026-01-06. When the FAQ result died I argued for keeping the Q&A markup anyway, because that vocabulary carries its own text and other parsers can still read it. The argument collapses for pointers. An address with nothing at the end of it is nothing, to every reader.

Running the selectors instead of reading them

Here's what my pages had been emitting:

{
  "@type": "SpeakableSpecification",
  "cssSelector": [
    "article h1",
    "article h2",
    "article p:first-of-type",
    ".article-summary"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Title, headings, lede. Clear intent. Intent isn't the thing that ships, though, so I opened the built HTML with jsdom and executed all four. Node 22.22, jsdom 29.1.1, against the dist as of 2026-08-11. Of 1,336 blog pages, 1,332 carried a SpeakableSpecification; I parsed a sample of twenty, five from each language.

Selector Matches per page (median) Total across 20 pages Verdict
article h1 1 24 two h1s on 4 pages
article h2 9 229 every section heading
article p:first-of-type 13 272 over-matching
.article-summary 0 0 matches nothing

The zero has a boring explanation: no component on this site uses that class. Whether it existed once or was only ever planned, I couldn't settle from the commit history. What's certain is that the selector rode along on 1,332 pages without ever pointing at anything.

There's a trap worth naming here. Grep dist for article-summary and it hits on all 1,332 pages, which reads like confirmation. Open one and the string appears exactly once — as the selector's own value inside the JSON-LD.

...akableSpecification","cssSelector":["article h1","article h2",
"article p:first-of-type",".article-summary"]},"url":"https://jangwo...
Enter fullscreen mode Exit fullscreen mode

The pointer shows up in a text search because of its own name. String matching cannot detect this class of rot. Selectors have to be executed.

Nodes reached per page by each speakable cssSelector, measured against the built HTML

Where those 272 paragraphs actually lived

The thirteen interested me more than the zero, so I grouped every matched paragraph by its parent element.

Parent elements of the 272 matched paragraphs; only 20 were the article's own lede

Parent element Paragraphs matched What it really is
li 73 paragraphs inside list items
div.item-content 60 related-post recommendation cards
blockquote 59 pull quotes
header.article-shell__header 20 post header
div.article-prose 20 the actual lede
div.text-center 20 layout chrome
div.flex-1 20 layout chrome

Twenty out of 272 were what I meant. The cause is the selector definition rather than anything exotic: :first-of-type means first sibling of that type under its own parent, not first occurrence in the document. Combine it with a descendant combinator that walks the whole subtree, and every container holding paragraphs contributes one.

The div.item-content row is the one that stung. Those sixty are the blurbs on related-post cards — machine-written navigation copy. Had a voice surface honored this annotation, it would have treated three recommendation blurbs as core spoken content on equal footing with the article's opening sentence. My own thesis was outvoted by my own sidebar.

I recognize the shape of this from auditing text fragment deep links, where 14 of 15 code-block citations broke. Same species of bug. Pointers rarely break when you write them; they break when everything around them moves.

What each tool can and cannot see

The schema.org Schema Markup Validator caught the null selector. Feeding it a live URL returned three objects and exactly one error: NO_MATCHES_FOUND, isSevere: true, naming .article-summary. That's more than syntax checking — the validator runs your selectors against the fetched document, which is a genuinely useful behavior that I suspect most people never exercise.

It said nothing about the thirteen, and it shouldn't have. Matching many nodes is legal; speakable accepts an array and multiple targets are a supported pattern. Nothing here is invalid. It's just wrong.

Failure mode Schema validator Build Text grep What actually catches it
Selector matches 0 nodes caught (severe) no no deploy gate
Selector over-matches no no no a count ceiling you write
Used outside its stated audience no no no human judgment

That third row has no tooling answer. Whether a multilingual personal blog should ship a feature documented for U.S. English Google Home users is a question no linter will settle for you.

The fix, and the assertion that keeps it fixed

Two selectors instead of four, each scoped to hit exactly once:

// src/components/BaseHead.astro
const speakableSchema = articleData ? {
  '@context': 'https://schema.org',
  '@type': 'WebPage',
  'speakable': {
    '@type': 'SpeakableSpecification',
    'cssSelector': ['.article-shell__header h1', '.article-prose > p:first-of-type']
  },
  'url': canonicalURL.toString()
} : null;
Enter fullscreen mode Exit fullscreen mode

The load-bearing change is the child combinator. .article-prose > p:first-of-type only considers direct children of the prose container, so paragraphs nested in lists and blockquotes never enter the candidate set. Re-measured across the same twenty pages:

Selector Pages matched Nodes per page
.article-shell__header h1 20 / 20 1
.article-prose > p:first-of-type 20 / 20 1

Then a postbuild assertion, so this can't silently rot again. Open the output, run the selectors, fail on zero, fail when a paragraph selector blows past a ceiling.

// scripts/validate-speakable.mjs (core)
for (const selector of selectors) {
  const count = dom.window.document.querySelectorAll(selector).length;
  if (count === 0) {
    failures.push(`${file}: "${selector}" matches nothing`);
  } else if (/\bp\b|paragraph/.test(selector) && count > MAX_PARAGRAPH_MATCHES) {
    failures.push(`${file}: "${selector}" matches ${count}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Point it at the pre-fix dist and it fails exactly 40 times: twenty pages times two bad selectors.

❌ validate-speakable failed (40)
  - dist/en/blog/en/45-day-analytics-report-2025-11/index.html:
      "article p:first-of-type" matches 24 (limit 2)
  - dist/en/blog/en/45-day-analytics-report-2025-11/index.html:
      ".article-summary" matches nothing
  ...
Enter fullscreen mode Exit fullscreen mode

When I built the CI validation for JSON-LD, I checked syntax and required properties. That check would have passed this markup every single day, because the syntax was immaculate. Pointer-valued properties need a separate question asked of them: how many nodes does this actually resolve to?

One honest boundary. I'm not claiming this improves my search presence. I'm not an English-language news publisher serving U.S. Google Home users, Google states outright that structured data guarantees nothing about appearing in results, and I have no evidence that LLM crawlers read speakable at all — so I won't write as though they do. What I fixed is the accuracy of a statement my site makes to machines. Two true lines beat 1,332 pages of a false one. The sample is twenty pages, not the full corpus, and I'd rather say so than round up.

Before you ship markup that points instead of carries

  • Search your structured data for cssSelector and xPath first. Properties holding addresses need their own care.
  • Verify selectors by executing them, never by grepping. A selector string always matches itself.
  • Any time you see :first-of-type, :first-child, or a descendant combinator, count the matches. Intent of one and a result in double digits means you want >.
  • The schema validator flags zero matches as severe and waves over-matching straight through. The ceiling is yours to write.
  • When renaming CSS classes, search the JSON-LD too. No linter knows your stylesheet and your structured data share a class name.
  • Read the audience restrictions before adding a type at all. Beta notices and country or language limits usually sit in the first paragraph of the docs.

One thing I haven't resolved: whether keeping those two lines is the right call when I know I'm outside the documented audience. Deleting them removes something to maintain. Keeping them leaves one machine-readable statement that the heart of the page is its title and its opening paragraph. The gate now guarantees that statement is true, which tipped me toward keeping it. Ask me again in six months.

Wiring structured data into deploy gates is the kind of work I take on. Contact routes are on my profile.

Top comments (0)