From d79b5ece5efc97cefb7dc5801a6734db898b4f65 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 29 Jun 2026 22:11:41 +0200 Subject: [PATCH 1/7] feat: Add structured data audit features including JSON-LD and Schema.org viewers --- src/view/frontend/web/css/toolbar.css | 1 + .../web/css/toolbar/_jsonld-viewer.css | 218 ++++++++++++++++ .../frontend/web/css/toolbar/_variables.css | 1 + .../frontend/web/js/toolbar/audits/index.js | 9 +- .../js/toolbar/audits/schema-org-viewer.js | 242 ++++++++++++++++++ .../js/toolbar/audits/seo-json-ld-viewer.js | 184 +++++++++++++ src/view/frontend/web/js/toolbar/ui/build.js | 2 +- .../frontend/web/js/toolbar/ui/constants.js | 2 + 8 files changed, 656 insertions(+), 3 deletions(-) create mode 100644 src/view/frontend/web/css/toolbar/_jsonld-viewer.css create mode 100644 src/view/frontend/web/js/toolbar/audits/schema-org-viewer.js create mode 100644 src/view/frontend/web/js/toolbar/audits/seo-json-ld-viewer.js diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index 6a99b0a4..5abb2a9e 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -14,6 +14,7 @@ @import url("toolbar/_menu.css"); @import url("toolbar/_groups.css"); @import url("toolbar/_findings.css"); +@import url("toolbar/_jsonld-viewer.css"); @import url("toolbar/_highlights.css"); @import url("toolbar/_feedback.css"); @import url("toolbar/_animations.css"); diff --git a/src/view/frontend/web/css/toolbar/_jsonld-viewer.css b/src/view/frontend/web/css/toolbar/_jsonld-viewer.css new file mode 100644 index 00000000..d8b97594 --- /dev/null +++ b/src/view/frontend/web/css/toolbar/_jsonld-viewer.css @@ -0,0 +1,218 @@ +/** + * MageForge Toolbar – JSON-LD Viewer styles + * + * @package OpenForgeProject\MageForge + * @license GPL-3.0 + */ + +.mageforge-jsonld-viewer-open { + padding: 0 !important; + border-top: none !important; +} + +.mageforge-jsonld-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--mageforge-border-color); + font-size: 11px; + font-weight: 500; +} + +.mageforge-jsonld-count { + color: var(--mageforge-text-secondary, rgba(148, 163, 184, 0.8)); +} + +.mageforge-jsonld-errors { + color: var(--mageforge-color-red); + background: var(--mageforge-color-red-alpha-15); + padding: 1px 6px; + border-radius: 4px; +} + +.mageforge-jsonld-empty { + padding: 12px; + margin: 0; + font-size: 11px; + color: var(--mageforge-color-amber); + font-style: italic; +} + +.mageforge-jsonld-block { + border-bottom: 1px solid var(--mageforge-border-color); +} + +.mageforge-jsonld-block:last-child { + border-bottom: none; +} + +.mageforge-jsonld-block--error .mageforge-jsonld-block-header { + color: var(--mageforge-color-red); +} + +.mageforge-jsonld-block-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: var(--mageforge-text-primary, rgba(248, 250, 252, 0.9)); + font-size: 11px; + font-weight: 500; + font-family: var(--mageforge-font-family); + transition: background 0.15s ease; +} + +.mageforge-jsonld-block-header:hover { + background: var(--mageforge-surface-glass-hover); +} + +.mageforge-jsonld-block-type { + display: flex; + align-items: center; + gap: 6px; +} + +.mageforge-jsonld-chevron { + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease; +} + +.mageforge-jsonld-chevron--open { + transform: rotate(180deg); + opacity: 0.9; +} + +.mageforge-jsonld-block-content { + padding: 0 12px 10px; + position: relative; +} + +.mageforge-jsonld-parse-error { + margin: 4px 0 0; + padding: 6px 8px; + background: var(--mageforge-color-red-alpha-15); + border: 1px solid var(--mageforge-color-red-alpha-35); + border-radius: 4px; + font-size: 10px; + color: var(--mageforge-color-red); + word-break: break-word; +} + +.mageforge-jsonld-pre { + margin: 0; + padding: 8px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--mageforge-border-color); + border-radius: 4px; + overflow: auto; + max-height: 260px; + font-size: 10px; + line-height: 1.6; +} + +.mageforge-jsonld-pre code { + font-family: + "JetBrains Mono", "Fira Code", "Cascadia Code", Consolas, "Courier New", + monospace; + color: var(--mageforge-text-primary, rgba(248, 250, 252, 0.9)); + white-space: pre; +} + +.mageforge-jsonld-copy-btn { + display: block; + margin-top: 6px; + padding: 3px 10px; + background: var(--mageforge-surface-glass); + border: 1px solid var(--mageforge-border-glass); + border-radius: 4px; + color: var(--mageforge-color-slate-400); + font-size: 10px; + font-family: var(--mageforge-font-family); + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; +} + +.mageforge-jsonld-copy-btn:hover { + background: var(--mageforge-surface-glass-hover); + color: var(--mageforge-color-white); +} + +/* ── Schema.org Viewer ──────────────────────────────────────────────────── */ + +.mageforge-schema-badge { + display: inline-block; + padding: 1px 5px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + background: rgba(99, 102, 241, 0.15); + color: #818cf8; + border: 1px solid rgba(99, 102, 241, 0.3); + line-height: 1.5; +} + +.mageforge-schema-badge--rdfa { + background: rgba(251, 146, 60, 0.15); + color: var(--mageforge-color-orange); + border-color: rgba(251, 146, 60, 0.3); +} + +.mageforge-schema-type-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0 6px; + border-bottom: 1px solid var(--mageforge-border-color); + margin-bottom: 4px; +} + +.mageforge-schema-type-link { + font-size: 10px; + color: #818cf8; + text-decoration: none; + word-break: break-all; +} + +.mageforge-schema-type-link:hover { + text-decoration: underline; +} + +.mageforge-schema-props { + margin: 4px 0 0; + display: grid; + grid-template-columns: minmax(80px, max-content) 1fr; + gap: 2px 10px; + align-items: baseline; +} + +.mageforge-schema-prop-name { + font-size: 10px; + font-weight: 600; + color: #818cf8; + white-space: nowrap; + padding: 2px 0; +} + +.mageforge-schema-prop-value { + font-size: 10px; + color: var(--mageforge-text-primary, rgba(248, 250, 252, 0.9)); + word-break: break-word; + padding: 2px 0; + margin: 0; +} + +.mageforge-schema-prop-nested { + color: var(--mageforge-color-slate-400); + font-style: italic; +} diff --git a/src/view/frontend/web/css/toolbar/_variables.css b/src/view/frontend/web/css/toolbar/_variables.css index 12d22b87..05ec86b9 100644 --- a/src/view/frontend/web/css/toolbar/_variables.css +++ b/src/view/frontend/web/css/toolbar/_variables.css @@ -38,6 +38,7 @@ --mageforge-group-color-html-quality: var(--mageforge-color-blue); --mageforge-group-color-performance: var(--mageforge-color-orange); --mageforge-group-color-seo: #14b8a6; + --mageforge-group-color-structured-data: #6366f1; /* Backgrounds */ --mageforge-bg-dark: rgba(15, 23, 42, 0.98); diff --git a/src/view/frontend/web/js/toolbar/audits/index.js b/src/view/frontend/web/js/toolbar/audits/index.js index 989dcc2c..d759ee36 100644 --- a/src/view/frontend/web/js/toolbar/audits/index.js +++ b/src/view/frontend/web/js/toolbar/audits/index.js @@ -38,7 +38,9 @@ import renderBlockingScripts from "./render-blocking-scripts.js"; import seoDuplicateMeta from "./seo-duplicate-meta.js"; import seoHeadingHierarchy from "./seo-heading-hierarchy.js"; import seoMissingCanonical from "./seo-missing-canonical.js"; -import seoMissingJsonLd from "./seo-missing-json-ld.js"; +import structuredMissingJsonLd from "./seo-missing-json-ld.js"; +import structuredJsonLdViewer from "./seo-json-ld-viewer.js"; +import schemaOrgViewer from "./schema-org-viewer.js"; import seoMissingLang from "./seo-missing-lang.js"; import seoMissingMetaDescription from "./seo-missing-meta-description.js"; import seoMissingTitle from "./seo-missing-title.js"; @@ -50,6 +52,7 @@ import unsafeBlankTarget from "./unsafe-blank-target.js"; /** @type {AuditGroup[]} */ export const auditGroups = [ { key: "seo", label: "SEO" }, + { key: "structured-data", label: "Structured Data" }, { key: "performance", label: "Performance" }, { key: "html-quality", label: "HTML Quality" }, { key: "wcag", label: "Accessibility" }, @@ -79,6 +82,8 @@ export const audits = [ { ...seoMissingCanonical, group: "seo" }, { ...seoMissingLang, group: "seo" }, { ...seoHeadingHierarchy, group: "seo" }, - { ...seoMissingJsonLd, group: "seo" }, { ...seoDuplicateMeta, group: "seo" }, + { ...structuredMissingJsonLd, group: "structured-data" }, + { ...structuredJsonLdViewer, group: "structured-data" }, + { ...schemaOrgViewer, group: "structured-data" }, ]; diff --git a/src/view/frontend/web/js/toolbar/audits/schema-org-viewer.js b/src/view/frontend/web/js/toolbar/audits/schema-org-viewer.js new file mode 100644 index 00000000..34b4ce8e --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/schema-org-viewer.js @@ -0,0 +1,242 @@ +/** + * MageForge Toolbar Audit – Schema.org Microdata Viewer + * + * Reads all schema.org microdata (itemscope / itemtype / itemprop attributes) + * from the current page DOM and renders a structured tree view in the findings + * panel. Also detects RDFa (typeof / property attributes). + * + * Page-level audit: no DOM element highlighting, findings panel shows the data. + */ + +const KEY = "schema-org-viewer"; + +/** + * Recursively collect microdata properties from an itemscope element. + * + * @param {Element} root + * @returns {{ type: string, props: Array<{name: string, value: string, nested?: object}> }} + */ +function collectMicrodata(root) { + const type = root.getAttribute("itemtype") ?? ""; + const props = []; + + root.querySelectorAll("[itemprop]").forEach((el) => { + // Skip deeply nested items already handled by their own itemscope + const closestScope = el.parentElement?.closest("[itemscope]"); + if (closestScope && closestScope !== root) return; + + const name = el.getAttribute("itemprop") ?? ""; + let value = ""; + let nested = null; + + if (el.hasAttribute("itemscope")) { + nested = collectMicrodata(el); + } else if (el.tagName === "META") { + value = el.getAttribute("content") ?? ""; + } else if (el.tagName === "LINK") { + value = el.getAttribute("href") ?? ""; + } else if (el.tagName === "IMG") { + value = el.getAttribute("src") ?? ""; + } else if (el.tagName === "TIME") { + value = el.getAttribute("datetime") ?? el.textContent.trim(); + } else if (el.tagName === "A") { + value = el.getAttribute("href") ?? el.textContent.trim(); + } else { + value = el.textContent.trim().slice(0, 120); + } + + props.push({ name, value, nested }); + }); + + return { type, props }; +} + +/** + * Collect RDFa-annotated root elements (typeof attribute on non-nested elements). + * + * @returns {Array<{type: string, el: Element}>} + */ +function collectRdfa() { + return Array.from(document.querySelectorAll("[typeof]")) + .filter((el) => !el.parentElement?.closest("[typeof]")) + .map((el) => ({ + type: el.getAttribute("typeof") ?? "", + el, + })); +} + +/** + * Build a DOM tree for a collected microdata item. + * + * @param {{ type: string, props: Array }} item + * @param {string} badgeLabel + * @returns {HTMLElement} + */ +function buildBlockDOM(item, badgeLabel) { + const typeShort = item.type.replace(/https?:\/\/schema\.org\//i, ""); + + const block = document.createElement("div"); + block.className = "mageforge-jsonld-block"; + + const header = document.createElement("button"); + header.type = "button"; + header.className = "mageforge-jsonld-block-header"; + header.setAttribute("aria-expanded", "false"); + header.innerHTML = ` + + + ${typeShort || "Unknown Type"} + ${badgeLabel} + + + `; + + const content = document.createElement("div"); + content.className = "mageforge-jsonld-block-content"; + content.hidden = true; + + if (item.type) { + const typeRow = document.createElement("div"); + typeRow.className = "mageforge-schema-type-row"; + typeRow.innerHTML = `@type${item.type}`; + content.appendChild(typeRow); + } + + if (item.props.length === 0) { + const empty = document.createElement("p"); + empty.className = "mageforge-jsonld-empty"; + empty.textContent = "No itemprop attributes found."; + content.appendChild(empty); + } else { + const table = document.createElement("dl"); + table.className = "mageforge-schema-props"; + item.props.forEach(({ name, value, nested }) => { + const dt = document.createElement("dt"); + dt.className = "mageforge-schema-prop-name"; + dt.textContent = name; + table.appendChild(dt); + + const dd = document.createElement("dd"); + dd.className = "mageforge-schema-prop-value"; + if (nested) { + const nestedType = nested.type.replace(/https?:\/\/schema\.org\//i, ""); + dd.textContent = `[${nestedType || "Nested item"} — ${nested.props.length} prop${nested.props.length !== 1 ? "s" : ""}]`; + dd.classList.add("mageforge-schema-prop-nested"); + } else { + dd.textContent = value || "—"; + } + table.appendChild(dd); + }); + content.appendChild(table); + } + + header.onclick = (e) => { + e.stopPropagation(); + const isOpen = !content.hidden; + content.hidden = isOpen; + header.setAttribute("aria-expanded", String(!isOpen)); + header + .querySelector(".mageforge-jsonld-chevron") + .classList.toggle("mageforge-jsonld-chevron--open", !isOpen); + }; + + block.appendChild(header); + block.appendChild(content); + return block; +} + +export default { + key: KEY, + icon: '', + label: "Schema.org Viewer", + description: + "Shows all schema.org microdata (itemscope/itemprop) and RDFa blocks", + + run(context, active) { + const auditItem = context.menu?.querySelector(`[data-audit-key="${KEY}"]`); + const findingsContainer = auditItem?.querySelector( + ".mageforge-audit-findings", + ); + + if (!active) { + if (findingsContainer) { + findingsContainer.innerHTML = ""; + findingsContainer.classList.remove( + "mageforge-has-findings", + "mageforge-findings-open", + "mageforge-jsonld-viewer-open", + ); + } + return; + } + + // Collect microdata root elements (not nested) + const microdataRoots = Array.from( + document.querySelectorAll("[itemscope][itemtype]"), + ).filter((el) => !el.parentElement?.closest("[itemscope]")); + + const rdfaRoots = collectRdfa(); + const total = microdataRoots.length + rdfaRoots.length; + + context.setAuditCounterBadge( + KEY, + String(total), + total > 0 ? "success" : "warning", + ); + + if (!findingsContainer) return; + + findingsContainer.innerHTML = ""; + findingsContainer.classList.add( + "mageforge-has-findings", + "mageforge-findings-open", + "mageforge-jsonld-viewer-open", + ); + + if (total === 0) { + const empty = document.createElement("p"); + empty.className = "mageforge-jsonld-empty"; + empty.textContent = "No schema.org microdata or RDFa found on this page."; + findingsContainer.appendChild(empty); + return; + } + + const summary = document.createElement("div"); + summary.className = "mageforge-jsonld-summary"; + summary.innerHTML = ` + ${microdataRoots.length > 0 ? `${microdataRoots.length} microdata item${microdataRoots.length !== 1 ? "s" : ""}` : ""} + ${rdfaRoots.length > 0 ? `RDFa: ${rdfaRoots.length}` : ""} + `; + findingsContainer.appendChild(summary); + + microdataRoots.forEach((el) => { + const item = collectMicrodata(el); + findingsContainer.appendChild(buildBlockDOM(item, "Microdata")); + }); + + rdfaRoots.forEach(({ type, el }) => { + const typeShort = type.replace(/https?:\/\/schema\.org\//i, ""); + const props = Array.from(el.querySelectorAll("[property]")) + .filter( + (p) => + !p.parentElement?.closest("[typeof]") || + p.closest("[typeof]") === el, + ) + .map((p) => ({ + name: p.getAttribute("property") ?? "", + value: + p.getAttribute("content") ?? + p.getAttribute("href") ?? + p.textContent.trim().slice(0, 120), + nested: null, + })); + + findingsContainer.appendChild( + buildBlockDOM( + { type: `https://schema.org/${typeShort}`, props }, + "RDFa", + ), + ); + }); + }, +}; diff --git a/src/view/frontend/web/js/toolbar/audits/seo-json-ld-viewer.js b/src/view/frontend/web/js/toolbar/audits/seo-json-ld-viewer.js new file mode 100644 index 00000000..2f5ed0bf --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/seo-json-ld-viewer.js @@ -0,0 +1,184 @@ +/** + * MageForge Toolbar Audit – JSON-LD Viewer + * + * Reads all