diff --git a/docs/superpowers/specs/2026-08-09-jsonld-vocabulary-catalog-design.md b/docs/superpowers/specs/2026-08-09-jsonld-vocabulary-catalog-design.md new file mode 100644 index 000000000..475245a37 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-jsonld-vocabulary-catalog-design.md @@ -0,0 +1,162 @@ +# seo/json-ld: schema.org vocabulary validation via a schema-dts-derived catalog + +Date: 2026-08-09 +Status: Design (spike deliverable for issue #421 — answers its four questions; implementation is a +separate, later PR) +Origin: 2026-08-09 v1.0 rule-validity review follow-up; suggested by the maintainer during #417 +review. + +## The gap (restated) + +A typo'd type — `"@type": "Artcle"` — passes every rule in the json-ld family today. +`seo/json-ld-validity` gates parse + `@context` + `@type` _presence_; `seo/json-ld-required-props` +looks types up in its curated Google-requirements table (an unknown type simply has no row); nothing +checks that a declared name exists in the schema.org ontology at all. The ontology has a maintained, +typed distribution — [google/schema-dts](https://github.com/google/schema-dts) — from which a +catalog can be generated and kept current by a normal Renovate bump. + +Verified facts about schema-dts (2.0.0, inspected via unpkg): + +- Types only: one `dist/schema.d.ts` (~992 KB) and a 77-byte runtime stub. Nothing importable at + runtime — the catalog must be extracted at build time. +- Every schema.org type is an `export type` alias (`export type Airplane = AirplaneLeaf;`, + `export type Accommodation = AccommodationLeaf | Apartment | … | string;`) — roughly 1,000 + exported aliases. Property containers (`*Base`) and `@type` discriminators (`*Leaf`) are + interfaces, not aliases, so the alias namespace is almost exactly the vocabulary. Four generic + helpers (`WithContext`, `Graph`, `SchemaValue`, `IdReference`) are the exceptions to filter. +- schema.org-level deprecations/supersessions are carried as `@deprecated` JSDoc on the export + (e.g. Attorney → "LegalService is more inclusive and less ambiguous"). + +## Decision summary + +**Phase 1 (recommended): `@type` existence only, as a new arm of `seo/json-ld-validity`, backed by +a generated name catalog committed in `@svelte-vitals/core` (~800–1,000 names, ~20 KB) with a +drift test.** Property-name and property-domain validation are rejected (not deferred for size +reasons alone — they have a legitimate-use false-positive problem; see below). Ontology +supersession warnings are deferred as a possible phase 2. + +## The four spike questions + +### 1. Scope: type existence / property existence / property-domain + +**Type existence only.** + +- `@type` typos are the highest-value class: a typo'd type silently voids the whole node's + rich-result eligibility, and `json-ld-required-props` goes blind at the same moment (no row + found). One catalog lookup catches it. +- **Property-name existence is rejected, not just deferred**: schema.org's data model explicitly + tolerates extension properties and mixed vocabularies — publishers legitimately attach custom or + third-party-vocabulary properties to schema.org nodes, and Google's parser ignores unknown + properties rather than penalizing them. A property-existence check therefore flags legitimate + markup with no eligibility consequence — a false-positive class we would own forever. (The one + false-positive-free variant — checking only properties that are _almost_ a known name, i.e. + did-you-mean on properties — needs the full property list anyway and still guesses about intent; + not worth the bytes.) +- **Property-domain validation** ("is this property valid _on this type_") inherits the same + false-positive problem plus the largest catalog (per-type property maps, 100 KB+ in core for a + check Google doesn't perform). Rejected. + +### 2. Home: new rule vs. extending `seo/json-ld-validity` + +**Extend `seo/json-ld-validity`.** It already owns "malformed at the JSON-LD level" (unparseable +JSON, missing `@context`, missing `@type`), and an unknown `@type` is the same family: the document +is structurally broken as schema.org data, independent of any specific consumer's requirements. +This also keeps the severity story coherent — the rule is registered `warning`, and an unknown type +is exactly as consequential as a missing one (the node drops out of eligibility either way). No +severity split: the new arm fires at the rule's registered severity, so a global +`rules: { 'seo/json-ld-validity': … }` override behaves unsurprisingly. + +A second registered rule would need its own docs pages, inventory weight, and severity +narrative for what is one concept; rejected. + +Message shape (message text is not part of `findingKey`, so wording stays adjustable): + +``` +Unknown @type 'Artcle' — not a schema.org type. Did you mean 'Article'? +``` + +The did-you-mean suggestion is a case-insensitive exact match against the catalog (cheap, no +edit-distance dependency); omitted when nothing matches. Multi-typed nodes +(`"@type": ["Product", "Vehicle"]`) validate each name; one finding per unknown name. + +### 3. Case handling and non-schema.org contexts + +- **Exact, case-sensitive match.** schema.org names are case-sensitive PascalCase; `"article"` is + not a valid type and Google's parser treats it as unknown. The case-insensitive pass exists only + to power the suggestion, never to accept. +- **Non-schema.org `@context` exempts the document.** Custom vocabularies are legal JSON-LD. The + arm runs only when the node's governing `@context` is schema.org — accepted spellings: + `http(s)://schema.org` with optional trailing slash (the existing `@context` handling in + `json-ld-validity` already recognizes these; reuse it). If `@context` is an array or object that + mentions any non-schema.org vocabulary, the document is exempt — term remapping means a name we + don't recognize may be perfectly valid. Conservative by construction: silence, never a false + positive, on exotic contexts. + +### 4. Catalog size vs. core bundle budget + +- Names only: ~800–1,000 entries × ~13 chars ≈ **20 KB source** as a + `ReadonlySet` literal in a generated module. Acceptable for `@svelte-vitals/core` (the + curated tables in `jsonld-engine.ts` set the precedent; this is one order larger but still + trivial next to the parser). +- The property-domain map (the only expensive option) is rejected under Q1, so no budget question + remains. + +## Catalog + generator design + +Follows the repo's committed-generated-file pattern (`packages/cli/src/docs/generated.ts` + +`docs-embed.test.mjs`; `packages/cli/src/ci/action-pin.generated.ts`): + +- **Dev dependency**: `schema-dts` added to the workspace catalog (`pnpm-workspace.yaml`) and + `packages/core`'s devDependencies as `catalog:`. Renovate bumps it like any other dep. +- **Generator**: `packages/core/scripts/gen-schema-vocab.mjs`, run manually via + `pnpm --filter @svelte-vitals/core run gen:schema-vocab`. Core's no-`node:`/no-I/O rule covers + `src/` only; dev-time scripts are exempt (same as its test suite). The script: + 1. Locates `schema-dts/dist/schema.d.ts` via `import.meta.resolve`. + 2. Parses it with the TypeScript compiler API (already a workspace devDependency) and collects + exported **type alias** names — interfaces (`*Base`, `*Leaf`) are excluded by node kind, and + the four generic helpers (`WithContext`, `Graph`, `SchemaValue`, `IdReference`) by an explicit + list, not by pattern (a hypothetical future vocabulary type ending in "Leaf" must not be + silently dropped; the alias-vs-interface distinction does the structural work). + 3. Sanity-asserts the count lands in 700–1,500 — outside that band the upstream shape changed and + the script fails loudly instead of committing a gutted catalog. + 4. Emits `packages/core/src/rules/seo/schema-vocabulary.generated.ts`: a header naming the + schema-dts version it was generated from, plus + `export const SCHEMA_ORG_TYPES: ReadonlySet`. +- **Drift test**: `packages/core/test/schema-vocabulary.test.mjs` re-runs the extraction against + the installed schema-dts and fails if the committed module differs — so a Renovate bump of + schema-dts fails CI until the catalog is regenerated and committed, which is the mechanism that + keeps the catalog current (mirrors `docs-embed.test.mjs`). + +## Deferred (phase 2 candidates, each needing its own decision) + +- **Supersession warnings**: the generator can also capture `@deprecated` exports into a + `SUPERSEDED_TYPES` map (name → replacement parsed from the JSDoc). This overlaps conceptually + with `seo/json-ld-deprecated-type` — whose curated list is about _Google dropping rich-result + support_ (HowTo, FAQPage), a different statement than _schema.org superseded this term_. Shipping + both without confusing users needs message design; deferred rather than bundled into phase 1. +- **Vite provider parity**: nothing here is provider-specific — the arm lives in the shared engine, + so both providers get it for free. Listed only to note there is no extra work. + +## Implementation-plan requirements (for the eventual phase-1 PR) + +- New penalized findings can appear on existing projects (any typo'd `@type` that was silently + passing), so under `--fail-on warning` a previously green run can turn red. The changeset must + declare this movement explicitly, and the review runs the full fixture × `--fail-on` × pre/post + truth table (established practice since #428). +- The arm adds a per-node set lookup to the existing parse pass — no new I/O, no io-budget effect. +- Docs (en + ja): new "Unknown type" section on the `seo/json-ld-validity` rule pages, including + the exemption rules for non-schema.org contexts; regenerate rule indexes if the pages' headings + change. +- `@svelte-vitals/core` changeset: minor (new detection surface). + +## Rejected alternatives (record, so this isn't re-litigated) + +- **Runtime dependency on schema-dts**: impossible — it ships no runtime values. +- **Fetching the schema.org vocabulary JSON at generation time**: adds a network step and a second + source of truth; schema-dts already tracks releases and Renovate handles currency. +- **Hand-curating a "common types" subset**: the full name list is only ~20 KB; curation would + reintroduce exactly the staleness this design eliminates. +- **Property-name / property-domain validation**: see Q1 — legitimate-use false positives + (extension properties are valid schema.org usage), plus the only expensive catalog. This is the + standing rejection; revisit only with evidence that unknown-property typos materially hurt + eligibility.