diff --git a/src/content/panel/components/JsonPreview.tsx b/src/content/panel/components/JsonPreview.tsx index cde95a2..d31d394 100644 --- a/src/content/panel/components/JsonPreview.tsx +++ b/src/content/panel/components/JsonPreview.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { buildUrl } from '@/lib/clay-uri'; import { copyToClipboard } from '@/lib/clipboard'; +import { highlightJson } from '@/lib/json-highlight'; import { useEnvHost, useStore } from '../store'; import { Icon } from './Icon'; @@ -12,32 +13,6 @@ interface FetchState { const cache = new Map(); -function highlightJson(value: unknown): string { - const json = JSON.stringify(value, null, 2); - return json - .replace(/&/g, '&') - .replace(//g, '>') - .replace( - /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(?=\s*:))|("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")|(\b(?:true|false)\b)|(\bnull\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, - (match: string) => { - if (/^"[^"]+"\s*:?$/.test(match) && match.endsWith(':')) { - return `${match}`; - } - if (match.startsWith('"')) { - return `${match}`; - } - if (match === 'true' || match === 'false') { - return `${match}`; - } - if (match === 'null') { - return `${match}`; - } - return `${match}`; - } - ); -} - function cacheKey(uri: string | null, host: string): string { return `${host || '_'}::${uri ?? ''}`; } diff --git a/src/content/panel/components/SeoTab.tsx b/src/content/panel/components/SeoTab.tsx index f61307d..a234be2 100644 --- a/src/content/panel/components/SeoTab.tsx +++ b/src/content/panel/components/SeoTab.tsx @@ -1,5 +1,17 @@ -import { useEffect, useState } from 'react'; -import { extractSeoMeta, lintSeo, type SeoIssue, type SeoMeta } from '@/lib/seo'; +import { useEffect, useMemo, useState } from 'react'; +import { copyToClipboard } from '@/lib/clipboard'; +import { highlightJson } from '@/lib/json-highlight'; +import { + extractSeoMeta, + lintJsonLd, + lintSeo, + summarizeJsonLd, + type JsonLdIssue, + type SeoIssue, + type SeoMeta, +} from '@/lib/seo'; +import { useStore } from '../store'; +import { Icon } from './Icon'; const TONE: Record = { error: 'cs-seo-issue-error', @@ -7,6 +19,20 @@ const TONE: Record = { info: 'cs-seo-issue-info', }; +const SEVERITY_RANK: Record = { error: 0, warn: 1, info: 2 }; + +/** Pick the most important severity from a list of issues. */ +function worstSeverity( + issues: readonly { severity: SeoIssue['severity'] }[] +): SeoIssue['severity'] | null { + if (issues.length === 0) return null; + return issues.reduce( + (worst, issue) => + SEVERITY_RANK[issue.severity] < SEVERITY_RANK[worst] ? issue.severity : worst, + 'info' + ); +} + function CardPreview({ meta, kind }: { meta: SeoMeta; kind: 'twitter' | 'facebook' }) { const title = (kind === 'twitter' && meta.twitter['twitter:title']) || meta.og['og:title'] || meta.title; @@ -41,6 +67,180 @@ function CardPreview({ meta, kind }: { meta: SeoMeta; kind: 'twitter' | 'faceboo ); } +function JsonLdBlockCard({ + block, + index, + issues, +}: { + block: unknown; + index: number; + issues: readonly JsonLdIssue[]; +}) { + const pushToast = useStore((s) => s.pushToast); + // Open by default if the block has any errors so the user immediately + // sees what's wrong without an extra click. + const hasError = issues.some((i) => i.severity === 'error'); + const [open, setOpen] = useState(hasError); + const summary = summarizeJsonLd(block); + const headerSeverity = worstSeverity(issues); + + const onCopy = async (e: React.MouseEvent) => { + // Prevent the click from toggling the
open state. + e.preventDefault(); + e.stopPropagation(); + const text = + summary.invalid && isInvalidBlock(block) ? (block.raw ?? '') : JSON.stringify(block, null, 2); + const ok = await copyToClipboard(text); + pushToast(ok ? `Copied JSON-LD block #${index + 1}` : 'Copy failed', ok ? 'success' : 'error'); + }; + + const cardClasses = ['cs-jsonld-card']; + if (summary.invalid) cardClasses.push('cs-jsonld-card-invalid'); + if (headerSeverity === 'error') cardClasses.push('cs-jsonld-card-has-error'); + else if (headerSeverity === 'warn') cardClasses.push('cs-jsonld-card-has-warn'); + + return ( +
setOpen((e.target as HTMLDetailsElement).open)} + > + + + #{index + 1} + + {summary.typeLabel} + {summary.itemCount !== null && ( + + {' '} + · {summary.itemCount} item{summary.itemCount === 1 ? '' : 's'} + + )} + + {summary.secondary && ( + + {summary.secondary} + + )} + {issues.length > 0 && ( + + {headerSeverity === 'error' ? '✕' : headerSeverity === 'warn' ? '!' : 'i'}{' '} + {issues.length} + + )} + + + {/* Body is only mounted once expanded — saves the syntax-highlight + regex pass on big @graph payloads when the user never opens them. */} + {open && ( + <> + {issues.length > 0 && ( +
    + {issues.map((issue, i) => ( +
  • + {issue.severity} + + {issue.message} + {issue.path && ( + + {issue.path} + + )} + +
  • + ))} +
+ )} + {summary.invalid && isInvalidBlock(block) ? ( +
+

+ This block isn’t valid JSON. Showing the raw script contents: +

+
{block.raw ?? '(empty)'}
+
+ ) : ( +
+          )}
+        
+      )}
+    
+ ); +} + +function isInvalidBlock(block: unknown): block is { __invalid: true; raw: string | null } { + return ( + typeof block === 'object' && + block !== null && + (block as { __invalid?: unknown }).__invalid === true + ); +} + +function JsonLdSection({ blocks }: { blocks: readonly unknown[] }) { + // Lint once at the section level and group by blockIndex so each card + // only re-renders when its own slice of issues changes. Issues for the + // duplicate-@id check (which span multiple blocks) attach to the + // earliest block by design — see lintJsonLd. + const issuesByBlock = useMemo(() => { + const all = lintJsonLd(blocks); + const map = new Map(); + for (const issue of all) { + const list = map.get(issue.blockIndex) ?? []; + list.push(issue); + map.set(issue.blockIndex, list); + } + return map; + }, [blocks]); + + if (blocks.length === 0) return null; + + const errorCount = [...issuesByBlock.values()] + .flat() + .filter((i) => i.severity === 'error').length; + const warnCount = [...issuesByBlock.values()].flat().filter((i) => i.severity === 'warn').length; + + return ( +
+

+ Structured data (JSON-LD){' '} + + · {blocks.length} block{blocks.length === 1 ? '' : 's'} + {errorCount > 0 && ( + + ✕ {errorCount} error{errorCount === 1 ? '' : 's'} + + )} + {warnCount > 0 && ( + + ! {warnCount} warning{warnCount === 1 ? '' : 's'} + + )} + +

+
+ {blocks.map((block, i) => ( + + ))} +
+
+ ); +} + export function SeoTab() { const [meta, setMeta] = useState(() => extractSeoMeta()); @@ -81,7 +281,16 @@ export function SeoTab() {
JSON-LD
-
{meta.jsonLd.length} block(s)
+
+ {meta.jsonLd.length === 0 ? ( + none + ) : ( + <> + {meta.jsonLd.length} block{meta.jsonLd.length === 1 ? '' : 's'}{' '} + (see below) + + )} +
@@ -96,6 +305,8 @@ export function SeoTab() { + + {issues.length > 0 && (

Issues ({issues.length})

diff --git a/src/content/panel/styles.css b/src/content/panel/styles.css index d029b8a..8891efa 100644 --- a/src/content/panel/styles.css +++ b/src/content/panel/styles.css @@ -1196,3 +1196,230 @@ background: var(--cs-accent-bg); color: var(--cs-accent); } + +/* ============================================================ + JSON-LD collapsible cards (SEO tab → Structured data section) + ============================================================ */ + +.cs-section-count { + font-weight: 400; + color: var(--cs-text-subtle); + font-size: 11px; +} + +.cs-jsonld-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cs-jsonld-card { + border: 1px solid var(--cs-border); + border-radius: 6px; + background: var(--cs-bg-elevated); + overflow: hidden; +} + +.cs-jsonld-card[open] { + border-color: var(--cs-accent); +} + +.cs-jsonld-card-invalid, +.cs-jsonld-card-has-error { + border-color: var(--cs-danger); +} + +.cs-jsonld-card-has-warn { + border-color: var(--cs-warning); +} + +.cs-jsonld-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + list-style: none; + user-select: none; + font-size: 12px; + color: var(--cs-text); +} + +/* Hide the default disclosure triangle (Chrome / Safari / Firefox). */ +.cs-jsonld-summary::-webkit-details-marker { + display: none; +} + +.cs-jsonld-summary::marker { + display: none; + content: ''; +} + +.cs-jsonld-summary:hover { + background: var(--cs-bg-hover); +} + +.cs-jsonld-chevron { + display: inline-block; + width: 10px; + font-size: 9px; + color: var(--cs-text-subtle); + transition: transform 0.12s ease; + text-align: center; + flex-shrink: 0; +} + +.cs-jsonld-card[open] .cs-jsonld-chevron { + transform: rotate(90deg); +} + +.cs-jsonld-index { + font-family: var(--cs-mono); + font-size: 10px; + color: var(--cs-text-subtle); + background: var(--cs-bg); + border: 1px solid var(--cs-border); + border-radius: 3px; + padding: 1px 4px; + flex-shrink: 0; +} + +.cs-jsonld-type { + font-weight: 600; + color: var(--cs-accent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + max-width: 60%; +} + +.cs-jsonld-card-invalid .cs-jsonld-type { + color: var(--cs-danger); +} + +.cs-jsonld-count { + font-weight: 400; + color: var(--cs-text-subtle); +} + +.cs-jsonld-secondary { + font-size: 11px; + color: var(--cs-text-muted); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cs-jsonld-copy { + margin-left: auto; + flex-shrink: 0; +} + +.cs-jsonld-body { + margin: 0; + border-top: 1px solid var(--cs-border); + border-radius: 0; + max-height: 360px; + overflow: auto; + font-size: 11px; +} + +.cs-jsonld-error { + margin: 0; + padding: 8px 10px; + font-size: 11px; + color: var(--cs-danger); + border-bottom: 1px solid var(--cs-border); +} + +.cs-jsonld-raw { + margin: 0; + padding: 8px; + background: var(--cs-bg); + font-family: var(--cs-mono); + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + color: var(--cs-text-muted); +} + +/* Severity badge in the card summary + section-level aggregate counters. */ + +.cs-jsonld-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + flex-shrink: 0; + font-family: var(--cs-mono); + letter-spacing: 0.02em; +} + +.cs-jsonld-badge-inline { + margin-left: 8px; +} + +.cs-jsonld-badge-error { + background: rgba(220, 38, 38, 0.15); + color: var(--cs-danger); +} + +.cs-jsonld-badge-warn { + background: rgba(217, 119, 6, 0.15); + color: var(--cs-warning); +} + +.cs-jsonld-badge-info { + background: var(--cs-accent-bg); + color: var(--cs-accent); +} + +/* Issues list rendered above the JSON pre when a card is expanded. + Reuses the existing .cs-seo-issue tone classes for visual consistency + with the page-level Issues section, but styled tighter for inline use. */ + +.cs-jsonld-issues { + list-style: none; + margin: 0; + padding: 6px 8px; + border-top: 1px solid var(--cs-border); + background: var(--cs-bg); + display: flex; + flex-direction: column; + gap: 4px; +} + +.cs-jsonld-issues .cs-seo-issue { + padding: 5px 8px; + font-size: 11px; + line-height: 1.4; + align-items: flex-start; +} + +.cs-jsonld-issue-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.cs-jsonld-issue-path { + font-family: var(--cs-mono); + font-size: 10px; + color: var(--cs-text-subtle); + background: var(--cs-bg-elevated); + padding: 1px 4px; + border-radius: 3px; + align-self: flex-start; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/lib/json-highlight.ts b/src/lib/json-highlight.ts new file mode 100644 index 0000000..cc9a008 --- /dev/null +++ b/src/lib/json-highlight.ts @@ -0,0 +1,45 @@ +/** + * Pretty-prints a value as syntax-highlighted HTML, suitable for use with + * `dangerouslySetInnerHTML` inside a `
`.
+ *
+ * Used by the JSON tab and the SEO tab's JSON-LD viewer; centralized here so
+ * both surfaces look identical and pick up CSS theme tokens from a single
+ * place (`.cs-json-key`, `.cs-json-string`, etc.).
+ *
+ * The HTML output is XSS-safe — every `&`, `<`, `>` in the source value is
+ * escaped before the regex tags strings/numbers/booleans/null. The regex
+ * never produces tags that aren't ``.
+ *
+ * Note: the original implementation used a zero-width lookahead `(?=\s*:)`
+ * to detect keys, then checked `match.endsWith(':')` in the callback —
+ * which never fired because the colon was outside the match. As a result,
+ * keys silently rendered with the same green as string values for the
+ * lifetime of the JSON tab. This version captures the colon inside the
+ * match, so keys now correctly pick up `.cs-json-key`.
+ */
+export function highlightJson(value: unknown): string {
+  const json = JSON.stringify(value, null, 2);
+  if (json === undefined) return '';
+  return json
+    .replace(/&/g, '&')
+    .replace(//g, '>')
+    .replace(
+      /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"\s*:)|("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")|(\b(?:true|false)\b)|(\bnull\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
+      (match: string) => {
+        if (match.endsWith(':')) {
+          return `${match}`;
+        }
+        if (match.startsWith('"')) {
+          return `${match}`;
+        }
+        if (match === 'true' || match === 'false') {
+          return `${match}`;
+        }
+        if (match === 'null') {
+          return `${match}`;
+        }
+        return `${match}`;
+      }
+    );
+}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
index 902c8bb..3d7747d 100644
--- a/src/lib/seo.ts
+++ b/src/lib/seo.ts
@@ -57,6 +57,93 @@ function readJsonLd(doc: Document): unknown[] {
   return blocks;
 }
 
+/**
+ * One-line description of a JSON-LD block, suitable for the collapsed
+ * card header in the SEO tab. Tries hard to extract the most useful
+ * signal — `@type` (or list of types in a `@graph`) plus a name/headline
+ * if one is present — and falls back to a generic label when the block
+ * doesn't follow the schema.org conventions.
+ */
+export interface JsonLdSummary {
+  readonly typeLabel: string;
+  readonly secondary: string | null;
+  readonly invalid: boolean;
+  readonly itemCount: number | null;
+}
+
+const MAX_SECONDARY = 80;
+
+function isRecord(value: unknown): value is Record {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Schema.org `@type` values can be a string, an array of strings, or
+ * occasionally a nested array. Normalize into a clean string list.
+ */
+function readTypes(node: unknown): string[] {
+  if (!isRecord(node)) return [];
+  const raw = node['@type'];
+  if (typeof raw === 'string') return [raw];
+  if (Array.isArray(raw)) return raw.filter((t): t is string => typeof t === 'string');
+  return [];
+}
+
+/**
+ * Pick the best human-readable label out of the common name-ish fields.
+ * `headline` is the standard for `Article`/`NewsArticle`; `name` is the
+ * fallback for almost everything else; `url` is a last resort.
+ */
+function readSecondary(node: unknown): string | null {
+  if (!isRecord(node)) return null;
+  for (const field of ['headline', 'name', 'title', 'url'] as const) {
+    const v = node[field];
+    if (typeof v === 'string' && v.trim()) {
+      const trimmed = v.trim();
+      return trimmed.length > MAX_SECONDARY ? `${trimmed.slice(0, MAX_SECONDARY - 1)}…` : trimmed;
+    }
+  }
+  return null;
+}
+
+export function summarizeJsonLd(block: unknown): JsonLdSummary {
+  if (isRecord(block) && block.__invalid === true) {
+    return { typeLabel: 'Invalid JSON', secondary: null, invalid: true, itemCount: null };
+  }
+
+  // `@graph` is the canonical "bag of multiple top-level entities" pattern
+  // — Yoast and Rank Math both emit it. Show the count + the unique types
+  // in the graph so the user knows what they're about to expand.
+  if (isRecord(block) && Array.isArray(block['@graph'])) {
+    const items = block['@graph'];
+    const types = new Set();
+    for (const item of items) for (const t of readTypes(item)) types.add(t);
+    const typeLabel =
+      types.size === 0
+        ? '@graph'
+        : `@graph (${[...types].sort().slice(0, 4).join(', ')}${types.size > 4 ? '…' : ''})`;
+    return { typeLabel, secondary: null, invalid: false, itemCount: items.length };
+  }
+
+  // A bare top-level array of entities — unusual but valid.
+  if (Array.isArray(block)) {
+    const types = new Set();
+    for (const item of block) for (const t of readTypes(item)) types.add(t);
+    const typeLabel =
+      types.size === 0 ? 'Array' : `Array (${[...types].sort().slice(0, 4).join(', ')})`;
+    return { typeLabel, secondary: null, invalid: false, itemCount: block.length };
+  }
+
+  const types = readTypes(block);
+  const typeLabel = types.length === 0 ? 'Untyped object' : types.join(' / ');
+  return {
+    typeLabel,
+    secondary: readSecondary(block),
+    invalid: false,
+    itemCount: null,
+  };
+}
+
 export function extractSeoMeta(doc: Document = document): SeoMeta {
   return {
     title: doc.title ?? '',
@@ -156,3 +243,374 @@ export function lintSeo(meta: SeoMeta): SeoIssue[] {
 
   return issues;
 }
+
+/* ============================================================
+   JSON-LD validator
+   ============================================================ */
+
+export interface JsonLdIssue {
+  /** Stable rule id, e.g. `missing-context`. Useful for tests + telemetry. */
+  readonly code: string;
+  readonly severity: 'info' | 'warn' | 'error';
+  readonly message: string;
+  /** Index into the `meta.jsonLd` array — i.e. which `