From 30ee869937a3ce39e6f502c27c5db0c7cfffe943 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 18 Aug 2026 14:19:37 +0200 Subject: [PATCH] fix: omit unlinked breadcrumb crumbs from JSON-LD Google Search Console flags BreadcrumbList entries missing the required `item` field. Non-page section nodes (page: false) have no URL, so drop them from structured data and always emit `item` via breadcrumbListLd. --- app/pages/[...slug].vue | 21 ++++++++------------- app/utils/json-ld.ts | 23 +++++++++++++++++++++++ test/og.test.ts | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue index 929fcc1..8b3c78c 100644 --- a/app/pages/[...slug].vue +++ b/app/pages/[...slug].vue @@ -85,19 +85,14 @@ if (content.value.mode === 'prod') { }, author: { '@type': 'Organization', name: seo?.siteName, url: site.url }, }, - { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: [ - { '@type': 'ListItem', position: 1, name: 'Docs', item: site.url }, - ...breadcrumb.value.map((item, index) => ({ - '@type': 'ListItem', - position: index + 2, - name: item.title, - ...(item.path ? { item: joinURL(site.url, item.path) } : {}), - })), - ], - }, + // Google requires `item` on every ListItem. Non-page section nodes + // (page: false) only have a title — omit them from structured data. + breadcrumbListLd([ + { name: 'Docs', item: site.url }, + ...breadcrumb.value + .filter((item): item is { title: string, path: string } => Boolean(item.path)) + .map((item) => ({ name: item.title, item: joinURL(site.url, item.path) })), + ]), ]) ), }, diff --git a/app/utils/json-ld.ts b/app/utils/json-ld.ts index da07164..94a2360 100644 --- a/app/utils/json-ld.ts +++ b/app/utils/json-ld.ts @@ -7,3 +7,26 @@ export function jsonLd(value: unknown): string { return JSON.stringify(value).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026') } + +export interface BreadcrumbListEntry { + name: string + /** Absolute URL. Required by Google for every BreadcrumbList ListItem. */ + item: string +} + +/** + * Build a schema.org BreadcrumbList. Every entry must carry an absolute `item` URL — Google rejects + * crumbs that only have a name (e.g. non-page section nodes in the docs nav). + */ +export function breadcrumbListLd(entries: BreadcrumbListEntry[]) { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: entries.map((entry, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: entry.name, + item: entry.item, + })), + } +} diff --git a/test/og.test.ts b/test/og.test.ts index 5092e66..e7cbd5e 100644 --- a/test/og.test.ts +++ b/test/og.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { truncate, withAlpha } from '../app/utils/og' -import { jsonLd } from '../app/utils/json-ld' +import { breadcrumbListLd, jsonLd } from '../app/utils/json-ld' describe('withAlpha', () => { it('expands 6-digit hex', () => { @@ -60,3 +60,35 @@ describe('jsonLd', () => { expect(JSON.parse(output).name).toBe('A & B') }) }) + +describe('breadcrumbListLd', () => { + it('emits position + item on every ListItem (Google requires item)', () => { + expect( + breadcrumbListLd([ + { name: 'Docs', item: 'https://comark.dev' }, + { name: 'Introduction', item: 'https://comark.dev/getting-started/introduction' }, + ]) + ).toEqual({ + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Docs', item: 'https://comark.dev' }, + { + '@type': 'ListItem', + position: 2, + name: 'Introduction', + item: 'https://comark.dev/getting-started/introduction', + }, + ], + }) + }) + + it('never omits item, even for a single root crumb', () => { + const list = breadcrumbListLd([{ name: 'Docs', item: 'https://comark.dev/' }]) + for (const entry of list.itemListElement) { + expect(entry).toHaveProperty('item') + expect(typeof entry.item).toBe('string') + expect(entry.item.length).toBeGreaterThan(0) + } + }) +})