@focusgts/eds-mcp-server
Advanced tools
| /** | ||
| * Per-page accessibility checks (ADR-010). | ||
| * | ||
| * Regex-based analysis of a page's rendered HTML — no DOM, no dependencies. | ||
| * Detection logic (patterns, ratio thresholds) is ported verbatim from the | ||
| * eds-score scorer; only the output shape differs. A check returns `null` when | ||
| * the page passes. | ||
| */ | ||
| import type { AuditFinding } from '../types.js'; | ||
| /** Run every per-page accessibility check and return the findings. */ | ||
| export declare function accessibilityFindings(html: string): AuditFinding[]; |
| /** | ||
| * Per-page accessibility checks (ADR-010). | ||
| * | ||
| * Regex-based analysis of a page's rendered HTML — no DOM, no dependencies. | ||
| * Detection logic (patterns, ratio thresholds) is ported verbatim from the | ||
| * eds-score scorer; only the output shape differs. A check returns `null` when | ||
| * the page passes. | ||
| */ | ||
| function checkImageAltText(html) { | ||
| const images = html.match(/<img\s[^>]*>/gi) ?? []; | ||
| if (images.length === 0) | ||
| return null; | ||
| let missing = 0; | ||
| for (const img of images) { | ||
| // Only a TRULY absent alt attribute is a WCAG failure (screen readers then | ||
| // announce the file name). `alt=""` is the spec-correct decorative marker — | ||
| // valid, not a violation — and EDS emits it whenever an author leaves alt | ||
| // blank, so treating it as "missing" would false-positive across EDS sites. | ||
| const hasAltAttr = /\balt\s*=/i.test(img); | ||
| if (!hasAltAttr) { | ||
| const decorative = /role=["']presentation["']/i.test(img) || /aria-hidden=["']true["']/i.test(img); | ||
| if (!decorative) | ||
| missing++; | ||
| } | ||
| } | ||
| if (missing === 0) | ||
| return null; | ||
| const ratio = missing / images.length; | ||
| return { | ||
| dimension: 'accessibility', | ||
| // A majority of images with no alt attribute is a real barrier; a few is a warning. | ||
| severity: ratio > 0.5 ? 'critical' : 'warning', | ||
| title: 'Images missing an alt attribute', | ||
| detail: `${missing} of ${images.length} images have no alt attribute at all.`, | ||
| suggestion: 'Add alt text (or alt="" for genuinely decorative images).', | ||
| }; | ||
| } | ||
| function checkHeadingHierarchy(html) { | ||
| const re = /<h([1-6])[\s>]/gi; | ||
| const levels = []; | ||
| let m; | ||
| while ((m = re.exec(html)) !== null) | ||
| levels.push(parseInt(m[1], 10)); | ||
| if (levels.length === 0) { | ||
| return { | ||
| dimension: 'accessibility', | ||
| severity: 'warning', | ||
| title: 'No headings on the page', | ||
| detail: 'The page has no heading elements to structure its content.', | ||
| suggestion: 'Add a heading outline (one <h1>, then <h2>/<h3>) for screen-reader navigation.', | ||
| }; | ||
| } | ||
| let skips = 0; | ||
| for (let i = 1; i < levels.length; i++) { | ||
| if (levels[i] > levels[i - 1] + 1) | ||
| skips++; | ||
| } | ||
| if (skips === 0) | ||
| return null; | ||
| return { | ||
| dimension: 'accessibility', | ||
| severity: skips > 2 ? 'warning' : 'info', | ||
| title: 'Heading levels skip', | ||
| detail: `Found ${skips} heading-level skip(s) across ${levels.length} headings (e.g. h1 → h3).`, | ||
| suggestion: 'Do not skip heading levels; step down one at a time.', | ||
| }; | ||
| } | ||
| function checkLinkText(html) { | ||
| const re = /<a\s[^>]*>([\s\S]*?)<\/a>/gi; | ||
| const bad = new Set(['click here', 'read more', 'learn more', 'here', 'more', 'link']); | ||
| let total = 0; | ||
| let generic = 0; | ||
| let m; | ||
| while ((m = re.exec(html)) !== null) { | ||
| total++; | ||
| const text = m[1].replace(/<[^>]*>/g, '').trim().toLowerCase(); | ||
| if (text && bad.has(text)) | ||
| generic++; | ||
| } | ||
| if (total === 0 || generic === 0) | ||
| return null; | ||
| return { | ||
| dimension: 'accessibility', | ||
| severity: 'info', | ||
| title: 'Non-descriptive link text', | ||
| detail: `${generic} of ${total} links use generic text (e.g. "click here", "read more").`, | ||
| suggestion: 'Use link text that describes the destination out of context.', | ||
| }; | ||
| } | ||
| function checkAriaLandmarks(html) { | ||
| const hasMain = /<main[\s>]/i.test(html) || /role=["']main["']/i.test(html); | ||
| // <header>/banner counts as the top landmark: EDS loads <nav> into the header | ||
| // client-side, so requiring a literal <nav> in the served HTML would | ||
| // false-positive on every EDS site. | ||
| const hasNav = /<nav[\s>]/i.test(html) || | ||
| /role=["']navigation["']/i.test(html) || | ||
| /<header[\s>]/i.test(html) || | ||
| /role=["']banner["']/i.test(html); | ||
| const hasFooter = /<footer[\s>]/i.test(html) || /role=["']contentinfo["']/i.test(html); | ||
| const found = [hasMain, hasNav, hasFooter].filter(Boolean).length; | ||
| if (found === 3) | ||
| return null; | ||
| const missing = []; | ||
| if (!hasMain) | ||
| missing.push('main'); | ||
| if (!hasNav) | ||
| missing.push('header/nav'); | ||
| if (!hasFooter) | ||
| missing.push('footer'); | ||
| return { | ||
| dimension: 'accessibility', | ||
| severity: found === 0 ? 'warning' : 'info', | ||
| title: 'Missing landmark regions', | ||
| detail: `Missing landmark region(s): ${missing.join(', ')}.`, | ||
| suggestion: 'Use semantic <main>, <nav> and <footer> for screen-reader navigation.', | ||
| }; | ||
| } | ||
| // NOTE: there is deliberately no `<html lang>` check. Edge Delivery Services | ||
| // applies `document.documentElement.lang` client-side (from metadata) — every | ||
| // EDS page, including Adobe's own www.aem.live, serves bare `<html>` at the | ||
| // origin. Auditing the served HTML for lang would false-positive a "critical" | ||
| // on 100% of pages of every EDS site, which is wrong and misleading. | ||
| function checkFormLabels(html) { | ||
| const re = /<(?:input|select|textarea)\s[^>]*>/gi; | ||
| const controls = []; | ||
| let m; | ||
| while ((m = re.exec(html)) !== null) { | ||
| const tag = m[0]; | ||
| const type = tag.match(/type=["']([^"']*)["']/i)?.[1]?.toLowerCase() ?? 'text'; | ||
| if (['hidden', 'submit', 'button', 'image'].includes(type)) | ||
| continue; | ||
| controls.push(tag); | ||
| } | ||
| if (controls.length === 0) | ||
| return null; | ||
| let unlabeled = 0; | ||
| for (const control of controls) { | ||
| const hasAriaLabel = /aria-label=["'][^"']+["']/i.test(control); | ||
| const hasAriaLabelledBy = /aria-labelledby=["'][^"']+["']/i.test(control); | ||
| const hasTitle = /title=["'][^"']+["']/i.test(control); | ||
| const id = control.match(/\bid=["']([^"']*)["']/i)?.[1]; | ||
| let hasAssociatedLabel = false; | ||
| if (id) { | ||
| // Escape regex metacharacters — ids like "a[0]" would otherwise mis-match | ||
| // or throw (and throwing would drop the whole page to a fetch "failure"). | ||
| const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| hasAssociatedLabel = new RegExp(`<label\\s[^>]*for=["']${escaped}["']`, 'i').test(html); | ||
| } | ||
| if (!hasAriaLabel && !hasAriaLabelledBy && !hasTitle && !hasAssociatedLabel) | ||
| unlabeled++; | ||
| } | ||
| if (unlabeled === 0) | ||
| return null; | ||
| const ratio = unlabeled / controls.length; | ||
| return { | ||
| dimension: 'accessibility', | ||
| severity: ratio > 0.5 ? 'critical' : 'warning', | ||
| title: 'Form inputs missing labels', | ||
| detail: `${unlabeled} of ${controls.length} form inputs have no associated label.`, | ||
| suggestion: 'Associate each input with a <label for>, aria-label, or aria-labelledby.', | ||
| }; | ||
| } | ||
| /** Run every per-page accessibility check and return the findings. */ | ||
| export function accessibilityFindings(html) { | ||
| return [ | ||
| checkImageAltText(html), | ||
| checkHeadingHierarchy(html), | ||
| checkLinkText(html), | ||
| checkAriaLandmarks(html), | ||
| checkFormLabels(html), | ||
| ].filter((f) => f !== null); | ||
| } |
| /** | ||
| * Per-page SEO checks (ADR-010). | ||
| * | ||
| * Regex-based analysis of a page's rendered HTML — no DOM, no dependencies. | ||
| * The detection logic (patterns, length thresholds) is ported verbatim from the | ||
| * eds-score scorer; only the output shape differs (findings, not scores). A | ||
| * check returns `null` when the page passes. | ||
| */ | ||
| import type { AuditFinding } from '../types.js'; | ||
| /** Run every per-page SEO check and return the findings (passing checks omitted). */ | ||
| export declare function seoFindings(html: string): AuditFinding[]; |
| /** | ||
| * Per-page SEO checks (ADR-010). | ||
| * | ||
| * Regex-based analysis of a page's rendered HTML — no DOM, no dependencies. | ||
| * The detection logic (patterns, length thresholds) is ported verbatim from the | ||
| * eds-score scorer; only the output shape differs (findings, not scores). A | ||
| * check returns `null` when the page passes. | ||
| */ | ||
| function matchTitle(html) { | ||
| const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); | ||
| return m?.[1]?.trim() ?? ''; | ||
| } | ||
| function checkTitle(html) { | ||
| const title = matchTitle(html); | ||
| if (!title) { | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'critical', | ||
| title: 'Missing title tag', | ||
| detail: 'No <title> tag found on the page.', | ||
| suggestion: 'Add a descriptive <title> of 30–60 characters.', | ||
| }; | ||
| } | ||
| const len = title.length; | ||
| if (len >= 30 && len <= 60) | ||
| return null; | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'warning', | ||
| title: 'Title length is outside the ideal range', | ||
| detail: `Title "${title}" is ${len} characters (ideal 30–60).`, | ||
| suggestion: 'Aim for a 30–60 character title so it renders fully in search results.', | ||
| }; | ||
| } | ||
| function checkMetaDescription(html) { | ||
| // Match the closing quote to the opening one (backreference) so a description | ||
| // containing an apostrophe isn't truncated at the first ' — a very common | ||
| // false positive with `content="what's new …"`. | ||
| const m = html.match(/<meta\s+name=["']description["']\s+content=(["'])([\s\S]*?)\1[^>]*>/i) ?? | ||
| html.match(/<meta\s+content=(["'])([\s\S]*?)\1\s+name=["']description["'][^>]*>/i); | ||
| const description = m?.[2]?.trim() ?? ''; | ||
| if (!description) { | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'critical', | ||
| title: 'Missing meta description', | ||
| detail: 'No <meta name="description"> found on the page.', | ||
| suggestion: 'Add a 120–160 character meta description summarizing the page.', | ||
| }; | ||
| } | ||
| const len = description.length; | ||
| if (len >= 120 && len <= 160) | ||
| return null; | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'warning', | ||
| title: 'Meta description length is outside the ideal range', | ||
| detail: `Meta description is ${len} characters (ideal 120–160).`, | ||
| suggestion: 'Aim for 120–160 characters so it renders fully in search results.', | ||
| }; | ||
| } | ||
| function checkH1(html) { | ||
| const count = (html.match(/<h1[\s>]/gi) ?? []).length; | ||
| if (count === 1) | ||
| return null; | ||
| if (count === 0) { | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'critical', | ||
| title: 'No H1 heading', | ||
| detail: 'The page has no <h1> heading.', | ||
| suggestion: 'Add exactly one <h1> that describes the page.', | ||
| }; | ||
| } | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'warning', | ||
| title: 'Multiple H1 headings', | ||
| detail: `Found ${count} <h1> headings — a page should have exactly one.`, | ||
| suggestion: 'Keep a single <h1>; demote the rest to <h2>/<h3>.', | ||
| }; | ||
| } | ||
| function checkRobots(html) { | ||
| const m = html.match(/<meta\s+name=["']robots["']\s+content=["']([^"']*)["'][^>]*>/i) ?? | ||
| html.match(/<meta\s+content=["']([^"']*)["']\s+name=["']robots["'][^>]*>/i); | ||
| const content = m?.[1]?.toLowerCase() ?? ''; | ||
| // Only `noindex` (or `none`, which implies noindex) blocks indexing. | ||
| // `nofollow` controls link-following, NOT indexing — do not report it as | ||
| // "blocked from search indexing" (a factually wrong, embarrassing claim). | ||
| if (content.includes('noindex') || content.includes('none')) { | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'critical', | ||
| title: 'Page is blocked from search indexing', | ||
| detail: `A robots meta directive is blocking indexing: "${content}".`, | ||
| suggestion: 'Remove noindex/none if this page should appear in search results.', | ||
| }; | ||
| } | ||
| return null; | ||
| } | ||
| function checkCanonical(html) { | ||
| if (/<link\s+[^>]*rel=["']canonical["'][^>]*>/i.test(html)) | ||
| return null; | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'warning', | ||
| title: 'No canonical URL', | ||
| detail: 'The page does not declare a canonical URL.', | ||
| suggestion: 'Add <link rel="canonical"> to prevent duplicate-content issues.', | ||
| }; | ||
| } | ||
| function checkJsonLd(html) { | ||
| if (/<script\s+type=["']application\/ld\+json["'][^>]*>/i.test(html)) | ||
| return null; | ||
| return { | ||
| dimension: 'seo', | ||
| severity: 'info', | ||
| title: 'No structured data (JSON-LD)', | ||
| detail: 'The page has no Schema.org JSON-LD markup.', | ||
| suggestion: 'Add JSON-LD structured data to enable rich search results.', | ||
| }; | ||
| } | ||
| function checkOgTags(html) { | ||
| const hasTitle = /<meta\s+[^>]*property=["']og:title["'][^>]*>/i.test(html); | ||
| const hasDesc = /<meta\s+[^>]*property=["']og:description["'][^>]*>/i.test(html); | ||
| const hasImage = /<meta\s+[^>]*property=["']og:image["'][^>]*>/i.test(html); | ||
| const found = [hasTitle, hasDesc, hasImage].filter(Boolean).length; | ||
| if (found === 3) | ||
| return null; | ||
| const missing = []; | ||
| if (!hasTitle) | ||
| missing.push('og:title'); | ||
| if (!hasDesc) | ||
| missing.push('og:description'); | ||
| if (!hasImage) | ||
| missing.push('og:image'); | ||
| return { | ||
| dimension: 'seo', | ||
| // No OG tags at all is a warning; a partial set is a minor gap. | ||
| severity: found === 0 ? 'warning' : 'info', | ||
| title: found === 0 ? 'No Open Graph tags' : 'Incomplete Open Graph tags', | ||
| detail: `Missing Open Graph tags: ${missing.join(', ')}.`, | ||
| suggestion: 'Add og:title, og:description and og:image for rich social sharing.', | ||
| }; | ||
| } | ||
| /** Run every per-page SEO check and return the findings (passing checks omitted). */ | ||
| export function seoFindings(html) { | ||
| return [ | ||
| checkTitle(html), | ||
| checkMetaDescription(html), | ||
| checkH1(html), | ||
| checkRobots(html), | ||
| checkCanonical(html), | ||
| checkJsonLd(html), | ||
| checkOgTags(html), | ||
| ].filter((f) => f !== null); | ||
| } |
| /** | ||
| * Content-audit engine (ADR-010). | ||
| * | ||
| * `auditPage` runs the per-page SEO + accessibility checks over a single page's | ||
| * HTML. `auditSite` sweeps the site: per-page checks across the page index | ||
| * (bounded concurrency) plus site-level checks (freshness, sitemap coverage, | ||
| * and — when a domain is supplied — RUM performance and 404s). | ||
| * | ||
| * All data comes from the EdsClient (server-side, owner/repo/ref addressed); | ||
| * there is no Google PageSpeed dependency — performance uses Adobe's own RUM. | ||
| */ | ||
| import type { EdsClient } from '../eds-admin/client.js'; | ||
| import { type AuditDimension, type AuditFinding, type AuditReport } from './types.js'; | ||
| /** Run every per-page check over one page's HTML. */ | ||
| export declare function auditPage(html: string, path?: string): AuditFinding[]; | ||
| export interface AuditSiteOptions { | ||
| /** Only audit pages under this path prefix (e.g. "/blog/"). */ | ||
| pathPrefix?: string; | ||
| /** Max pages to fetch HTML for (per-page checks). Default 50. */ | ||
| maxPages?: number; | ||
| /** Which dimensions to run. Default: all. */ | ||
| dimensions?: AuditDimension[]; | ||
| /** Live domain for RUM (performance, links). Omit to skip RUM checks. */ | ||
| domain?: string; | ||
| /** RUM window in days. Default 7. */ | ||
| days?: number; | ||
| } | ||
| /** Audit a whole site (or a subtree). */ | ||
| export declare function auditSite(client: EdsClient, options?: AuditSiteOptions): Promise<AuditReport>; | ||
| /** Audit a single page from its HTML. */ | ||
| export declare function auditSinglePage(html: string, path: string): AuditReport; |
| /** | ||
| * Content-audit engine (ADR-010). | ||
| * | ||
| * `auditPage` runs the per-page SEO + accessibility checks over a single page's | ||
| * HTML. `auditSite` sweeps the site: per-page checks across the page index | ||
| * (bounded concurrency) plus site-level checks (freshness, sitemap coverage, | ||
| * and — when a domain is supplied — RUM performance and 404s). | ||
| * | ||
| * All data comes from the EdsClient (server-side, owner/repo/ref addressed); | ||
| * there is no Google PageSpeed dependency — performance uses Adobe's own RUM. | ||
| */ | ||
| import { ALL_DIMENSIONS, } from './types.js'; | ||
| import { seoFindings } from './checks/seo.js'; | ||
| import { accessibilityFindings } from './checks/accessibility.js'; | ||
| const SEVERITY_ORDER = { | ||
| critical: 0, | ||
| warning: 1, | ||
| info: 2, | ||
| }; | ||
| /** Sort findings critical-first, stable within a severity. */ | ||
| function sortFindings(findings) { | ||
| return [...findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); | ||
| } | ||
| /** Run every per-page check over one page's HTML. */ | ||
| export function auditPage(html, path) { | ||
| const findings = [...seoFindings(html), ...accessibilityFindings(html)]; | ||
| if (path) | ||
| for (const f of findings) | ||
| f.page = path; | ||
| return sortFindings(findings); | ||
| } | ||
| /** Run `fn` over `items` with at most `concurrency` in flight at once. */ | ||
| async function mapWithConcurrency(items, fn, concurrency) { | ||
| let next = 0; | ||
| const worker = async () => { | ||
| while (next < items.length) { | ||
| const i = next++; | ||
| await fn(items[i]); | ||
| } | ||
| }; | ||
| const size = Math.max(1, Math.min(concurrency, items.length)); | ||
| await Promise.all(Array.from({ length: size }, () => worker())); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Site-level checks | ||
| // --------------------------------------------------------------------------- | ||
| const YEAR_MS = 365 * 24 * 60 * 60 * 1000; | ||
| function preview(paths, n = 10) { | ||
| const shown = paths.slice(0, n).join(', '); | ||
| return paths.length > n ? `${shown}, …` : shown; | ||
| } | ||
| /** Normalize a path for cross-source comparison: decode + drop a trailing slash. */ | ||
| function normPath(p) { | ||
| let s = p; | ||
| try { | ||
| s = decodeURIComponent(p); | ||
| } | ||
| catch { | ||
| /* malformed encoding — compare as-is */ | ||
| } | ||
| return s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s; | ||
| } | ||
| /** Pages not updated in over a year (query-index `lastModified`, unix seconds). */ | ||
| function freshnessFindings(entries, now) { | ||
| const stale = entries.filter((e) => typeof e.lastModified === 'number' && now - e.lastModified * 1000 > YEAR_MS); | ||
| if (stale.length === 0) | ||
| return []; | ||
| return [ | ||
| { | ||
| dimension: 'freshness', | ||
| severity: 'warning', | ||
| title: `${stale.length} page(s) not updated in over a year`, | ||
| detail: `Stale pages: ${preview(stale.map((e) => e.path))}`, | ||
| suggestion: 'Review and refresh outdated content, or confirm it is still accurate.', | ||
| }, | ||
| ]; | ||
| } | ||
| /** Sitemap presence + coverage of the page index. */ | ||
| function sitemapFindings(sitemap, entries) { | ||
| if (sitemap.length === 0) { | ||
| return [ | ||
| { | ||
| dimension: 'sitemap', | ||
| severity: 'warning', | ||
| title: 'No sitemap entries', | ||
| detail: 'sitemap.xml returned no URLs.', | ||
| suggestion: 'Publish a sitemap.xml so search engines can discover every page.', | ||
| }, | ||
| ]; | ||
| } | ||
| const sitemapPaths = new Set(sitemap.map((s) => { | ||
| try { | ||
| return normPath(new URL(s.loc).pathname); | ||
| } | ||
| catch { | ||
| return normPath(s.loc); | ||
| } | ||
| })); | ||
| const missing = entries.map((e) => e.path).filter((p) => !sitemapPaths.has(normPath(p))); | ||
| if (missing.length === 0) | ||
| return []; | ||
| return [ | ||
| { | ||
| dimension: 'sitemap', | ||
| severity: 'info', | ||
| title: `${missing.length} indexed page(s) missing from the sitemap`, | ||
| detail: `Not in sitemap: ${preview(missing)}`, | ||
| suggestion: 'Ensure the sitemap includes every published page.', | ||
| }, | ||
| ]; | ||
| } | ||
| /** Core Web Vitals from RUM — flag pages exceeding the "good" thresholds. */ | ||
| function performanceFindings(cwv) { | ||
| const findings = []; | ||
| const worst = (rows, fmt, by) => [...rows].sort((a, b) => by(b) - by(a)).slice(0, 5).map(fmt).join(', '); | ||
| const slowLcp = cwv.filter((c) => c.lcp > 2500); | ||
| if (slowLcp.length > 0) { | ||
| findings.push({ | ||
| dimension: 'performance', | ||
| severity: 'warning', | ||
| title: `${slowLcp.length} page(s) with slow LCP (>2.5s)`, | ||
| detail: `Worst: ${worst(slowLcp, (c) => `${c.url} ${Math.round(c.lcp)}ms`, (c) => c.lcp)}`, | ||
| suggestion: 'Optimize the largest content element — images, fonts, render-blocking resources.', | ||
| }); | ||
| } | ||
| const shiftyCls = cwv.filter((c) => c.cls > 0.1); | ||
| if (shiftyCls.length > 0) { | ||
| findings.push({ | ||
| dimension: 'performance', | ||
| severity: 'warning', | ||
| title: `${shiftyCls.length} page(s) with layout shift (CLS >0.1)`, | ||
| detail: `Worst: ${worst(shiftyCls, (c) => `${c.url} ${c.cls.toFixed(2)}`, (c) => c.cls)}`, | ||
| suggestion: 'Set explicit width/height on media and reserve space for late-loading content.', | ||
| }); | ||
| } | ||
| const laggyInp = cwv.filter((c) => c.inp > 200); | ||
| if (laggyInp.length > 0) { | ||
| findings.push({ | ||
| dimension: 'performance', | ||
| severity: 'warning', | ||
| title: `${laggyInp.length} page(s) with slow interaction (INP >200ms)`, | ||
| detail: `Worst: ${worst(laggyInp, (c) => `${c.url} ${Math.round(c.inp)}ms`, (c) => c.inp)}`, | ||
| suggestion: 'Reduce long tasks and third-party JavaScript on the main thread.', | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
| /** 404s from RUM — the top broken URLs by traffic. */ | ||
| function link404Findings(entries) { | ||
| if (entries.length === 0) | ||
| return []; | ||
| const top = [...entries].sort((a, b) => b.views - a.views).slice(0, 10); | ||
| return [ | ||
| { | ||
| dimension: 'links', | ||
| severity: 'warning', | ||
| title: `${entries.length} URL(s) returning 404`, | ||
| detail: `Top 404s: ${top.map((e) => `${e.url} (${e.views} views)`).join(', ')}`, | ||
| suggestion: 'Add redirects for these URLs, or fix the links pointing to them.', | ||
| }, | ||
| ]; | ||
| } | ||
| function summarize(scope, target, findings, skipped, truncated, pagesAudited) { | ||
| const sorted = sortFindings(findings); | ||
| return { | ||
| scope, | ||
| target, | ||
| findings: sorted, | ||
| summary: { | ||
| critical: sorted.filter((f) => f.severity === 'critical').length, | ||
| warning: sorted.filter((f) => f.severity === 'warning').length, | ||
| info: sorted.filter((f) => f.severity === 'info').length, | ||
| total: sorted.length, | ||
| ...(pagesAudited !== undefined ? { pagesAudited } : {}), | ||
| }, | ||
| skipped, | ||
| truncated, | ||
| }; | ||
| } | ||
| /** Audit a whole site (or a subtree). */ | ||
| export async function auditSite(client, options = {}) { | ||
| const maxPages = options.maxPages ?? 50; | ||
| const dims = new Set(options.dimensions ?? ALL_DIMENSIONS); | ||
| const days = options.days ?? 7; | ||
| const findings = []; | ||
| const skipped = []; | ||
| // 1. Page index (query-index) — paginate so site-level checks (freshness, | ||
| // sitemap coverage) see every page, not just the first response. | ||
| const HARD_CAP = 5000; | ||
| const allEntries = []; | ||
| let offset = 0; | ||
| let indexTotal; | ||
| for (;;) { | ||
| const page = await client.listPages(500, offset); | ||
| indexTotal = typeof page.total === 'number' ? page.total : allEntries.length + page.data.length; | ||
| allEntries.push(...page.data); | ||
| offset += page.data.length; | ||
| if (page.data.length === 0 || allEntries.length >= indexTotal || allEntries.length >= HARD_CAP) | ||
| break; | ||
| } | ||
| if (indexTotal > allEntries.length) { | ||
| skipped.push(`index truncated (scanned first ${allEntries.length} of ${indexTotal} pages — freshness/sitemap coverage reflect only those)`); | ||
| } | ||
| let entries = allEntries; | ||
| if (options.pathPrefix) { | ||
| entries = entries.filter((e) => e.path.startsWith(options.pathPrefix)); | ||
| } | ||
| // 2. Per-page SEO + accessibility (bounded concurrency, capped by maxPages). | ||
| const wantsPageChecks = dims.has('seo') || dims.has('accessibility'); | ||
| const toAudit = entries.slice(0, maxPages); | ||
| const truncated = entries.length > maxPages && wantsPageChecks; | ||
| if (wantsPageChecks) { | ||
| await mapWithConcurrency(toAudit, async (entry) => { | ||
| try { | ||
| const { html } = await client.getRenderedPage(entry.path); | ||
| if (dims.has('seo')) { | ||
| for (const f of seoFindings(html)) | ||
| findings.push({ ...f, page: entry.path }); | ||
| } | ||
| if (dims.has('accessibility')) { | ||
| for (const f of accessibilityFindings(html)) | ||
| findings.push({ ...f, page: entry.path }); | ||
| } | ||
| } | ||
| catch (error) { | ||
| findings.push({ | ||
| dimension: 'links', | ||
| severity: 'info', | ||
| page: entry.path, | ||
| title: 'Page could not be fetched', | ||
| detail: error instanceof Error ? error.message : String(error), | ||
| suggestion: 'Confirm the page is published and reachable.', | ||
| }); | ||
| } | ||
| }, 6); | ||
| } | ||
| const now = Date.now(); | ||
| // 3. Freshness (query-index lastModified) — no extra fetch. | ||
| if (dims.has('freshness')) | ||
| findings.push(...freshnessFindings(entries, now)); | ||
| // 4. Sitemap coverage. | ||
| if (dims.has('sitemap')) { | ||
| try { | ||
| const sitemap = await client.getSitemap(); | ||
| findings.push(...sitemapFindings(sitemap, entries)); | ||
| } | ||
| catch (error) { | ||
| skipped.push(`sitemap (${error instanceof Error ? error.message : 'unavailable'})`); | ||
| } | ||
| } | ||
| // 5. Performance (RUM) — needs a domain. | ||
| if (dims.has('performance')) { | ||
| if (options.domain) { | ||
| try { | ||
| findings.push(...performanceFindings(await client.getCwv(options.domain, days))); | ||
| } | ||
| catch (error) { | ||
| skipped.push(`performance (${error instanceof Error ? error.message : 'RUM unavailable'})`); | ||
| } | ||
| } | ||
| else { | ||
| skipped.push('performance (pass a domain + set EDS_DOMAIN_KEY for RUM data)'); | ||
| } | ||
| } | ||
| // 6. 404s (RUM) — needs a domain. | ||
| if (dims.has('links')) { | ||
| if (options.domain) { | ||
| try { | ||
| findings.push(...link404Findings(await client.get404s(options.domain, days))); | ||
| } | ||
| catch (error) { | ||
| skipped.push(`links/404s (${error instanceof Error ? error.message : 'RUM unavailable'})`); | ||
| } | ||
| } | ||
| else { | ||
| skipped.push('links/404s (pass a domain + set EDS_DOMAIN_KEY for RUM data)'); | ||
| } | ||
| } | ||
| const target = options.pathPrefix ? options.pathPrefix : '(whole site)'; | ||
| return summarize('site', target, findings, skipped, truncated, wantsPageChecks ? toAudit.length : 0); | ||
| } | ||
| /** Audit a single page from its HTML. */ | ||
| export function auditSinglePage(html, path) { | ||
| return summarize('page', path, auditPage(html, path), [], false); | ||
| } |
| /** | ||
| * Types for the content-audit layer (ADR-010). | ||
| * | ||
| * The audit produces a flat, prioritized list of {@link AuditFinding}s — the | ||
| * agent-native "tell me what's wrong" surface. Unlike the eds-score web tool, | ||
| * there is no numeric grade: an agent wants actionable issues, not a letter. | ||
| */ | ||
| /** The quality dimension a finding belongs to. */ | ||
| export type AuditDimension = 'seo' | 'accessibility' | 'performance' | 'freshness' | 'links' | 'sitemap'; | ||
| /** Severity of a finding, most to least urgent. */ | ||
| export type AuditSeverity = 'critical' | 'warning' | 'info'; | ||
| /** Every dimension the audit can cover. */ | ||
| export declare const ALL_DIMENSIONS: AuditDimension[]; | ||
| /** One thing worth fixing, found by a check. */ | ||
| export interface AuditFinding { | ||
| /** Which quality dimension this belongs to. */ | ||
| dimension: AuditDimension; | ||
| /** How urgent it is. */ | ||
| severity: AuditSeverity; | ||
| /** Site-relative page path, when the finding is page-specific. */ | ||
| page?: string; | ||
| /** Short label for the issue. */ | ||
| title: string; | ||
| /** What was actually found. */ | ||
| detail: string; | ||
| /** What to do about it. */ | ||
| suggestion?: string; | ||
| } | ||
| /** The result of an audit — a prioritized findings list plus roll-up counts. */ | ||
| export interface AuditReport { | ||
| /** Whether this was a single page or a whole-site sweep. */ | ||
| scope: 'page' | 'site'; | ||
| /** What was audited (a path, or the site root/prefix). */ | ||
| target: string; | ||
| /** Findings, sorted critical-first. */ | ||
| findings: AuditFinding[]; | ||
| /** Roll-up counts. */ | ||
| summary: { | ||
| critical: number; | ||
| warning: number; | ||
| info: number; | ||
| total: number; | ||
| /** Number of pages inspected (site scope only). */ | ||
| pagesAudited?: number; | ||
| }; | ||
| /** Dimensions that could not run (e.g. RUM without a domain key), never silent. */ | ||
| skipped: string[]; | ||
| /** True when the sweep hit the page cap and some pages were not audited. */ | ||
| truncated: boolean; | ||
| } |
| /** | ||
| * Types for the content-audit layer (ADR-010). | ||
| * | ||
| * The audit produces a flat, prioritized list of {@link AuditFinding}s — the | ||
| * agent-native "tell me what's wrong" surface. Unlike the eds-score web tool, | ||
| * there is no numeric grade: an agent wants actionable issues, not a letter. | ||
| */ | ||
| /** Every dimension the audit can cover. */ | ||
| export const ALL_DIMENSIONS = [ | ||
| 'seo', | ||
| 'accessibility', | ||
| 'performance', | ||
| 'freshness', | ||
| 'links', | ||
| 'sitemap', | ||
| ]; |
| /** | ||
| * MCP tool handlers for the content audit (ADR-010). | ||
| * | ||
| * `eds_audit_page` audits one page; `eds_audit_site` sweeps the site. Both | ||
| * return a prioritized, human-readable findings report. | ||
| */ | ||
| import type { EdsClient } from '../eds-admin/client.js'; | ||
| import type { AuditDimension } from '../audit/types.js'; | ||
| export declare function handleAuditPage(client: EdsClient, args: { | ||
| path: string; | ||
| }): Promise<{ | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| }>; | ||
| export declare function handleAuditSite(client: EdsClient, args: { | ||
| pathPrefix?: string; | ||
| maxPages?: number; | ||
| dimensions?: AuditDimension[]; | ||
| domain?: string; | ||
| days?: number; | ||
| }): Promise<{ | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| }>; |
| /** | ||
| * MCP tool handlers for the content audit (ADR-010). | ||
| * | ||
| * `eds_audit_page` audits one page; `eds_audit_site` sweeps the site. Both | ||
| * return a prioritized, human-readable findings report. | ||
| */ | ||
| import { formatError } from '../utils/errors.js'; | ||
| import { auditSite, auditSinglePage } from '../audit/engine.js'; | ||
| function textResult(text) { | ||
| return { content: [{ type: 'text', text }] }; | ||
| } | ||
| function errorResult(error) { | ||
| return { | ||
| content: [{ type: 'text', text: `Error: ${formatError(error)}` }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const SEVERITY_LABEL = { | ||
| critical: 'CRITICAL', | ||
| warning: 'WARNING', | ||
| info: 'INFO', | ||
| }; | ||
| /** Cap the number of findings rendered so a huge site can't blow the response. */ | ||
| const MAX_RENDERED = 200; | ||
| function formatReport(report) { | ||
| const { summary } = report; | ||
| const lines = []; | ||
| const scopeLabel = report.scope === 'page' ? `page ${report.target}` : `site ${report.target}`; | ||
| lines.push(`Audit of ${scopeLabel} — ${summary.critical} critical, ${summary.warning} warning, ${summary.info} info (${summary.total} finding${summary.total === 1 ? '' : 's'}).`); | ||
| if (summary.pagesAudited !== undefined && report.scope === 'site') { | ||
| lines.push(`Pages inspected: ${summary.pagesAudited}${report.truncated ? ' (hit the page cap — raise maxPages or narrow pathPrefix for full coverage)' : ''}.`); | ||
| } | ||
| if (report.skipped.length > 0) { | ||
| lines.push(`Skipped: ${report.skipped.join('; ')}.`); | ||
| } | ||
| if (summary.total === 0) { | ||
| lines.push('', 'No issues found. ✓'); | ||
| return lines.join('\n'); | ||
| } | ||
| const rendered = report.findings.slice(0, MAX_RENDERED); | ||
| let lastSeverity = null; | ||
| for (const f of rendered) { | ||
| if (f.severity !== lastSeverity) { | ||
| lines.push('', SEVERITY_LABEL[f.severity]); | ||
| lastSeverity = f.severity; | ||
| } | ||
| const where = f.page ? ` — ${f.page}` : ''; | ||
| lines.push(` [${f.dimension}] ${f.title}${where}`); | ||
| lines.push(` ${f.detail}`); | ||
| if (f.suggestion) | ||
| lines.push(` → ${f.suggestion}`); | ||
| } | ||
| if (report.findings.length > MAX_RENDERED) { | ||
| lines.push('', `(${report.findings.length - MAX_RENDERED} more finding(s) not shown — narrow the audit with pathPrefix or dimensions.)`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| export async function handleAuditPage(client, args) { | ||
| try { | ||
| const { html } = await client.getRenderedPage(args.path); | ||
| return textResult(formatReport(auditSinglePage(html, args.path))); | ||
| } | ||
| catch (error) { | ||
| return errorResult(error); | ||
| } | ||
| } | ||
| export async function handleAuditSite(client, args) { | ||
| try { | ||
| const options = { | ||
| pathPrefix: args.pathPrefix, | ||
| maxPages: args.maxPages, | ||
| dimensions: args.dimensions, | ||
| domain: args.domain, | ||
| days: args.days, | ||
| }; | ||
| return textResult(formatReport(await auditSite(client, options))); | ||
| } | ||
| catch (error) { | ||
| return errorResult(error); | ||
| } | ||
| } |
@@ -103,2 +103,11 @@ /** | ||
| /** | ||
| * Fetch the FULL rendered page HTML (with `<head>`), not the `.plain.html` | ||
| * body fragment. This is what SEO/metadata analysis needs — title, meta | ||
| * description, canonical, Open Graph, JSON-LD and `<html lang>` all live in | ||
| * the head, which `.plain.html` omits. | ||
| * | ||
| * GET https://{ref}--{repo}--{owner}.aem.live/{path} | ||
| */ | ||
| getRenderedPage(path: string): Promise<EdsPageContent>; | ||
| /** | ||
| * List pages from the query index. | ||
@@ -105,0 +114,0 @@ * |
@@ -365,2 +365,21 @@ /** | ||
| /** | ||
| * Fetch the FULL rendered page HTML (with `<head>`), not the `.plain.html` | ||
| * body fragment. This is what SEO/metadata analysis needs — title, meta | ||
| * description, canonical, Open Graph, JSON-LD and `<html lang>` all live in | ||
| * the head, which `.plain.html` omits. | ||
| * | ||
| * GET https://{ref}--{repo}--{owner}.aem.live/{path} | ||
| */ | ||
| async getRenderedPage(path) { | ||
| const normalized = this.normalizePath(path) | ||
| .replace(/\.plain\.html$/, '') | ||
| .replace(/\.html$/, ''); | ||
| const url = `${this.contentOrigin}/${normalized}`; | ||
| const html = await this.request(url, { method: 'GET' }); | ||
| return { | ||
| path: `/${normalized}`, | ||
| html: typeof html === 'string' ? html : String(html), | ||
| }; | ||
| } | ||
| /** | ||
| * List pages from the query index. | ||
@@ -367,0 +386,0 @@ * |
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 31 tools registered. | ||
| * Creates a {@link McpServer} instance with all 33 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -6,0 +6,0 @@ * first-party MCP servers. |
+37
-1
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 31 tools registered. | ||
| * Creates a {@link McpServer} instance with all 33 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -18,2 +18,4 @@ * first-party MCP servers. | ||
| import * as daHandlers from './da-handlers.js'; | ||
| import * as auditHandlers from './audit-handlers.js'; | ||
| import { ALL_DIMENSIONS } from '../audit/types.js'; | ||
| const require = createRequire(import.meta.url); | ||
@@ -304,3 +306,37 @@ const { version } = require('../../package.json'); | ||
| }, async (args) => daHandlers.handleDaRollback(daClient, args)); | ||
| // ------------------------------------------------------------------------- | ||
| // Content audit (ADR-010) — find what's wrong, prioritized and actionable | ||
| // ------------------------------------------------------------------------- | ||
| server.tool('eds_audit_page', 'Audit a single page for SEO and accessibility issues (missing title/description, no H1, images without alt text, missing landmarks, etc.). Returns a prioritized list of findings with suggested fixes. Read-only.', { | ||
| path: edsPath.describe('Site-relative page path to audit (e.g. /blog/post)'), | ||
| }, async (args) => auditHandlers.handleAuditPage(client, args)); | ||
| server.tool('eds_audit_site', 'Sweep the whole site (or a subtree) and return a prioritized list of content-quality issues across SEO, accessibility, freshness, sitemap coverage, and — when a domain is supplied — performance (Core Web Vitals) and 404s from real-user data. Read-only; safe to run anytime. Pair with the eds_da_* write tools to fix what it finds.', { | ||
| pathPrefix: z | ||
| .string() | ||
| .optional() | ||
| .describe('Only audit pages under this path prefix (e.g. "/blog/"). 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 run (default all): ${ALL_DIMENSIONS.join(', ')}.`), | ||
| domain: z | ||
| .string() | ||
| .optional() | ||
| .describe('Live domain (e.g. www.example.com) for RUM-based performance and 404 checks. Requires EDS_DOMAIN_KEY. Omit to skip those.'), | ||
| days: z | ||
| .number() | ||
| .int() | ||
| .positive() | ||
| .max(365) | ||
| .optional() | ||
| .describe('RUM look-back window in days (default 7).'), | ||
| }, async (args) => auditHandlers.handleAuditSite(client, args)); | ||
| return server; | ||
| } |
+1
-1
| { | ||
| "name": "@focusgts/eds-mcp-server", | ||
| "version": "0.7.0", | ||
| "version": "0.8.0", | ||
| "mcpName": "io.github.focusgts/eds-mcp-server", | ||
@@ -5,0 +5,0 @@ "description": "MCP server for Adobe Edge Delivery Services — preview, publish, metrics, and content operations", |
+10
-3
@@ -13,3 +13,3 @@ <div align="center"> | ||
| **31 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.** | ||
| **33 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.** | ||
| The first MCP server purpose-built for Edge Delivery Services. | ||
@@ -44,3 +44,3 @@ | ||
| flowchart LR | ||
| A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>31 tools"] | ||
| A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>33 tools"] | ||
| B --> C["Admin API<br/>admin.hlx.page"] | ||
@@ -80,3 +80,3 @@ B --> D["Content API<br/>*.aem.live"] | ||
| ## 🛠️ The 31 tools | ||
| ## 🛠️ The 33 tools | ||
@@ -158,2 +158,9 @@ ### Edge Delivery Services — publish, content, analytics | ||
| ### Content audit — find what's wrong, before you fix it | ||
| - `eds_audit_page` | ||
| - `eds_audit_site` | ||
| > **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. Pair it with the `eds_da_*` write tools (dry-run + undo) to fix what it finds. | ||
| --- | ||
@@ -160,0 +167,0 @@ |
230891
19.51%41
32.26%5147
21.39%278
2.58%