Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/seo-jsonld-false-positives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@svelte-vitals/core': patch
'@svelte-vitals/vite': patch
---

Refine the JSON-LD rules to cut false positives: SEO018 no longer flags `@id`
(a node identifier, often a relative fragment) and now accepts any URI scheme
(`data:`/`mailto:`/`urn:`) and protocol-relative URLs; SEO019 accepts schema.org
reduced-precision dates (`2026`, `2026-06`); SEO021 treats empty/blank required
values as missing. Rendered-mode capture reads `<script>` via `rawText` so HTML
entities (e.g. `&quot;`) are no longer decoded and the JSON stays intact.
4 changes: 3 additions & 1 deletion docs/src/content/docs/ja/rules/seo018.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ description: JSON-LD の URL は絶対 URL であるべきです。

## チェック内容

JSON-LD の既知の URL キー(`url`・`@id`・`image`・`logo`・`sameAs`・`contentUrl`・`thumbnailUrl`)の相対値を検出します。
JSON-LD の既知の URL キー(`url`・`image`・`logo`・`sameAs`・`contentUrl`・`thumbnailUrl`)の相対値を検出します。URI スキーム(`https:`・`data:`・`mailto:` など)を持つ値やプロトコル相対(`//host/…`)は絶対とみなし、`/logo.png` のようなスキームなしのパスのみを検出します。

`@id` は検査しません。`@id` はノードの識別子であり、同一 `@graph` 内のノードを相互参照する相対フラグメント(例: `#organization`)として使われることが多く、これは壊れた URL ではなく正当なパターンだからです。

## なぜ重要か

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/ja/rules/seo019.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: JSON-LD の日付プロパティは ISO-8601 であるべきです

## チェック内容

既知の日付キー(`datePublished`・`dateModified`・`startDate` など)が ISO-8601 でない値を検出します。
既知の日付キー(`datePublished`・`dateModified`・`startDate` など)が ISO-8601 でない値を検出します。schema.org が許容する縮約精度(年 `2026`、年月 `2026-06`、完全な日付、日時)はいずれも有効として扱います。

## なぜ重要か

Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/rules/seo018.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ description: URLs in JSON-LD should be absolute.

## What it checks

Flags a relative value under a known URL key (`url`, `@id`, `image`, `logo`, `sameAs`, `contentUrl`, `thumbnailUrl`) in JSON-LD.
Flags a relative value under a known URL key (`url`, `image`, `logo`, `sameAs`, `contentUrl`, `thumbnailUrl`) in JSON-LD. A value is considered absolute when it carries a URI scheme (`https:`, `data:`, `mailto:`, …) or is protocol-relative (`//host/…`); only scheme-less paths like `/logo.png` are flagged.

`@id` is **not** checked, because it is a node identifier that is commonly a relative fragment (e.g. `#organization`) cross-referencing nodes within the same `@graph` — a valid pattern, not a broken URL.

## Why it matters

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/rules/seo019.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: Date properties in JSON-LD should be ISO-8601.

## What it checks

Flags a value under a known date key (`datePublished`, `dateModified`, `startDate`, …) that is not ISO-8601.
Flags a value under a known date key (`datePublished`, `dateModified`, `startDate`, …) that is not ISO-8601. Reduced precision allowed by schema.org is accepted — year (`2026`), year-month (`2026-06`), full date, and date-time all pass.

## Why it matters

Expand Down
55 changes: 38 additions & 17 deletions packages/core/src/rules/seo/jsonld-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,22 +71,35 @@ export function nodeStringValues(node: JsonLdNode): string[] {
return out;
}

/**
* Absolute = carries a URI scheme (`http:`, `https:`, `data:`, `mailto:`, `urn:`, `tel:`, …) or is
* protocol-relative (`//host/…`). Only scheme-less path references (`/x`, `x/y`, `./x`, `#frag`) are
* relative — those are what search engines can't resolve. We deliberately accept non-http schemes and
* protocol-relative URLs so legitimate values (data-URI logos, `mailto:`/`urn:` identifiers) aren't flagged.
*/
export function isAbsoluteUrl(s: string): boolean {
return /^https?:\/\//i.test(s.trim());
const str = s.trim();
return /^[a-z][a-z0-9+.-]*:/i.test(str) || str.startsWith('//');
}

/**
* ISO-8601 date or date-time, AND a real calendar date — the shape regex alone would accept
* impossible values like 2026-13-40 or 2026-02-31, which we reject via a UTC round-trip.
* ISO-8601 date or date-time at any allowed precision — year (`2026`), year-month (`2026-06`), full
* date, or date-time. Schema.org `Date`/`DateTime` permit reduced precision, so we accept it. We also
* reject impossible calendar values (`2026-13`, `2026-02-31`, `…T25:00`) that the shape regex alone allows.
*/
export function isIso8601(s: string): boolean {
const str = s.trim();
if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(str)) return false;
if (!/^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/.test(str)) return false;
const y = Number(str.slice(0, 4));
const m = Number(str.slice(5, 7));
const d = Number(str.slice(8, 10));
const dt = new Date(Date.UTC(y, m - 1, d));
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return false;
const hasMonth = str.length >= 7;
const hasDay = str.length >= 10;
const m = hasMonth ? Number(str.slice(5, 7)) : 1;
if (m < 1 || m > 12) return false;
if (hasDay) {
const d = Number(str.slice(8, 10));
const dt = new Date(Date.UTC(y, m - 1, d));
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return false;
}
const tm = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(str);
if (tm) {
const [hh, mm, ss] = [Number(tm[1]), Number(tm[2]), tm[3] !== undefined ? Number(tm[3]) : 0];
Expand All @@ -108,15 +121,9 @@ export function hasPlaceholder(s: string): boolean {
return PLACEHOLDER_RES.some((re) => re.test(s));
}

export const URL_KEYS: ReadonlySet<string> = new Set([
'url',
'@id',
'image',
'logo',
'sameAs',
'contentUrl',
'thumbnailUrl'
]);
// `@id` is intentionally excluded: it is a node *identifier* (IRI), commonly a relative fragment
// like "#organization" that cross-references nodes within the same @graph — valid, not a broken URL.
export const URL_KEYS: ReadonlySet<string> = new Set(['url', 'image', 'logo', 'sameAs', 'contentUrl', 'thumbnailUrl']);
export const DATE_KEYS: ReadonlySet<string> = new Set([
'datePublished',
'dateModified',
Expand All @@ -131,6 +138,20 @@ export const DATE_KEYS: ReadonlySet<string> = new Set([
/** Types whose Google rich results were dropped/restricted (verify before relying on them). */
export const DEPRECATED_TYPES: ReadonlySet<string> = new Set(['HowTo', 'FAQPage', 'ClaimReview']);

/**
* True when `node[key]` is present AND carries a usable value — not `null`/`undefined`, not an empty
* or whitespace-only string, not an empty array. A bare `"headline": ""` is exactly the placeholder an
* author leaves behind, and Google treats it as missing, so presence alone (`key in node`) is too weak.
*/
export function hasNonEmpty(node: JsonLdNode, key: string): boolean {
if (!(key in node)) return false;
const v = node[key];
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim().length > 0;
if (Array.isArray(v)) return v.length > 0;
return true;
}

/** Curated @type -> required properties for the rich result (Google structured-data docs). */
export const REQUIRED_PROPS: Record<string, string[]> = {
Article: ['headline'],
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/rules/seo/seo016-021.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isAbsoluteUrl,
isIso8601,
hasPlaceholder,
hasNonEmpty,
URL_KEYS,
DATE_KEYS,
DEPRECATED_TYPES,
Expand Down Expand Up @@ -226,7 +227,7 @@ export const seo021RequiredProps = jsonldRule({
const required = REQUIRED_PROPS[t];
if (!required) continue; // unknown/custom type → not flagged
hasKnownType = true;
const missing = required.filter((p) => !(p in node));
const missing = required.filter((p) => !hasNonEmpty(node, p));
if (missing.length > 0) return `${t} JSON-LD is missing required ${missing.join(', ')}`;
}
}
Expand Down
28 changes: 28 additions & 0 deletions packages/core/test/jsonld-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isAbsoluteUrl,
isIso8601,
hasPlaceholder,
hasNonEmpty,
typeOf,
URL_KEYS,
REQUIRED_PROPS
Expand Down Expand Up @@ -48,12 +49,26 @@ describe('predicates', () => {
expect(isAbsoluteUrl('https://e.com/a')).toBe(true);
expect(isAbsoluteUrl('/a')).toBe(false);
expect(isAbsoluteUrl('a/b')).toBe(false);
expect(isAbsoluteUrl('#frag')).toBe(false);
expect(isAbsoluteUrl('./rel')).toBe(false);
});
it('isAbsoluteUrl accepts protocol-relative and non-http schemes (no false positives)', () => {
expect(isAbsoluteUrl('//cdn.example.com/x.png')).toBe(true); // protocol-relative
expect(isAbsoluteUrl('data:image/png;base64,AAAA')).toBe(true); // data URI logo
expect(isAbsoluteUrl('mailto:hi@example.com')).toBe(true);
expect(isAbsoluteUrl('urn:isbn:9780000000000')).toBe(true);
});
it('isIso8601', () => {
expect(isIso8601('2026-06-26')).toBe(true);
expect(isIso8601('2026-06-26T10:00:00Z')).toBe(true);
expect(isIso8601('June 26, 2026')).toBe(false);
});
it('isIso8601 accepts schema.org reduced precision (year, year-month)', () => {
expect(isIso8601('2026')).toBe(true);
expect(isIso8601('2026-06')).toBe(true);
expect(isIso8601('2026-13')).toBe(false); // month out of range
expect(isIso8601('2026-00')).toBe(false);
});
it('isIso8601 rejects impossible calendar dates/times', () => {
expect(isIso8601('2026-13-40')).toBe(false); // month/day out of range
expect(isIso8601('2026-02-31')).toBe(false); // Feb 31 doesn't exist
Expand All @@ -70,6 +85,19 @@ describe('predicates', () => {
});
});

describe('hasNonEmpty', () => {
it('treats empty/blank values as missing', () => {
expect(hasNonEmpty({ headline: 'x' }, 'headline')).toBe(true);
expect(hasNonEmpty({ headline: '' }, 'headline')).toBe(false);
expect(hasNonEmpty({ headline: ' ' }, 'headline')).toBe(false);
expect(hasNonEmpty({ headline: null }, 'headline')).toBe(false);
expect(hasNonEmpty({}, 'headline')).toBe(false);
expect(hasNonEmpty({ itemListElement: [] }, 'itemListElement')).toBe(false);
expect(hasNonEmpty({ itemListElement: [{}] }, 'itemListElement')).toBe(true);
expect(hasNonEmpty({ offers: {} }, 'offers')).toBe(true); // a non-empty object counts
});
});

describe('REQUIRED_PROPS', () => {
it('covers the common types', () => {
expect(REQUIRED_PROPS['Product']).toContain('name');
Expand Down
61 changes: 61 additions & 0 deletions packages/core/test/seo-jsonld-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ describe('SEO017-021', () => {
)
).toHaveLength(0);
});
it('SEO018 does not flag a relative @id (node identifier, not a URL)', async () => {
expect(
fails(
await seo018RelativeUrl.check(
ctx(
headWithJsonLd(
'{"@context":"https://schema.org","@type":"Org","@id":"#organization","url":"https://e.com"}'
)
)
)
)
).toHaveLength(0);
});
it('SEO018 accepts protocol-relative and data-URI values', async () => {
expect(
fails(
await seo018RelativeUrl.check(
ctx(headWithJsonLd('{"@context":"https://schema.org","@type":"Org","logo":"//cdn.e.com/l.png"}'))
)
)
).toHaveLength(0);
expect(
fails(
await seo018RelativeUrl.check(
ctx(headWithJsonLd('{"@context":"https://schema.org","@type":"Org","image":"data:image/png;base64,AAAA"}'))
)
)
).toHaveLength(0);
});
it('SEO019 flags a non-ISO date under a known key', async () => {
expect(
fails(
Expand All @@ -85,6 +114,22 @@ describe('SEO017-021', () => {
)
).toHaveLength(0);
});
it('SEO019 accepts schema.org reduced-precision dates (year / year-month)', async () => {
expect(
fails(
await seo019DateFormat.check(
ctx(headWithJsonLd('{"@context":"https://schema.org","@type":"Event","startDate":"2026"}'))
)
)
).toHaveLength(0);
expect(
fails(
await seo019DateFormat.check(
ctx(headWithJsonLd('{"@context":"https://schema.org","@type":"Event","startDate":"2026-06"}'))
)
)
).toHaveLength(0);
});
it('SEO020 flags placeholder text', async () => {
expect(
fails(
Expand Down Expand Up @@ -115,6 +160,22 @@ describe('SEO017-021', () => {
)
).toHaveLength(0); // unknown type → no signal
});
it('SEO021 treats an empty/blank required value as missing', async () => {
expect(
fails(
await seo021RequiredProps.check(
ctx(headWithJsonLd('{"@context":"https://schema.org","@type":"Article","headline":""}'))
)
)
).toHaveLength(1); // blank headline → still missing
expect(
fails(
await seo021RequiredProps.check(
ctx(headWithJsonLd('{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[]}'))
)
)
).toHaveLength(1); // empty array → still missing
});
it('SEO017-021 skip parseable JSON-LD that SEO016 deems invalid (missing @context/@type)', async () => {
// Relative URL present, but no @context → SEO016 owns the finding; SEO018 stays silent (no misleading pass).
expect(await seo018RelativeUrl.check(ctx(headWithJsonLd('{"@type":"Org","image":"/logo.png"}')))).toHaveLength(0);
Expand Down
5 changes: 4 additions & 1 deletion packages/vite/src/providers/rendered/parse-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export function parseHtmlHead(html: string): ParsedHtmlHead {

for (const script of head.querySelectorAll('script')) {
if (script.getAttribute('type') === 'application/ld+json') {
const raw = script.text;
// `<script>` is a raw-text element — browsers and search engines read its body verbatim and do
// NOT decode HTML entities. `.text` decodes (e.g. `&quot;` -> `"`), which would corrupt the JSON
// before SEO016 parses it; `.rawText` preserves exactly what the crawler sees.
const raw = script.rawText;
tags.push({
kind: 'jsonld',
presence: 'own',
Expand Down
8 changes: 8 additions & 0 deletions packages/vite/test/parse-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,12 @@ describe('parse-html: jsonld raw capture', () => {
);
expect(tags.find((t) => t.kind === 'jsonld')!.jsonld).toBe('{"@type":"WebPage"}');
});
it('preserves HTML entities verbatim (script is raw-text; crawlers do not decode)', () => {
// `&quot;` must NOT be decoded to `"` — doing so would corrupt the JSON and make SEO016 misreport.
const body = '{"@context":"https://schema.org","@type":"Org","name":"Tom &quot;Cat&quot; Jones"}';
const { tags } = parseHtmlHead(
`<html><head><script type="application/ld+json">${body}</script></head><body></body></html>`
);
expect(tags.find((t) => t.kind === 'jsonld')!.jsonld).toBe(body);
});
});