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/_groups.css b/src/view/frontend/web/css/toolbar/_groups.css index 61481bc5..df65e73f 100644 --- a/src/view/frontend/web/css/toolbar/_groups.css +++ b/src/view/frontend/web/css/toolbar/_groups.css @@ -102,6 +102,11 @@ background: rgba(20, 184, 166, 0.1); } +.mageforge-toolbar-tab-btn[data-tab="structured-data"].mageforge-tab-active { + color: var(--mageforge-group-color-structured-data); + background: rgba(217, 70, 239, 0.1); +} + /* Left-edge indicator bar on active tab */ .mageforge-toolbar-tab-btn.mageforge-tab-active::before { @@ -144,6 +149,10 @@ color: var(--mageforge-group-color-seo); } +.mageforge-toolbar-tab-btn[data-tab="structured-data"] .mageforge-tab-icon { + color: var(--mageforge-group-color-structured-data); +} + .mageforge-tab-label { display: block; white-space: normal; @@ -578,3 +587,8 @@ .mageforge-toolbar-menu-icon { color: var(--mageforge-group-color-seo); } + +.mageforge-toolbar-menu-item[data-group-key="structured-data"] + .mageforge-toolbar-menu-icon { + color: var(--mageforge-group-color-structured-data); +} 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..26279d55 --- /dev/null +++ b/src/view/frontend/web/css/toolbar/_jsonld-viewer.css @@ -0,0 +1,287 @@ +/** + * 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-warnings { + color: var(--mageforge-color-amber); + background: var(--mageforge-color-amber-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--warning .mageforge-jsonld-block-header { + color: var(--mageforge-color-amber); +} + +/* Validation badge in block header */ +.mageforge-jsonld-val-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 5px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + line-height: 1.5; + vertical-align: middle; +} + +.mageforge-jsonld-val-badge--error { + background: var(--mageforge-color-red-alpha-15); + color: var(--mageforge-color-red); + border: 1px solid var(--mageforge-color-red-alpha-35); +} + +.mageforge-jsonld-val-badge--warning { + background: var(--mageforge-color-amber-alpha-15); + color: var(--mageforge-color-amber); + border: 1px solid var(--mageforge-color-amber-alpha-35); +} + +/* Validation issues list */ +.mageforge-jsonld-issues { + list-style: none; + margin: 0 0 8px; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.mageforge-jsonld-issue { + display: flex; + align-items: flex-start; + gap: 5px; + font-size: 10px; + line-height: 1.4; +} + +.mageforge-jsonld-issue svg { + flex-shrink: 0; + margin-top: 1px; +} + +.mageforge-jsonld-issue--error { + color: var(--mageforge-color-red); +} + +.mageforge-jsonld-issue--warning { + color: var(--mageforge-color-amber); +} + +.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: var(--mageforge-color-fuchsia-alpha-15); + color: var(--mageforge-color-fuchsia-light); + border: 1px solid var(--mageforge-color-fuchsia-alpha-30); + 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: var(--mageforge-color-fuchsia-light); + 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: var(--mageforge-color-fuchsia-light); + 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..0e37e12b 100644 --- a/src/view/frontend/web/css/toolbar/_variables.css +++ b/src/view/frontend/web/css/toolbar/_variables.css @@ -38,6 +38,11 @@ --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: #d946ef; + --mageforge-color-fuchsia: #d946ef; + --mageforge-color-fuchsia-alpha-15: rgba(217, 70, 239, 0.15); + --mageforge-color-fuchsia-alpha-30: rgba(217, 70, 239, 0.3); + --mageforge-color-fuchsia-light: #e879f9; /* 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..ad143d8c --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/schema-org-viewer.js @@ -0,0 +1,269 @@ +/** + * 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"); + // Static SVG only – dynamic text set via textContent (XSS-safe) + header.innerHTML = ` + + + + + + + `; + header.querySelector(".mageforge-schema-type").textContent = + typeShort || "Unknown Type"; + header.querySelector(".mageforge-schema-badge").textContent = 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"; + const typePropName = document.createElement("span"); + typePropName.className = "mageforge-schema-prop-name"; + typePropName.textContent = "@type"; + typeRow.appendChild(typePropName); + // Only linkify http(s) URLs to prevent javascript: injection + if (/^https?:\/\//i.test(item.type)) { + const typeLink = document.createElement("a"); + typeLink.className = "mageforge-schema-type-link"; + typeLink.href = item.type; + typeLink.target = "_blank"; + typeLink.rel = "noopener noreferrer"; + typeLink.textContent = item.type; + typeRow.appendChild(typeLink); + } else { + const typeText = document.createElement("span"); + typeText.className = "mageforge-schema-type-link"; + typeText.textContent = item.type; + typeRow.appendChild(typeText); + } + 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 }) => { + // Normalise to a full schema.org URL only when the value is already one; + // CURIEs (e.g. "schema:Product") or plain names are passed through as-is + // to avoid producing broken URLs like https://schema.org/schema:Product. + const normalizedType = /^https?:\/\//i.test(type) + ? type + : type.includes(":") + ? type // CURIE – leave untouched + : `https://schema.org/${type}`; + + 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: normalizedType, 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..a4f7bf0b --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/seo-json-ld-viewer.js @@ -0,0 +1,713 @@ +/** + * MageForge Toolbar Audit – JSON-LD Viewer + * + * Reads all