From c43bcb20439a6f63e26d567e3262ef2f72788d62 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Wed, 1 Apr 2026 01:02:50 +0300 Subject: [PATCH 01/30] feat(devtools): add SEO tab documentation with detailed features and functionality This commit introduces a new README.md file for the SEO tab in the devtools package. It outlines the purpose of the SEO tab, including its major features such as Social Previews, SERP Previews, JSON-LD Previews, and more. Each section provides an overview of functionality, data sources, and how the previews are rendered, enhancing the documentation for better user understanding. --- packages/devtools/src/tabs/seo-tab/README.md | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/devtools/src/tabs/seo-tab/README.md diff --git a/packages/devtools/src/tabs/seo-tab/README.md b/packages/devtools/src/tabs/seo-tab/README.md new file mode 100644 index 00000000..cf07db09 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/README.md @@ -0,0 +1,75 @@ +# SEO Tab Overview + +## Overview + +The seo tab contains major tabs that are complement to the inspect elements light house tab and not a replacement for them. It is a replacement for the extensions and simple tools you use to check and discover things by simply digging deeper in the html section, network or other pages in your site. + +SEO tabs: + +- Social Previews: shows open graph and twitter previews for you page when shared across social media apps. +- SERP Previews: shows you a similar preview of how your page will be displayed in search engine results page. +- JSON-LD Previews: shows you all the json ld detected in the page. +- Heading Structure Visualizer: preview your layout in heading tags. +- Links preview: check all page links and thier details like internal/external, text, ... +- Canonical & URL & if page is indexible and follow +- overview tab for SEO Score / Report: that contains a percentage of how everything is going in the other tabs and a small icon/link that will redirect them to the sepcific tab for more informations and details. + +## Social Previews + +Shows simulated share cards for major networks using metadata read from `document.head`. + +Implemented networks and tag checks: + +- Facebook, LinkedIn, Discord, Slack, Mastodon, Bluesky: + - `og:title`, `og:description`, `og:image`, `og:url` +- X/Twitter: + - `twitter:title`, `twitter:description`, `twitter:image`, `twitter:url` + +How it works: + +- Reads all `meta` tags from the current page head and maps matches into a per-network report. +- Renders one card per network with: + - network header color, + - preview image (or `No Image` placeholder), + - title (`No Title` fallback), + - description (`No Description` fallback), + - URL (falls back to `window.location.href` when missing). +- Lists missing tags under each network in a dedicated "Missing tags" block. +- Subscribes to head updates via `useHeadChanges` and refreshes reports reactively. + +## SERP Previews + +Shows Google-style result snippets based on the current page title, description, favicon, URL, and site name. + +Data sources: + +- `document.title` +- `` +- `` (fallback: hostname without `www.`) +- `` for favicon (resolved to absolute URL when possible) +- `window.location.href` + +Rendered previews: + +- Desktop preview +- Mobile preview + +Truncation and limits: + +- Title truncated to `~60` characters for display. +- Description truncated to `~158` characters for display. +- Mobile description overflow check uses `~120` characters (3-line approximation). + +Issue reporting: + +- Shared checks: + - missing favicon/icon, + - missing title, + - missing meta description, + - title likely too long (message references width > 600px). +- Desktop-specific check: + - meta description may be trimmed (desktop/mobile pixel-width guidance message). +- Mobile-specific check: + - description exceeds mobile 3-line limit. + +Like Social Previews, this section updates live through `useHeadChanges`. \ No newline at end of file From ffb1f35ad2e6032512e3c6f4a11b5dab29f4c3c4 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Wed, 1 Apr 2026 01:03:15 +0300 Subject: [PATCH 02/30] feat(devtools): add SEO analysis features including JSON-LD, heading structure, and links preview This commit introduces several new sections to the SEO tab in the devtools package, enhancing its functionality. The new features include: - **JSON-LD Preview**: Parses and validates JSON-LD scripts on the page, providing detailed feedback on required and recommended attributes. - **Heading Structure Preview**: Analyzes heading tags (`h1` to `h6`) for hierarchy and common issues, ensuring proper SEO practices. - **Links Preview**: Scans all links on the page, classifying them as internal, external, or invalid, and reports on accessibility and SEO-related issues. Additionally, the SEO tab navigation has been updated to include these new sections, improving user experience and accessibility of SEO insights. --- examples/react/basic/index.html | 9 + packages/devtools/src/tabs/seo-tab/README.md | 122 ++++- .../tabs/seo-tab/canonical-url-preview.tsx | 185 +++++++ .../seo-tab/heading-structure-preview.tsx | 153 ++++++ packages/devtools/src/tabs/seo-tab/index.tsx | 52 +- .../src/tabs/seo-tab/json-ld-preview.tsx | 509 ++++++++++++++++++ .../src/tabs/seo-tab/links-preview.tsx | 180 +++++++ 7 files changed, 1208 insertions(+), 2 deletions(-) create mode 100644 packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx create mode 100644 packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx create mode 100644 packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx create mode 100644 packages/devtools/src/tabs/seo-tab/links-preview.tsx diff --git a/examples/react/basic/index.html b/examples/react/basic/index.html index b63b73f6..fe1a2596 100644 --- a/examples/react/basic/index.html +++ b/examples/react/basic/index.html @@ -38,6 +38,15 @@ > +
diff --git a/packages/devtools/src/tabs/seo-tab/README.md b/packages/devtools/src/tabs/seo-tab/README.md index cf07db09..2735d3aa 100644 --- a/packages/devtools/src/tabs/seo-tab/README.md +++ b/packages/devtools/src/tabs/seo-tab/README.md @@ -72,4 +72,124 @@ Issue reporting: - Mobile-specific check: - description exceeds mobile 3-line limit. -Like Social Previews, this section updates live through `useHeadChanges`. \ No newline at end of file +Like Social Previews, this section updates live through `useHeadChanges`. + +## JSON-LD Previews + +Parses all `script[type="application/ld+json"]` blocks available on the current page and displays each block as formatted JSON with validation output. + +Current scan behavior: + +- Non-reactive by design. +- The section scans and validates JSON-LD when the tab is opened. +- If page JSON-LD changes later, reopen the tab to rescan. + +Supported schema types with dedicated manual validation rules: + +- `WebSite` +- `Organization` +- `Person` +- `Article` +- `Product` +- `BreadcrumbList` +- `FAQPage` +- `LocalBusiness` + +Validation model: + +- Shared checks for every entity: + - missing or invalid `@context` (expects Schema.org context), + - missing `@type`, + - invalid JSON syntax and invalid root shape. +- Type-specific checks: + - missing required attributes -> `error`, + - missing recommended attributes -> `warning`, + - missing optional attributes -> `info`, + - unknown/non-allowed attributes for that type -> `warning`. +- Unknown schema types still render parsed output and are reported as: + - `warning`: no dedicated validator yet. + +UI details: + +- One card per JSON-LD block with: + - detected type summary, + - formatted parsed JSON (or raw content for parse errors), + - copy action (`Copy parsed JSON-LD`), + - grouped severity messages (`error`, `warning`, `info`). + +JSON-LD health progress bar: + +- Displayed when at least one JSON-LD block is found. +- Starts at `100%`. +- Decreases by: + - `20` points per `error`, + - `10` points per `warning`. +- `info` issues (optional missing attributes) do not reduce score. +- Score is clamped between `0` and `100`. + +## Heading Structure Visualizer + +Scans all heading tags (`h1` to `h6`) on the page and renders the hierarchy in DOM order. + +Current behavior: + +- Non-reactive scan when the section is opened. +- Shows each heading with indentation based on heading level. +- Displays a structure issue list with severity. + +Checks included: + +- No headings found (`error`) +- Missing `h1` (`error`) +- Multiple `h1` (`warning`) +- First heading is not `h1` (`warning`) +- Skipped heading levels, e.g. `h2` to `h4` (`warning`) +- Empty heading text (`warning`) + +## Links Preview + +Collects links from the page and reports their SEO/security-related characteristics. + +Current behavior: + +- Non-reactive scan when the section is opened. +- Detects `a[href]` links and excludes devtools UI links. +- Classifies links as `internal`, `external`, `non-web`, or `invalid`. + +Checks included: + +- Missing visible/accessibility text (`error`) +- `javascript:` links (`error`) +- Invalid URL format (`error`) +- External `_blank` link without `noopener` (`warning`) +- Unexpected protocol (`warning`) +- External link without `nofollow` (`info`) +- Hash, mailto, tel, and other non-web links (`info`) + +## Canonical, URL, Indexability & Follow + +Evaluates canonical URL setup, robots directives, and basic URL hygiene. + +Current behavior: + +- Non-reactive scan when the section is opened. +- Reads canonical links from ``. +- Reads `robots` and `googlebot` meta directives. +- Derives indexability/follow from directives (`noindex`/`nofollow`). +- Includes a simple score (`100 - 25*errors - 10*warnings`). + +Checks included: + +- Missing canonical tag (`error`) +- Multiple canonical tags (`error`) +- Empty/invalid canonical href (`error`) +- Canonical with hash fragment (`warning`) +- Canonical cross-origin mismatch (`warning`) +- Page marked as `noindex` (`error`) +- Page marked as `nofollow` (`warning`) +- Missing robots directives (`info`) +- URL query parameters present (`info`) + +Note: + +- `X-Robots-Tag` response headers are not reliably available from this in-page view. \ No newline at end of file diff --git a/packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx b/packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx new file mode 100644 index 00000000..2d1441d3 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx @@ -0,0 +1,185 @@ +import { For } from 'solid-js' +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' + +type Severity = 'error' | 'warning' | 'info' + +type Issue = { + severity: Severity + message: string +} + +type CanonicalData = { + currentUrl: string + canonicalRaw: Array + canonicalResolved: Array + robots: Array + indexable: boolean + follow: boolean + issues: Array +} + +function severityColor(severity: Severity): string { + if (severity === 'error') return '#dc2626' + if (severity === 'warning') return '#d97706' + return '#2563eb' +} + +function getCanonicalData(): CanonicalData { + const currentUrl = window.location.href + const current = new URL(currentUrl) + + const canonicalLinks = Array.from( + document.head.querySelectorAll('link[rel]'), + ).filter((link) => link.rel.toLowerCase().split(/\s+/).includes('canonical')) + + const canonicalRaw = canonicalLinks.map((link) => link.getAttribute('href') || '') + const canonicalResolved: Array = [] + const issues: Array = [] + + if (canonicalLinks.length === 0) { + issues.push({ severity: 'error', message: 'No canonical link found.' }) + } + if (canonicalLinks.length > 1) { + issues.push({ severity: 'error', message: 'Multiple canonical links found.' }) + } + + for (const raw of canonicalRaw) { + if (!raw.trim()) { + issues.push({ severity: 'error', message: 'Canonical href is empty.' }) + continue + } + try { + const resolved = new URL(raw, currentUrl) + canonicalResolved.push(resolved.href) + + if (resolved.hash) { + issues.push({ + severity: 'warning', + message: 'Canonical URL contains a hash fragment.', + }) + } + if (resolved.origin !== current.origin) { + issues.push({ + severity: 'warning', + message: 'Canonical URL points to a different origin.', + }) + } + } catch { + issues.push({ severity: 'error', message: `Canonical URL is invalid: ${raw}` }) + } + } + + const robotsMetas = Array.from( + document.head.querySelectorAll('meta[name]'), + ).filter((meta) => { + const name = meta.getAttribute('name')?.toLowerCase() + return name === 'robots' || name === 'googlebot' + }) + + const robots = robotsMetas + .map((meta) => meta.getAttribute('content') || '') + .flatMap((content) => + content + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter(Boolean), + ) + + const indexable = !robots.includes('noindex') + const follow = !robots.includes('nofollow') + + if (!indexable) { + issues.push({ severity: 'error', message: 'Page is marked as noindex.' }) + } + if (!follow) { + issues.push({ severity: 'warning', message: 'Page is marked as nofollow.' }) + } + if (robots.length === 0) { + issues.push({ + severity: 'info', + message: 'No robots meta found. Default behavior is usually index/follow.', + }) + } + + if (current.pathname !== '/' && /[A-Z]/.test(current.pathname)) { + issues.push({ + severity: 'warning', + message: 'URL path contains uppercase characters.', + }) + } + if (current.search) { + issues.push({ severity: 'info', message: 'URL contains query parameters.' }) + } + + return { + currentUrl, + canonicalRaw, + canonicalResolved, + robots, + indexable, + follow, + issues, + } +} + +function getScore(issues: Array): number { + const errors = issues.filter((issue) => issue.severity === 'error').length + const warnings = issues.filter((issue) => issue.severity === 'warning').length + return Math.max(0, 100 - errors * 25 - warnings * 10) +} + +export function CanonicalUrlPreviewSection() { + const styles = useStyles() + const data = getCanonicalData() + const score = getScore(data.issues) + + return ( +
+ + Checks canonical URL, robots directives, indexability/follow signals, + and basic URL hygiene from the current page. + + +
+
SEO status
+
+ Score: {score}% + Indexable: {data.indexable ? 'Yes' : 'No'} + Follow: {data.follow ? 'Yes' : 'No'} + Canonical tags: {data.canonicalRaw.length} +
+
+ +
+
Signals
+
+ Current URL: {data.currentUrl} +
+
+ Canonical:{' '} + {data.canonicalResolved.join(', ') || data.canonicalRaw.join(', ') || 'None'} +
+
+ Robots directives: {data.robots.join(', ') || 'None'} +
+
+ X-Robots-Tag response headers are not available in this in-page view. +
+
+ +
+
Issues
+
    + + {(issue) => ( +
  • + [{issue.severity}] {issue.message} +
  • + )} +
    +
+
+
+ ) +} diff --git a/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx b/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx new file mode 100644 index 00000000..bd0653e8 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx @@ -0,0 +1,153 @@ +import { For, Show } from 'solid-js' +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' + +type Severity = 'error' | 'warning' | 'info' + +type HeadingItem = { + id: string + level: 1 | 2 | 3 | 4 | 5 | 6 + tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + text: string +} + +type HeadingIssue = { + severity: Severity + message: string +} + +function severityColor(severity: Severity): string { + if (severity === 'error') return '#dc2626' + if (severity === 'warning') return '#d97706' + return '#2563eb' +} + +function extractHeadings(): Array { + const nodes = Array.from( + document.body.querySelectorAll('h1,h2,h3,h4,h5,h6'), + ) + + return nodes.map((node, index) => { + const tag = node.tagName.toLowerCase() as HeadingItem['tag'] + const level = Number(tag[1]) as HeadingItem['level'] + + return { + id: node.id || `heading-${index}`, + level, + tag, + text: node.textContent?.trim() || '', + } + }) +} + +function validateHeadings(headings: Array): Array { + if (headings.length === 0) { + return [{ severity: 'error', message: 'No heading tags found on this page.' }] + } + + const issues: Array = [] + const h1Count = headings.filter((h) => h.level === 1).length + if (h1Count === 0) { + issues.push({ severity: 'error', message: 'No H1 heading found on this page.' }) + } else if (h1Count > 1) { + issues.push({ + severity: 'warning', + message: `Multiple H1 headings detected (${h1Count}).`, + }) + } + + if (headings[0] && headings[0].level !== 1) { + issues.push({ + severity: 'warning', + message: `First heading is ${headings[0].tag.toUpperCase()} instead of H1.`, + }) + } + + for (let index = 0; index < headings.length; index++) { + const current = headings[index]! + if (!current.text) { + issues.push({ + severity: 'warning', + message: `${current.tag.toUpperCase()} is empty.`, + }) + } + if (index > 0) { + const previous = headings[index - 1]! + if (current.level - previous.level > 1) { + issues.push({ + severity: 'warning', + message: `Skipped heading level from ${previous.tag.toUpperCase()} to ${current.tag.toUpperCase()}.`, + }) + } + } + } + + if (issues.length === 0) { + issues.push({ + severity: 'info', + message: 'Heading hierarchy looks healthy.', + }) + } + + return issues +} + +export function HeadingStructurePreviewSection() { + const styles = useStyles() + const headings = extractHeadings() + const issues = validateHeadings(headings) + + return ( +
+ + Visualizes heading structure (`h1`-`h6`) in DOM order and highlights + common hierarchy issues. This section scans once when opened. + +
+
+ Total headings: {headings.length} +
+ 0} + fallback={ +
+ No headings found on this page. +
+ } + > +
    + + {(heading) => ( +
  • + {heading.tag.toUpperCase()} + {heading.text || '(empty heading)'} +
  • + )} +
    +
+
+
+ +
+
Structure issues
+
    + + {(issue) => ( +
  • + [{issue.severity}] {issue.message} +
  • + )} +
    +
+
+
+ ) +} diff --git a/packages/devtools/src/tabs/seo-tab/index.tsx b/packages/devtools/src/tabs/seo-tab/index.tsx index c00a97e9..b8667de9 100644 --- a/packages/devtools/src/tabs/seo-tab/index.tsx +++ b/packages/devtools/src/tabs/seo-tab/index.tsx @@ -3,8 +3,18 @@ import { MainPanel } from '@tanstack/devtools-ui' import { useStyles } from '../../styles/use-styles' import { SocialPreviewsSection } from './social-previews' import { SerpPreviewSection } from './serp-preview' +import { JsonLdPreviewSection } from './json-ld-preview' +import { HeadingStructurePreviewSection } from './heading-structure-preview' +import { LinksPreviewSection } from './links-preview' +import { CanonicalUrlPreviewSection } from './canonical-url-preview' -type SeoSubView = 'social-previews' | 'serp-preview' +type SeoSubView = + | 'social-previews' + | 'serp-preview' + | 'json-ld-preview' + | 'heading-structure' + | 'links-preview' + | 'canonical-url' export const SeoTab = () => { const [activeView, setActiveView] = @@ -28,6 +38,34 @@ export const SeoTab = () => { > SERP Preview + + + + @@ -36,6 +74,18 @@ export const SeoTab = () => { + + + + + + + + + + + + ) } diff --git a/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx b/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx new file mode 100644 index 00000000..9e54714d --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx @@ -0,0 +1,509 @@ +import { For, Show } from 'solid-js' +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' + +type JsonLdValue = Record + +type IssueSeverity = 'error' | 'warning' | 'info' + +type ValidationIssue = { + severity: IssueSeverity + message: string +} + +type SchemaRule = { + required: Array + recommended: Array + optional: Array + allowed: Array +} + +type JsonLdEntry = { + id: string + raw: string + parsed: JsonLdValue | Array | null + types: Array + issues: Array +} + +const SUPPORTED_RULES: Record = { + WebSite: { + required: ['@context', '@type', 'name', 'url'], + recommended: ['potentialAction'], + optional: ['description', 'inLanguage'], + allowed: ['name', 'url', 'description', 'inLanguage', 'potentialAction'], + }, + Organization: { + required: ['@context', '@type', 'name', 'url'], + recommended: ['logo', 'sameAs'], + optional: ['description', 'email', 'telephone'], + allowed: [ + 'name', + 'url', + 'logo', + 'sameAs', + 'description', + 'email', + 'telephone', + ], + }, + Person: { + required: ['@context', '@type', 'name'], + recommended: ['url', 'sameAs'], + optional: ['image', 'jobTitle'], + allowed: ['name', 'url', 'sameAs', 'image', 'jobTitle', 'description'], + }, + Article: { + required: ['@context', '@type', 'headline', 'datePublished', 'author'], + recommended: ['dateModified', 'image', 'mainEntityOfPage'], + optional: ['description', 'publisher'], + allowed: [ + 'headline', + 'datePublished', + 'author', + 'dateModified', + 'image', + 'mainEntityOfPage', + 'description', + 'publisher', + ], + }, + Product: { + required: ['@context', '@type', 'name'], + recommended: ['image', 'description', 'offers'], + optional: ['brand', 'sku', 'aggregateRating', 'review'], + allowed: [ + 'name', + 'image', + 'description', + 'offers', + 'brand', + 'sku', + 'aggregateRating', + 'review', + ], + }, + BreadcrumbList: { + required: ['@context', '@type', 'itemListElement'], + recommended: [], + optional: ['name'], + allowed: ['itemListElement', 'name'], + }, + FAQPage: { + required: ['@context', '@type', 'mainEntity'], + recommended: [], + optional: [], + allowed: ['mainEntity'], + }, + LocalBusiness: { + required: ['@context', '@type', 'name', 'address'], + recommended: ['telephone', 'openingHours'], + optional: ['geo', 'priceRange', 'url', 'sameAs'], + allowed: [ + 'name', + 'address', + 'telephone', + 'openingHours', + 'geo', + 'priceRange', + 'url', + 'sameAs', + 'image', + ], + }, +} + +const RESERVED_KEYS = new Set(['@context', '@type', '@id', '@graph']) + +function isRecord(value: unknown): value is JsonLdValue { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getTypeList(entity: JsonLdValue): Array { + const typeField = entity['@type'] + if (typeof typeField === 'string') return [typeField] + if (Array.isArray(typeField)) { + return typeField.filter((v): v is string => typeof v === 'string') + } + return [] +} + +function getEntities(payload: unknown): Array { + if (Array.isArray(payload)) { + return payload.filter(isRecord) + } + if (!isRecord(payload)) return [] + const graph = payload['@graph'] + if (Array.isArray(graph)) { + const graphEntities = graph.filter(isRecord) + if (graphEntities.length > 0) return graphEntities + } + return [payload] +} + +function hasMissingKeys(entity: JsonLdValue, keys: Array): Array { + return keys.filter((key) => { + const value = entity[key] + if (value === undefined || value === null) return true + if (typeof value === 'string' && !value.trim()) return true + if (Array.isArray(value) && value.length === 0) return true + return false + }) +} + +function validateContext(entity: JsonLdValue): Array { + const context = entity['@context'] + if (!context) { + return [{ severity: 'error', message: 'Missing @context attribute.' }] + } + if (typeof context === 'string') { + if ( + !context.includes('schema.org') && + context !== 'https://schema.org' && + context !== 'http://schema.org' + ) { + return [ + { + severity: 'error', + message: `Invalid @context value "${context}". Expected schema.org context.`, + }, + ] + } + return [] + } + return [ + { + severity: 'error', + message: 'Invalid @context type. Expected a string schema.org URL.', + }, + ] +} + +function validateTypes(entity: JsonLdValue): Array { + const types = getTypeList(entity) + if (types.length === 0) { + return [{ severity: 'error', message: 'Missing @type attribute.' }] + } + return [] +} + +function validateEntityByType(entity: JsonLdValue, typeName: string): Array { + const rules = SUPPORTED_RULES[typeName] + if (!rules) { + return [ + { + severity: 'warning', + message: `Type "${typeName}" has no dedicated validator yet.`, + }, + ] + } + + const issues: Array = [] + const missingRequired = hasMissingKeys(entity, rules.required) + const missingRecommended = hasMissingKeys(entity, rules.recommended) + const missingOptional = hasMissingKeys(entity, rules.optional) + + if (missingRequired.length > 0) { + issues.push({ + severity: 'error', + message: `Missing required attributes: ${missingRequired.join(', ')}`, + }) + } + if (missingRecommended.length > 0) { + issues.push({ + severity: 'warning', + message: `Missing recommended attributes: ${missingRecommended.join(', ')}`, + }) + } + if (missingOptional.length > 0) { + issues.push({ + severity: 'info', + message: `Missing optional attributes: ${missingOptional.join(', ')}`, + }) + } + + const allowedSet = new Set([...rules.allowed, ...Array.from(RESERVED_KEYS)]) + const unknownKeys = Object.keys(entity).filter((key) => !allowedSet.has(key)) + if (unknownKeys.length > 0) { + issues.push({ + severity: 'warning', + message: `Possible invalid attributes for ${typeName}: ${unknownKeys.join(', ')}`, + }) + } + + return issues +} + +function validateJsonLdValue(value: unknown): Array { + if (!isRecord(value) && !Array.isArray(value)) { + return [ + { + severity: 'error', + message: 'JSON-LD root must be an object or an array of objects.', + }, + ] + } + + const entities = getEntities(value) + if (entities.length === 0) { + return [{ severity: 'error', message: 'No valid JSON-LD objects found.' }] + } + + const issues: Array = [] + for (const entity of entities) { + issues.push(...validateContext(entity)) + issues.push(...validateTypes(entity)) + const types = getTypeList(entity) + for (const typeName of types) { + issues.push(...validateEntityByType(entity, typeName)) + } + } + return issues +} + +function getTypeSummary(value: unknown): Array { + const entities = getEntities(value) + const typeSet = new Set() + for (const entity of entities) { + for (const type of getTypeList(entity)) { + typeSet.add(type) + } + } + return Array.from(typeSet) +} + +function analyzeJsonLdScripts(): Array { + const scripts = Array.from( + document.querySelectorAll('script[type="application/ld+json"]'), + ) + + return scripts.map((script, index) => { + const raw = script.textContent?.trim() ?? '' + if (!raw) { + return { + id: `jsonld-${index}`, + raw, + parsed: null, + types: [], + issues: [{ severity: 'error', message: 'Empty JSON-LD script block.' }], + } + } + + try { + const parsed = JSON.parse(raw) as JsonLdValue | Array + return { + id: `jsonld-${index}`, + raw, + parsed, + types: getTypeSummary(parsed), + issues: validateJsonLdValue(parsed), + } + } catch (error) { + const parseMessage = + error instanceof Error ? error.message : 'Unknown JSON parse error.' + return { + id: `jsonld-${index}`, + raw, + parsed: null, + types: [], + issues: [ + { + severity: 'error', + message: `Invalid JSON syntax: ${parseMessage}`, + }, + ], + } + } + }) +} + +function severityColor(severity: IssueSeverity): string { + if (severity === 'error') return '#dc2626' + if (severity === 'warning') return '#d97706' + return '#2563eb' +} + +function getJsonLdScore(entries: Array): number { + let errors = 0 + let warnings = 0 + + for (const entry of entries) { + for (const issue of entry.issues) { + if (issue.severity === 'error') errors += 1 + if (issue.severity === 'warning') warnings += 1 + } + } + + // Optional/info issues do not reduce score. + const penalty = errors * 20 + warnings * 10 + return Math.max(0, 100 - penalty) +} + +function scoreColor(score: number): string { + if (score >= 80) return '#16a34a' + if (score >= 50) return '#d97706' + return '#dc2626' +} + +function JsonLdBlock(props: { entry: JsonLdEntry; index: number }) { + const styles = useStyles() + + const copyParsed = async () => { + if (!props.entry.parsed) return + try { + await navigator.clipboard.writeText( + JSON.stringify(props.entry.parsed, null, 2), + ) + } catch { + // ignore clipboard errors in restricted contexts + } + } + + return ( +
+
JSON-LD Block #{props.index + 1}
+
+ Detected types:{' '} + {props.entry.types.length > 0 ? props.entry.types.join(', ') : 'Unknown'} +
+ + + +
+        {props.entry.parsed
+          ? JSON.stringify(props.entry.parsed, null, 2)
+          : props.entry.raw || 'No JSON-LD content found.'}
+      
+ 0}> +
+ Validation issues: +
    + + {(issue) => ( +
  • + [{issue.severity}] {issue.message} +
  • + )} +
    +
+
+
+ +
+ No validation issues found for this block. +
+
+
+ ) +} + +export function JsonLdPreviewSection() { + const entries = analyzeJsonLdScripts() + const styles = useStyles() + const score = getJsonLdScore(entries) + const barColor = scoreColor(score) + const errorCount = entries.reduce( + (total, entry) => + total + entry.issues.filter((issue) => issue.severity === 'error').length, + 0, + ) + const warningCount = entries.reduce( + (total, entry) => + total + entry.issues.filter((issue) => issue.severity === 'warning').length, + 0, + ) + + return ( +
+ + Parses all {`