@focusgts/eds-mcp-server
Advanced tools
| /** | ||
| * Site-health report (ADR-014) — render an {@link AuditReport} as a | ||
| * self-contained, theme-aware HTML document (inline CSS + inline SVG gauges, no | ||
| * external assets, no dependencies). The value of the audit, made visible and | ||
| * shareable. | ||
| * | ||
| * The health score is derived transparently from the findings (not a fabricated | ||
| * metric) and the report says so; dimensions that could not run are shown as | ||
| * "not run", never as a misleading score. | ||
| */ | ||
| import { type AuditReport } from './types.js'; | ||
| /** Render an audit report as a self-contained HTML document. */ | ||
| export declare function generateReport(report: AuditReport, meta: { | ||
| site: string; | ||
| generatedAt: string; | ||
| }): string; |
| /** | ||
| * Site-health report (ADR-014) — render an {@link AuditReport} as a | ||
| * self-contained, theme-aware HTML document (inline CSS + inline SVG gauges, no | ||
| * external assets, no dependencies). The value of the audit, made visible and | ||
| * shareable. | ||
| * | ||
| * The health score is derived transparently from the findings (not a fabricated | ||
| * metric) and the report says so; dimensions that could not run are shown as | ||
| * "not run", never as a misleading score. | ||
| */ | ||
| import { ALL_DIMENSIONS } from './types.js'; | ||
| const DIMENSION_LABELS = { | ||
| seo: 'SEO', | ||
| accessibility: 'Accessibility', | ||
| performance: 'Performance', | ||
| freshness: 'Freshness', | ||
| links: 'Links & 404s', | ||
| sitemap: 'Sitemap', | ||
| }; | ||
| /** Plain-English, one-line explanation of each dimension — shown on hover. */ | ||
| const DIMENSION_TIPS = { | ||
| seo: 'How easily Google can find and understand your pages — titles, descriptions, headings and page structure.', | ||
| accessibility: 'How usable your site is for people with disabilities — image alt text, form labels, headings and landmarks.', | ||
| performance: 'How fast your pages load for real visitors (Google’s Core Web Vitals), measured from Adobe’s real-user data.', | ||
| freshness: 'How recently your pages were updated — flags stale content that hasn’t been touched in a long time.', | ||
| links: 'Broken links and “page not found” (404) errors your real visitors are actually hitting.', | ||
| sitemap: 'Whether your sitemap correctly lists your pages so search engines can discover all of them.', | ||
| }; | ||
| const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 }; | ||
| function grade(score) { | ||
| if (score >= 95) | ||
| return 'A+'; | ||
| if (score >= 85) | ||
| return 'A'; | ||
| if (score >= 70) | ||
| return 'B'; | ||
| if (score >= 55) | ||
| return 'C'; | ||
| if (score >= 40) | ||
| return 'D'; | ||
| return 'F'; | ||
| } | ||
| /** Health score for a dimension, derived from its findings, normalized by pages. */ | ||
| function scoreDimension(findings, pages) { | ||
| const weighted = findings.reduce((s, f) => s + (f.severity === 'critical' ? 3 : f.severity === 'warning' ? 1 : 0.25), 0); | ||
| const perPage = weighted / Math.max(1, pages); | ||
| return Math.max(0, Math.min(100, Math.round(100 - perPage * 10))); | ||
| } | ||
| function esc(s) { | ||
| return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | ||
| } | ||
| function scoreClass(score) { | ||
| return score >= 85 ? 'good' : score >= 55 ? 'fair' : 'poor'; | ||
| } | ||
| /** Inline SVG ring gauge. Colors/type come from CSS classes so it stays theme-aware. */ | ||
| function gauge(opts) { | ||
| const c = opts.size / 2; | ||
| const r = c - opts.sw / 2 - 1; | ||
| const circ = 2 * Math.PI * r; | ||
| const off = circ * (1 - Math.max(0, Math.min(100, opts.score)) / 100); | ||
| const cls = scoreClass(opts.score); | ||
| return (`<svg class="gauge" width="${opts.size}" height="${opts.size}" viewBox="0 0 ${opts.size} ${opts.size}" aria-hidden="true">` + | ||
| `<circle class="track" cx="${c}" cy="${c}" r="${r.toFixed(1)}" stroke-width="${opts.sw}" fill="none"/>` + | ||
| `<circle class="arc ${cls}" cx="${c}" cy="${c}" r="${r.toFixed(1)}" stroke-width="${opts.sw}" fill="none" stroke-linecap="round" stroke-dasharray="${circ.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}" transform="rotate(-90 ${c} ${c})"/>` + | ||
| `<text class="gtext ${cls}" x="${c}" y="${c}" text-anchor="middle" dominant-baseline="central" style="font-size:${opts.centerSize}px">${opts.center}</text>` + | ||
| `</svg>`); | ||
| } | ||
| function groupFindings(findings) { | ||
| const map = new Map(); | ||
| for (const f of findings) { | ||
| const key = `${f.dimension}|${f.title}`; | ||
| let g = map.get(key); | ||
| if (!g) { | ||
| g = { dimension: f.dimension, severity: f.severity, title: f.title, suggestion: f.suggestion, pages: [], fixable: false }; | ||
| map.set(key, g); | ||
| } | ||
| if (f.fix) | ||
| g.fixable = true; | ||
| if (f.page && !g.pages.includes(f.page)) | ||
| g.pages.push(f.page); | ||
| } | ||
| return [...map.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || b.pages.length - a.pages.length); | ||
| } | ||
| const STYLE = ` | ||
| :root{ | ||
| --bg:#eef1f7;--panel:#ffffff;--ink:#0e1730;--muted:#5c688a;--faint:#8a95b4;--line:#e4e8f1;--track:#e7ebf4; | ||
| --accent:#5b54e6;--good:#12a150;--fair:#d18700;--poor:#e0402f; | ||
| --shadow:0 1px 2px rgba(16,24,48,.04),0 8px 24px rgba(16,24,48,.06); | ||
| --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace; | ||
| --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; | ||
| } | ||
| @media(prefers-color-scheme:dark){:root:not([data-theme="light"]){ | ||
| --bg:#080c18;--panel:#111a30;--ink:#eef2fb;--muted:#98a4c6;--faint:#6f7ca3;--line:#212d4c;--track:#1c2743; | ||
| --accent:#9a8dff;--good:#38c47f;--fair:#eab53f;--poor:#f26a5a; | ||
| --shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.35); | ||
| }} | ||
| :root[data-theme="dark"]{ | ||
| --bg:#080c18;--panel:#111a30;--ink:#eef2fb;--muted:#98a4c6;--faint:#6f7ca3;--line:#212d4c;--track:#1c2743; | ||
| --accent:#9a8dff;--good:#38c47f;--fair:#eab53f;--poor:#f26a5a; | ||
| --shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.35); | ||
| } | ||
| *{box-sizing:border-box} | ||
| body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased} | ||
| .wrap{max-width:900px;margin:0 auto;padding:40px 20px 72px} | ||
| .gauge .track{stroke:var(--track)} | ||
| .gauge .arc.good{stroke:var(--good)}.gauge .arc.fair{stroke:var(--fair)}.gauge .arc.poor{stroke:var(--poor)} | ||
| .gauge .gtext{font-family:var(--mono);font-weight:700;font-variant-numeric:tabular-nums} | ||
| .gtext.good{fill:var(--good)}.gtext.fair{fill:var(--fair)}.gtext.poor{fill:var(--poor)} | ||
| .hero{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:20px;box-shadow:var(--shadow); | ||
| padding:30px 32px;display:flex;gap:28px;align-items:center;flex-wrap:wrap;overflow:hidden} | ||
| .hero::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:linear-gradient(90deg,var(--accent),transparent 70%)} | ||
| .hero-main{flex:1;min-width:230px} | ||
| .brand{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700} | ||
| .hero h1{margin:8px 0 4px;font-size:28px;font-weight:800;letter-spacing:-.01em;text-wrap:balance;word-break:break-word} | ||
| .meta{color:var(--muted);font-size:13px} | ||
| .pills{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px} | ||
| .pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--muted); | ||
| background:var(--bg);border:1px solid var(--line);border-radius:999px;padding:4px 11px} | ||
| .pill::before{content:"";width:8px;height:8px;border-radius:50%;background:currentColor} | ||
| .pill.crit{color:var(--poor)}.pill.warn{color:var(--fair)}.pill.info{color:var(--accent)} | ||
| .hero-gauge{display:flex;flex-direction:column;align-items:center;gap:8px} | ||
| .hero-gauge .cap{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} | ||
| .section-title{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);font-weight:700;margin:34px 2px 14px} | ||
| .gauges{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:14px} | ||
| @media(max-width:560px){.gauges{grid-template-columns:repeat(2,minmax(0,1fr))}} | ||
| .ga{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow); | ||
| padding:18px 12px 16px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center; | ||
| cursor:help;outline:none;transition:border-color .14s,box-shadow .14s} | ||
| .ga:hover,.ga:focus-visible{border-color:var(--accent)} | ||
| .ga:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 30%,transparent)} | ||
| .ga .lab{font-size:13px;font-weight:600} | ||
| .ga .sub{font-size:11.5px;color:var(--muted)} | ||
| .ga.skip{justify-content:center;min-height:150px;color:var(--faint)} | ||
| .ga.skip .lab{color:var(--muted);font-size:14px} | ||
| .ga.skip .nr{font-size:11.5px;font-weight:600;color:var(--faint);text-transform:uppercase;letter-spacing:.06em} | ||
| /* Hover / focus explainer bubble */ | ||
| .ga[data-tip]::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 9px); | ||
| transform:translateX(-50%) translateY(4px);width:230px;max-width:78vw; | ||
| background:var(--ink);color:var(--panel);font-size:12px;line-height:1.45;font-weight:500;text-align:left; | ||
| padding:10px 12px;border-radius:10px;box-shadow:0 8px 24px rgba(16,24,48,.22); | ||
| opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;z-index:10} | ||
| .ga[data-tip]::before{content:"";position:absolute;left:50%;bottom:calc(100% + 3px);transform:translateX(-50%); | ||
| border:6px solid transparent;border-top-color:var(--ink);opacity:0;transition:opacity .14s;z-index:10} | ||
| .ga[data-tip]:hover::after,.ga[data-tip]:focus-visible::after{opacity:1;transform:translateX(-50%) translateY(0)} | ||
| .ga[data-tip]:hover::before,.ga[data-tip]:focus-visible::before{opacity:1} | ||
| @media(hover:none){.ga{cursor:default}} | ||
| .issue{position:relative;background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--line); | ||
| border-radius:12px;box-shadow:var(--shadow);padding:14px 16px 14px 18px;margin-bottom:10px} | ||
| .issue.critical{border-left-color:var(--poor)} | ||
| .issue.warning{border-left-color:var(--fair)} | ||
| .issue.info{border-left-color:var(--accent)} | ||
| .itop{display:flex;align-items:center;gap:9px;flex-wrap:wrap} | ||
| .sev{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:2px 8px;border-radius:6px;color:#fff} | ||
| .issue.critical .sev{background:var(--poor)}.issue.warning .sev{background:var(--fair)}.issue.info .sev{background:var(--accent)} | ||
| .chip{font-size:11px;font-weight:600;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:2px 9px} | ||
| .fixable{font-size:10.5px;font-weight:700;letter-spacing:.02em;color:var(--good); | ||
| background:color-mix(in srgb,var(--good) 12%,transparent);border:1px solid color-mix(in srgb,var(--good) 34%,transparent); | ||
| border-radius:999px;padding:2px 9px;white-space:nowrap} | ||
| .fixlead{font-size:13.5px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:12px; | ||
| box-shadow:var(--shadow);padding:12px 15px;margin-bottom:14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap} | ||
| .fixlead code,footer code{font-family:var(--mono)} | ||
| .ititle{font-weight:600} | ||
| .count{margin-left:auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap} | ||
| .fix{margin-top:7px;font-size:13.5px;color:var(--muted)} | ||
| .fix b{color:var(--ink);font-weight:600} | ||
| .pages{margin-top:8px;font-size:11.5px;color:var(--faint);font-family:var(--mono);word-break:break-word} | ||
| .clean{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow); | ||
| padding:26px;text-align:center;color:var(--good);font-weight:700;font-size:17px} | ||
| .note{font-size:12px;color:var(--faint);margin-top:8px} | ||
| footer{margin-top:34px;padding-top:18px;border-top:1px solid var(--line);color:var(--faint);font-size:12px;text-align:center;line-height:1.7} | ||
| footer a{color:var(--accent);text-decoration:none} | ||
| footer code{font-family:var(--mono);font-size:11.5px;background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 5px} | ||
| `.trim(); | ||
| /** Render an audit report as a self-contained HTML document. */ | ||
| export function generateReport(report, meta) { | ||
| const pages = report.summary.pagesAudited ?? 1; | ||
| const skippedDims = new Set(ALL_DIMENSIONS.filter((d) => report.skipped.some((s) => s.startsWith(d)))); | ||
| const perDim = ALL_DIMENSIONS.map((dim) => { | ||
| const findings = report.findings.filter((f) => f.dimension === dim); | ||
| if (skippedDims.has(dim)) | ||
| return { dim, skipped: true, score: 0, findings }; | ||
| return { dim, skipped: false, score: scoreDimension(findings, pages), findings }; | ||
| }); | ||
| const scored = perDim.filter((d) => !d.skipped); | ||
| const overall = scored.length ? Math.round(scored.reduce((s, d) => s + d.score, 0) / scored.length) : 0; | ||
| const gaugesHtml = perDim | ||
| .map((d) => { | ||
| const label = esc(DIMENSION_LABELS[d.dim]); | ||
| const tip = esc(DIMENSION_TIPS[d.dim]); | ||
| if (d.skipped) { | ||
| return (`<div class="ga skip" tabindex="0" data-tip="${tip}" aria-label="${label}: ${tip}">` + | ||
| `<div class="lab">${label}</div>` + | ||
| `<div class="nr">Not run yet</div>` + | ||
| `<div class="sub">This one reads your real-visitor data — add your site’s live domain to switch it on.</div></div>`); | ||
| } | ||
| const n = d.findings.length; | ||
| return (`<div class="ga" tabindex="0" data-tip="${tip}" aria-label="${label}: ${tip}">` + | ||
| `${gauge({ size: 92, sw: 8, score: d.score, center: String(d.score), centerSize: 26 })}` + | ||
| `<div class="lab">${label}</div>` + | ||
| `<div class="sub">Grade ${grade(d.score)} · ${n} issue${n === 1 ? '' : 's'}</div></div>`); | ||
| }) | ||
| .join(''); | ||
| // Choose a column count so the cards always split into even rows — never a | ||
| // lone orphan on its own line. Favor 3-wide, but drop to 2 for 4 cards (2×2). | ||
| const nCards = perDim.length; | ||
| const cols = nCards <= 3 ? nCards : nCards === 4 ? 2 : 3; | ||
| const groups = groupFindings(report.findings); | ||
| const fixableGroups = groups.filter((g) => g.fixable).length; | ||
| const issuesHtml = groups.length | ||
| ? groups | ||
| .slice(0, 100) | ||
| .map((g) => { | ||
| const pageList = g.pages.length | ||
| ? `<div class="pages">${g.pages.length} page${g.pages.length === 1 ? '' : 's'}: ${esc(g.pages.slice(0, 25).join(' · '))}${g.pages.length > 25 ? ' · …' : ''}</div>` | ||
| : ''; | ||
| const fix = g.suggestion ? `<div class="fix"><b>Fix</b> — ${esc(g.suggestion)}</div>` : ''; | ||
| const count = g.pages.length ? `<span class="count">${g.pages.length} page${g.pages.length === 1 ? '' : 's'}</span>` : ''; | ||
| const fixable = g.fixable | ||
| ? `<span class="fixable" title="This server can fix this for you — ask your AI agent to apply it (previewed first, one-click undo).">✦ Fixable</span>` | ||
| : ''; | ||
| return (`<div class="issue ${g.severity}"><div class="itop">` + | ||
| `<span class="sev">${g.severity}</span>` + | ||
| `<span class="chip">${esc(DIMENSION_LABELS[g.dimension])}</span>` + | ||
| `<span class="ititle">${esc(g.title)}</span>${fixable}${count}</div>${fix}${pageList}</div>`); | ||
| }) | ||
| .join('') | ||
| : `<div class="clean">No issues found. ✓</div>`; | ||
| const s = report.summary; | ||
| const skippedLabels = ALL_DIMENSIONS.filter((d) => skippedDims.has(d)).map((d) => DIMENSION_LABELS[d]); | ||
| const skippedNote = skippedLabels.length | ||
| ? `<div class="note">Not measured yet: ${esc(skippedLabels.join(' · '))} — these read your site’s real-visitor data. Add your live domain to switch them on.</div>` | ||
| : ''; | ||
| return `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>Site Health — ${esc(meta.site)}</title> | ||
| <style>${STYLE}</style> | ||
| </head> | ||
| <body> | ||
| <div class="wrap"> | ||
| <header class="hero"> | ||
| <div class="hero-main"> | ||
| <div class="brand">EDS Site Health</div> | ||
| <h1>${esc(meta.site)}</h1> | ||
| <div class="meta">Audited ${esc(meta.generatedAt)} · ${pages} page${pages === 1 ? '' : 's'} inspected</div> | ||
| <div class="pills"> | ||
| <span class="pill crit">${s.critical} critical</span> | ||
| <span class="pill warn">${s.warning} warning</span> | ||
| <span class="pill info">${s.info} info</span> | ||
| </div> | ||
| ${skippedNote} | ||
| </div> | ||
| <div class="hero-gauge"> | ||
| ${gauge({ size: 132, sw: 11, score: overall, center: grade(overall), centerSize: 40 })} | ||
| <div class="cap">${overall}/100 overall</div> | ||
| </div> | ||
| </header> | ||
| <div class="section-title">By dimension</div> | ||
| <section class="gauges" style="--cols:${cols}">${gaugesHtml}</section> | ||
| <div class="section-title">Prioritized issues${groups.length > 100 ? ` — showing 100 of ${groups.length}` : ''}</div> | ||
| ${fixableGroups > 0 ? `<div class="fixlead"><span class="fixable">✦ Fixable</span> ${fixableGroups} of ${groups.length} issue type${groups.length === 1 ? '' : 's'} can be fixed in place — ask your AI agent to apply them with <code>eds_fix_audit</code> (previewed first, one-click undo).</div>` : ''} | ||
| ${issuesHtml} | ||
| <footer> | ||
| Generated by <a href="https://www.npmjs.com/package/@focusgts/eds-mcp-server">@focusgts/eds-mcp-server</a><br> | ||
| Health score derived from findings${fixableGroups > 0 ? ` · issues marked <span class="fixable">✦ Fixable</span> can be repaired with the <code>eds_fix_*</code> tools — safely, with undo` : ''}. | ||
| </footer> | ||
| </div> | ||
| </body> | ||
| </html>`; | ||
| } |
@@ -32,2 +32,3 @@ /** | ||
| // A majority of images with no alt attribute is a real barrier; a few is a warning. | ||
| code: 'a11y-missing-alt', | ||
| severity: ratio > 0.5 ? 'critical' : 'warning', | ||
@@ -48,2 +49,3 @@ title: 'Images missing an alt attribute', | ||
| dimension: 'accessibility', | ||
| code: 'a11y-no-headings', | ||
| severity: 'warning', | ||
@@ -64,2 +66,3 @@ title: 'No headings on the page', | ||
| dimension: 'accessibility', | ||
| code: 'a11y-heading-skip', | ||
| severity: skips > 2 ? 'warning' : 'info', | ||
@@ -87,2 +90,3 @@ title: 'Heading levels skip', | ||
| dimension: 'accessibility', | ||
| code: 'a11y-vague-link-text', | ||
| severity: 'info', | ||
@@ -116,2 +120,3 @@ title: 'Non-descriptive link text', | ||
| dimension: 'accessibility', | ||
| code: 'a11y-missing-landmarks', | ||
| severity: found === 0 ? 'warning' : 'info', | ||
@@ -162,2 +167,3 @@ title: 'Missing landmark regions', | ||
| dimension: 'accessibility', | ||
| code: 'a11y-unlabeled-inputs', | ||
| severity: ratio > 0.5 ? 'critical' : 'warning', | ||
@@ -164,0 +170,0 @@ title: 'Form inputs missing labels', |
@@ -18,2 +18,3 @@ /** | ||
| dimension: 'seo', | ||
| code: 'seo-missing-title', | ||
| severity: 'critical', | ||
@@ -23,2 +24,3 @@ title: 'Missing title tag', | ||
| suggestion: 'Add a descriptive <title> of 30–60 characters.', | ||
| fix: { tool: 'eds_fix_metadata', field: 'title' }, | ||
| }; | ||
@@ -31,2 +33,3 @@ } | ||
| dimension: 'seo', | ||
| code: 'seo-title-length', | ||
| severity: 'warning', | ||
@@ -36,2 +39,3 @@ title: 'Title length is outside the ideal range', | ||
| suggestion: 'Aim for a 30–60 character title so it renders fully in search results.', | ||
| fix: { tool: 'eds_fix_metadata', field: 'title' }, | ||
| }; | ||
@@ -49,2 +53,3 @@ } | ||
| dimension: 'seo', | ||
| code: 'seo-missing-description', | ||
| severity: 'critical', | ||
@@ -54,2 +59,3 @@ title: 'Missing meta description', | ||
| suggestion: 'Add a 120–160 character meta description summarizing the page.', | ||
| fix: { tool: 'eds_fix_metadata', field: 'description' }, | ||
| }; | ||
@@ -62,2 +68,3 @@ } | ||
| dimension: 'seo', | ||
| code: 'seo-description-length', | ||
| severity: 'warning', | ||
@@ -67,2 +74,3 @@ title: 'Meta description length is outside the ideal range', | ||
| suggestion: 'Aim for 120–160 characters so it renders fully in search results.', | ||
| fix: { tool: 'eds_fix_metadata', field: 'description' }, | ||
| }; | ||
@@ -77,2 +85,3 @@ } | ||
| dimension: 'seo', | ||
| code: 'seo-missing-h1', | ||
| severity: 'critical', | ||
@@ -86,2 +95,3 @@ title: 'No H1 heading', | ||
| dimension: 'seo', | ||
| code: 'seo-multiple-h1', | ||
| severity: 'warning', | ||
@@ -103,2 +113,3 @@ title: 'Multiple H1 headings', | ||
| dimension: 'seo', | ||
| code: 'seo-noindex', | ||
| severity: 'critical', | ||
@@ -117,2 +128,3 @@ title: 'Page is blocked from search indexing', | ||
| dimension: 'seo', | ||
| code: 'seo-missing-canonical', | ||
| severity: 'warning', | ||
@@ -129,2 +141,3 @@ title: 'No canonical URL', | ||
| dimension: 'seo', | ||
| code: 'seo-missing-jsonld', | ||
| severity: 'info', | ||
@@ -152,2 +165,3 @@ title: 'No structured data (JSON-LD)', | ||
| dimension: 'seo', | ||
| code: found === 0 ? 'seo-missing-og' : 'seo-incomplete-og', | ||
| // No OG tags at all is a warning; a partial set is a minor gap. | ||
@@ -154,0 +168,0 @@ severity: found === 0 ? 'warning' : 'info', |
@@ -71,2 +71,3 @@ /** | ||
| dimension: 'freshness', | ||
| code: 'freshness-stale', | ||
| severity: 'warning', | ||
@@ -85,2 +86,3 @@ title: `${stale.length} page(s) not updated in over a year`, | ||
| dimension: 'sitemap', | ||
| code: 'sitemap-empty', | ||
| severity: 'warning', | ||
@@ -107,2 +109,3 @@ title: 'No sitemap entries', | ||
| dimension: 'sitemap', | ||
| code: 'sitemap-missing-pages', | ||
| severity: 'info', | ||
@@ -123,2 +126,3 @@ title: `${missing.length} indexed page(s) missing from the sitemap`, | ||
| dimension: 'performance', | ||
| code: 'perf-slow-lcp', | ||
| severity: 'warning', | ||
@@ -134,2 +138,3 @@ title: `${slowLcp.length} page(s) with slow LCP (>2.5s)`, | ||
| dimension: 'performance', | ||
| code: 'perf-high-cls', | ||
| severity: 'warning', | ||
@@ -145,2 +150,3 @@ title: `${shiftyCls.length} page(s) with layout shift (CLS >0.1)`, | ||
| dimension: 'performance', | ||
| code: 'perf-slow-inp', | ||
| severity: 'warning', | ||
@@ -162,2 +168,3 @@ title: `${laggyInp.length} page(s) with slow interaction (INP >200ms)`, | ||
| dimension: 'links', | ||
| code: 'links-broken-404', | ||
| severity: 'warning', | ||
@@ -167,2 +174,3 @@ title: `${entries.length} URL(s) returning 404`, | ||
| suggestion: 'Add redirects for these URLs, or fix the links pointing to them.', | ||
| fix: { tool: 'eds_fix_redirect', field: 'redirect' }, | ||
| }, | ||
@@ -236,2 +244,3 @@ ]; | ||
| dimension: 'links', | ||
| code: 'page-unfetchable', | ||
| severity: 'info', | ||
@@ -238,0 +247,0 @@ page: entry.path, |
@@ -12,2 +12,23 @@ /** | ||
| export type AuditSeverity = 'critical' | 'warning' | 'info'; | ||
| /** | ||
| * Stable identifier for a *kind* of issue (ADR-015). Lets tooling route a | ||
| * finding to its fix without re-deriving from the human title. Kebab-case, | ||
| * `dimension-issue`. These are a quasi-public contract — agents key off them — | ||
| * so name them once and keep them stable. | ||
| */ | ||
| export type AuditCode = 'seo-missing-title' | 'seo-title-length' | 'seo-missing-description' | 'seo-description-length' | 'seo-missing-h1' | 'seo-multiple-h1' | 'seo-noindex' | 'seo-missing-canonical' | 'seo-missing-jsonld' | 'seo-missing-og' | 'seo-incomplete-og' | 'a11y-missing-alt' | 'a11y-no-headings' | 'a11y-heading-skip' | 'a11y-vague-link-text' | 'a11y-missing-landmarks' | 'a11y-unlabeled-inputs' | 'freshness-stale' | 'sitemap-empty' | 'sitemap-missing-pages' | 'perf-slow-lcp' | 'perf-high-cls' | 'perf-slow-inp' | 'links-broken-404' | 'page-unfetchable'; | ||
| /** What a fix writes. */ | ||
| export type FixField = 'title' | 'description' | 'ogImage' | 'redirect'; | ||
| /** | ||
| * How to repair a finding (ADR-015). Present only on findings our shipped safe | ||
| * writers can actually fix; `eds_fix_audit` routes on it. The target page is the | ||
| * finding's own `page`. Every current fixable field needs an agent-supplied | ||
| * value — the tool never fabricates copy. | ||
| */ | ||
| export interface FindingFix { | ||
| /** The tool that repairs this finding. */ | ||
| tool: 'eds_fix_metadata' | 'eds_fix_redirect'; | ||
| /** Which value it writes. */ | ||
| field: FixField; | ||
| } | ||
| /** Every dimension the audit can cover. */ | ||
@@ -19,2 +40,4 @@ export declare const ALL_DIMENSIONS: AuditDimension[]; | ||
| dimension: AuditDimension; | ||
| /** Stable machine identifier for the kind of issue (ADR-015). */ | ||
| code: AuditCode; | ||
| /** How urgent it is. */ | ||
@@ -30,2 +53,4 @@ severity: AuditSeverity; | ||
| suggestion?: string; | ||
| /** How to repair it, when a shipped safe writer can (ADR-015). */ | ||
| fix?: FindingFix; | ||
| } | ||
@@ -32,0 +57,0 @@ /** The result of an audit — a prioritized findings list plus roll-up counts. */ |
@@ -17,2 +17,14 @@ /** | ||
| }>; | ||
| export declare function handleAuditReport(client: EdsClient, site: string, args: { | ||
| pathPrefix?: string; | ||
| maxPages?: number; | ||
| dimensions?: AuditDimension[]; | ||
| domain?: string; | ||
| days?: number; | ||
| }): Promise<{ | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| }>; | ||
| export declare function handleAuditSite(client: EdsClient, args: { | ||
@@ -19,0 +31,0 @@ pathPrefix?: string; |
@@ -9,2 +9,3 @@ /** | ||
| import { auditSite, auditSinglePage } from '../audit/engine.js'; | ||
| import { generateReport } from '../audit/report.js'; | ||
| function textResult(text) { | ||
@@ -68,2 +69,19 @@ return { content: [{ type: 'text', text }] }; | ||
| } | ||
| export async function handleAuditReport(client, site, args) { | ||
| try { | ||
| const options = { | ||
| pathPrefix: args.pathPrefix, | ||
| maxPages: args.maxPages, | ||
| dimensions: args.dimensions, | ||
| domain: args.domain, | ||
| days: args.days, | ||
| }; | ||
| const report = await auditSite(client, options); | ||
| const html = generateReport(report, { site, generatedAt: new Date().toISOString().slice(0, 10) }); | ||
| return textResult(html); | ||
| } | ||
| catch (error) { | ||
| return errorResult(error); | ||
| } | ||
| } | ||
| export async function handleAuditSite(client, args) { | ||
@@ -70,0 +88,0 @@ try { |
@@ -48,1 +48,23 @@ /** | ||
| }>; | ||
| /** | ||
| * Apply the fixes an audit surfaced, across mixed types, in ONE reversible | ||
| * operation (ADR-015). Takes agent-supplied metadata fixes and/or redirect rules | ||
| * — the values the agent wrote from the findings (the tool never invents copy) — | ||
| * and pushes every changed document (metadata pages + the redirects sheet) in a | ||
| * single `withUndo` push, so ONE `eds_da_rollback` reverses the entire batch. | ||
| * Pure orchestration over the ADR-011/013 transforms; no new write logic. | ||
| */ | ||
| export declare function handleFixAudit(daClient: DaClient, edsClient: EdsClient, args: { | ||
| metadata?: Array<{ | ||
| path: string; | ||
| metadata: MetadataFields; | ||
| }>; | ||
| redirects?: RedirectRule[]; | ||
| dryRun?: boolean; | ||
| publish?: boolean; | ||
| }): Promise<{ | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| }>; |
+157
-0
@@ -262,1 +262,158 @@ /** | ||
| } | ||
| const errMsg = (e) => (e instanceof Error ? e.message : String(e)); | ||
| /** | ||
| * Apply the fixes an audit surfaced, across mixed types, in ONE reversible | ||
| * operation (ADR-015). Takes agent-supplied metadata fixes and/or redirect rules | ||
| * — the values the agent wrote from the findings (the tool never invents copy) — | ||
| * and pushes every changed document (metadata pages + the redirects sheet) in a | ||
| * single `withUndo` push, so ONE `eds_da_rollback` reverses the entire batch. | ||
| * Pure orchestration over the ADR-011/013 transforms; no new write logic. | ||
| */ | ||
| export async function handleFixAudit(daClient, edsClient, args) { | ||
| try { | ||
| const metaInput = args.metadata ?? []; | ||
| const redirectInput = args.redirects ?? []; | ||
| if (metaInput.length === 0 && redirectInput.length === 0) { | ||
| return errorResult(new Error('Nothing to fix — supply `metadata` fixes and/or `redirects`. These are the audit findings that carry a `fix`; the agent provides the values (this tool never invents copy).')); | ||
| } | ||
| // 1. Metadata — dedupe+merge by DA-normalized path (same discipline as | ||
| // bulk-fix, so two entries for one page can't race on write), then | ||
| // read+transform each. Read failures are recorded, never abort the batch. | ||
| const deduped = new Map(); | ||
| for (const p of metaInput) { | ||
| const key = p.path.replace(/^\/+/, '').replace(/\.html$/i, ''); | ||
| const existing = deduped.get(key); | ||
| if (existing) | ||
| existing.metadata = { ...existing.metadata, ...p.metadata }; | ||
| else | ||
| deduped.set(key, { path: p.path, metadata: { ...p.metadata } }); | ||
| } | ||
| const pages = [...deduped.values()]; | ||
| const plans = []; | ||
| const readFailed = []; | ||
| await mapWithConcurrency(pages, async (p) => { | ||
| try { | ||
| const source = await daClient.getSource(p.path); | ||
| const { html, changes } = applyMetadata(source.content, p.metadata); | ||
| if (changes.length > 0) { | ||
| plans.push({ path: p.path, sourcePath: source.path, contentType: source.contentType, html, fields: changes.map((c) => c.field) }); | ||
| } | ||
| } | ||
| catch (e) { | ||
| readFailed.push({ path: p.path, error: errMsg(e) }); | ||
| } | ||
| }, 6); | ||
| const metaUnchanged = pages.length - plans.length - readFailed.length; | ||
| // 2. Redirects — read the existing sheet and apply the rules. | ||
| let redirectDoc = null; | ||
| if (redirectInput.length > 0) { | ||
| const existing = await getSourceOrNull(daClient, '/redirects.json'); | ||
| let applied; | ||
| try { | ||
| applied = applyRedirects(existing?.content ?? null, redirectInput); | ||
| } | ||
| catch (e) { | ||
| return errorResult(e); // unrecognizable / multi-sheet existing doc | ||
| } | ||
| if (applied.changes.length > 0) { | ||
| redirectDoc = { content: applied.content, count: applied.changes.length, existed: !!existing }; | ||
| } | ||
| } | ||
| // 3. Dry run — the full combined plan, no writes. | ||
| if (args.dryRun) { | ||
| const lines = [ | ||
| `Dry run — nothing written. Would change ${plans.length} page(s) and ${redirectDoc ? redirectDoc.count : 0} redirect rule(s); ${metaUnchanged} page(s) already correct; ${readFailed.length} unreadable.`, | ||
| ]; | ||
| if (plans.length > 0) { | ||
| lines.push('', 'Metadata:'); | ||
| for (const pl of plans) | ||
| lines.push(` ${pl.sourcePath}: ${pl.fields.join(', ')}`); | ||
| } | ||
| if (redirectDoc) { | ||
| lines.push('', `Redirects (${redirectDoc.existed ? 'update' : 'create'} /redirects.json): ${redirectDoc.count} rule(s).`); | ||
| } | ||
| if (readFailed.length > 0) { | ||
| lines.push('', 'Could not read:'); | ||
| for (const f of readFailed) | ||
| lines.push(` ✗ ${f.path} — ${f.error}`); | ||
| } | ||
| return textResult(lines.join('\n')); | ||
| } | ||
| // Nothing actually changes. | ||
| if (plans.length === 0 && !redirectDoc) { | ||
| if (readFailed.length > 0) { | ||
| const lines = [`Nothing written — ${readFailed.length} page(s) could not be read; ${metaUnchanged} already correct.`, '', 'Could not read:']; | ||
| for (const f of readFailed) | ||
| lines.push(` ✗ ${f.path} — ${f.error}`); | ||
| return textResult(lines.join('\n')); | ||
| } | ||
| return textResult('No changes needed — everything the audit flagged is already correct.'); | ||
| } | ||
| // 4. Push EVERYTHING in one batch → a single unified undo across mixed types. | ||
| const docs = plans.map((pl) => ({ path: pl.sourcePath, content: pl.html, contentType: pl.contentType })); | ||
| if (redirectDoc) | ||
| docs.push({ path: '/redirects.json', content: redirectDoc.content, contentType: 'application/json' }); | ||
| const result = await daClient.pushDocuments(docs, { withUndo: true }); | ||
| // Report what ACTUALLY got written, not what was planned — a partial write | ||
| // failure (e.g. one page 403s) must never read as success. | ||
| const written = new Set(result.succeeded); | ||
| const pagesFixed = plans.filter((pl) => written.has(pl.sourcePath)).length; | ||
| const redirectsFixed = redirectDoc && written.has('/redirects.json') ? redirectDoc.count : 0; | ||
| const lines = [ | ||
| `Fixed ${pagesFixed} page(s)${redirectsFixed ? ` and ${redirectsFixed} redirect rule(s)` : ''} in one reversible batch; ${result.failed.length} write failure(s); ${metaUnchanged} page(s) already correct; ${readFailed.length} unreadable.`, | ||
| ]; | ||
| // 5. Optionally publish everything that was written. | ||
| if (args.publish && result.succeeded.length > 0) { | ||
| const toPublish = plans.filter((pl) => written.has(pl.sourcePath)).map((pl) => pl.path); | ||
| if (redirectDoc && written.has('/redirects.json')) | ||
| toPublish.push('/redirects.json'); | ||
| let published = 0; | ||
| const pubFailed = []; | ||
| await mapWithConcurrency(toPublish, async (path) => { | ||
| try { | ||
| await edsClient.previewAndPublish(path); | ||
| published++; | ||
| } | ||
| catch { | ||
| pubFailed.push(path); | ||
| } | ||
| }, 6); | ||
| lines.push(`Published ${published}/${toPublish.length} live${pubFailed.length ? ` (${pubFailed.length} publish failure(s))` : ''}.`); | ||
| } | ||
| else if (result.succeeded.length > 0) { | ||
| lines.push('(Written to DA. Pass publish:true to make the batch live.)'); | ||
| } | ||
| if (readFailed.length > 0) { | ||
| lines.push('', 'Could not read:'); | ||
| for (const f of readFailed) | ||
| lines.push(` ✗ ${f.path} — ${f.error}`); | ||
| } | ||
| if (result.failed.length > 0) { | ||
| lines.push('', 'Write failures:'); | ||
| for (const f of result.failed) | ||
| lines.push(` ✗ ${f.path} — ${f.error}`); | ||
| } | ||
| // One aggregated undo for the whole batch (metadata + redirects), capped so a | ||
| // huge batch's undo isn't inlined unusably (DA versions each doc as a fallback). | ||
| if (result.undo && (result.undo.restore.length > 0 || result.undo.remove.length > 0)) { | ||
| const undoJson = JSON.stringify({ undo: result.undo }); | ||
| if (undoJson.length <= 200_000) { | ||
| lines.push('', 'To undo this ENTIRE batch in one call, use eds_da_rollback with:', undoJson); | ||
| // eds_da_rollback restores the DA *source* but does not republish. If this | ||
| // batch went live, the old (now-wrong) version keeps serving until the | ||
| // reverted docs are republished — critical for a redirect, which hides a | ||
| // live page. Say so, honestly. | ||
| if (args.publish) { | ||
| lines.push('', 'Note: this batch was published. eds_da_rollback restores the source but does NOT republish — after rolling back, republish the affected paths (including /redirects.json) so the revert goes live.'); | ||
| } | ||
| } | ||
| else { | ||
| lines.push('', `(This batch's undo is ${Math.round(undoJson.length / 1024)} KB — too large to return inline. Run smaller batches; DA also versions every doc for per-doc revert.)`); | ||
| } | ||
| } | ||
| return textResult(lines.join('\n')); | ||
| } | ||
| catch (error) { | ||
| return errorResult(error); | ||
| } | ||
| } |
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 36 tools registered. | ||
| * Creates a {@link McpServer} instance with all 38 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -6,0 +6,0 @@ * first-party MCP servers. |
+52
-1
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 36 tools registered. | ||
| * Creates a {@link McpServer} instance with all 38 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -340,2 +340,15 @@ * first-party MCP servers. | ||
| }, async (args) => auditHandlers.handleAuditSite(client, args)); | ||
| server.tool('eds_audit_report', 'Run a site audit and return a beautiful, self-contained HTML site-health report — per-dimension health scores, a prioritized issue list, and the suggested fixes — ready to save, host, or share. Same options as eds_audit_site. Read-only.', { | ||
| pathPrefix: z.string().optional().describe('Only audit pages under this path prefix. Omit for the whole site.'), | ||
| maxPages: z.number().int().positive().max(1000).optional().describe('Max pages to fetch for per-page checks (default 50).'), | ||
| dimensions: z | ||
| .array(z.enum(ALL_DIMENSIONS)) | ||
| .optional() | ||
| .describe(`Which dimensions to include (default all): ${ALL_DIMENSIONS.join(', ')}.`), | ||
| domain: z.string().optional().describe('Live domain for RUM-based performance and 404 checks (needs EDS_DOMAIN_KEY). Also used as the report title.'), | ||
| days: z.number().int().positive().max(365).optional().describe('RUM look-back window in days (default 7).'), | ||
| }, async (args) => { | ||
| const site = args.domain ?? `${options.owner}/${options.repo}`; | ||
| return auditHandlers.handleAuditReport(client, site, args); | ||
| }); | ||
| // ------------------------------------------------------------------------- | ||
@@ -418,3 +431,41 @@ // Safe fixes (ADR-011) — repair audit findings through the safe-writes layer | ||
| })); | ||
| server.tool('eds_fix_audit', "Fix what the audit found — across metadata AND redirects — in ONE reversible operation. After eds_audit_site/eds_audit_report, findings that carry a `fix` are repairable here: pass `metadata` fixes (per page) and/or `redirects` ({ source, destination }) with the values YOU wrote from the findings — this tool never invents copy. Everything is pushed in a single batch, so ONE eds_da_rollback undoes the whole thing. dryRun previews the combined plan; publish:true makes it live. Requires EDS_DA_TOKEN. This is the 'Fix it' button in agent form: audit → you supply values → one safe, reversible apply.", { | ||
| metadata: z | ||
| .array(z.object({ | ||
| path: edsPath.describe('Site-relative page path to fix'), | ||
| metadata: z | ||
| .object({ | ||
| title: z.string().optional(), | ||
| description: z.string().optional(), | ||
| image: z.string().optional(), | ||
| imageAlt: z.string().optional(), | ||
| }) | ||
| .describe('Metadata values to set on this page (only provided ones change)'), | ||
| })) | ||
| .max(500) | ||
| .optional() | ||
| .describe('Per-page metadata fixes for SEO title/description/OG-image findings'), | ||
| redirects: z | ||
| .array(z.object({ | ||
| source: z.string().min(1).describe('The 404 path that should redirect (relative, e.g. /old-page)'), | ||
| destination: z.string().min(1).describe('Where it should go — a relative path or a full URL'), | ||
| })) | ||
| .max(500) | ||
| .optional() | ||
| .describe('Redirect rules for broken-link (404) findings'), | ||
| dryRun: z.boolean().optional().describe('Preview the combined plan without writing (recommended first pass)'), | ||
| publish: z.boolean().optional().describe('Preview + publish everything written so the fixes go live'), | ||
| }, async (args) => { | ||
| const metadata = args.metadata?.map((p) => { | ||
| const { imageAlt, ...rest } = p.metadata; | ||
| return { path: p.path, metadata: { ...rest, ...(imageAlt !== undefined ? { 'image-alt': imageAlt } : {}) } }; | ||
| }); | ||
| return fixHandlers.handleFixAudit(daClient, client, { | ||
| metadata, | ||
| redirects: args.redirects, | ||
| dryRun: args.dryRun, | ||
| publish: args.publish, | ||
| }); | ||
| }); | ||
| return server; | ||
| } |
+7
-3
| { | ||
| "name": "@focusgts/eds-mcp-server", | ||
| "version": "0.11.0", | ||
| "version": "0.12.0", | ||
| "mcpName": "io.github.focusgts/eds-mcp-server", | ||
| "description": "MCP server for Adobe Edge Delivery Services — preview, publish, metrics, and content operations", | ||
| "description": "The MCP server for Adobe Edge Delivery Services — read, audit, fix, publish and undo your site from any AI agent. 38 tools, safe by default.", | ||
| "license": "Apache-2.0", | ||
@@ -24,3 +24,7 @@ "author": "FocusGTS <dfox@focusgts.com> (https://focusgts.com)", | ||
| "ai", | ||
| "content-management" | ||
| "content-management", | ||
| "seo", | ||
| "accessibility", | ||
| "site-audit", | ||
| "document-authoring" | ||
| ], | ||
@@ -27,0 +31,0 @@ "type": "module", |
+9
-3
@@ -13,3 +13,3 @@ <div align="center"> | ||
| **36 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.** | ||
| **38 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.** | ||
| The first MCP server purpose-built for Edge Delivery Services. | ||
@@ -60,3 +60,3 @@ | ||
| flowchart LR | ||
| A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>36 tools"] | ||
| A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>38 tools"] | ||
| B --> C["Admin API<br/>admin.hlx.page"] | ||
@@ -96,3 +96,3 @@ B --> D["Content API<br/>*.aem.live"] | ||
| ## 🛠️ The 36 tools | ||
| ## 🛠️ The 38 tools | ||
@@ -178,4 +178,7 @@ ### Edge Delivery Services — publish, content, analytics | ||
| - `eds_audit_site` | ||
| - `eds_audit_report` | ||
| > **It tells you what's wrong.** `eds_audit_site` sweeps the whole site (or a subtree) and returns a **prioritized** list of issues across **SEO** (missing titles/descriptions, no H1, blocked from indexing), **accessibility** (images without alt text, missing landmarks, unlabeled form inputs), **freshness** (pages not updated in over a year), **sitemap coverage**, and — with a `domain` — **performance** (Core Web Vitals) and **404s** from Adobe's own real-user data. `eds_audit_page` does the same for one page. Read-only and safe to run anytime. | ||
| > | ||
| > **`eds_audit_report`** turns that audit into a **beautiful, shareable HTML report** — per-dimension health scores, a prioritized issue list, and each suggested fix — self-contained (no external assets), ready to open, host, or send to a stakeholder. | ||
@@ -187,2 +190,3 @@ ### Safe fixes — repair what the audit finds | ||
| - `eds_fix_redirect` | ||
| - `eds_fix_audit` | ||
@@ -194,2 +198,4 @@ > **It fixes what it finds — reversibly.** `eds_fix_metadata` repairs a page's title, meta description and Open Graph image by editing its Document Authoring source, routed through the same **dry-run + undo** path as the write tools. The agent supplies the content (e.g. writes a fitting description); the tool writes it *correctly and idempotently* (merges into the page's Metadata block, never duplicates it). Pass `publish: true` to preview + publish so the change goes live. | ||
| > **`eds_fix_redirect`** closes the 404 loop: `eds_audit_site` surfaces the broken links from real-user data, and this adds the **301 redirect rules** (to the site's `redirects` sheet) that fix them — one rule or many, idempotent, dry-run + undo. So the audit now has a fix for *every* major finding. | ||
| > | ||
| > **`eds_fix_audit`** is the "fix it" button in agent form: after an audit, apply its fixable findings — metadata **and** redirects together — in **one reversible batch**. Findings the report marks **✦ Fixable** carry a machine-readable fix; you supply the values (the tool never invents copy), and every change is pushed at once so a **single** `eds_da_rollback` undoes all of it. `dryRun` previews the whole plan; `publish: true` makes it live. | ||
@@ -196,0 +202,0 @@ --- |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
305881
12.99%49
4.26%6591
10.14%312
1.96%15
7.14%