pagesight 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
 
4
+ // --- Types ---
5
+
4
6
  interface MetaTag {
5
7
  name?: string;
6
8
  property?: string;
@@ -16,6 +18,43 @@ interface ParsedHead {
16
18
  jsonLd: unknown[];
17
19
  }
18
20
 
21
+ interface SchemaRule {
22
+ required: string[];
23
+ recommended: string[];
24
+ imageFields: string[];
25
+ nestedRequired?: Record<string, string[]>;
26
+ }
27
+
28
+ interface ValidationIssue {
29
+ type: string;
30
+ level: "required" | "recommended";
31
+ field: string;
32
+ }
33
+
34
+ interface RedirectHop {
35
+ url: string;
36
+ status: number;
37
+ }
38
+
39
+ interface ImageCheck {
40
+ url: string;
41
+ tag: string;
42
+ status: number | null;
43
+ contentType: string | null;
44
+ contentLength: number | null;
45
+ error: string | null;
46
+ }
47
+
48
+ interface LinkResult {
49
+ href: string;
50
+ status: number | null;
51
+ redirectChain: Array<{ url: string; status: number }>;
52
+ finalUrl: string | null;
53
+ error: string | null;
54
+ }
55
+
56
+ // --- Meta tags helpers ---
57
+
19
58
  function parseHead(html: string): ParsedHead {
20
59
  // Extract <head> content (case-insensitive, handles attributes on <head>)
21
60
  const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
@@ -136,13 +175,6 @@ function formatJsonLd(data: unknown, indent = 0): string[] {
136
175
 
137
176
  // --- Structured data validation (per Google Rich Results docs) ---
138
177
 
139
- interface SchemaRule {
140
- required: string[];
141
- recommended: string[];
142
- imageFields: string[];
143
- nestedRequired?: Record<string, string[]>;
144
- }
145
-
146
178
  // Required/recommended fields sourced from Google's Rich Results documentation
147
179
  // https://developers.google.com/search/docs/appearance/structured-data
148
180
  const SCHEMA_RULES: Record<string, SchemaRule> = {
@@ -241,12 +273,6 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
241
273
  },
242
274
  };
243
275
 
244
- interface ValidationIssue {
245
- type: string;
246
- level: "required" | "recommended";
247
- field: string;
248
- }
249
-
250
276
  function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
251
277
  const val = obj[field];
252
278
  if (val !== undefined && val !== null && val !== "") return val;
@@ -403,20 +429,6 @@ function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>
403
429
  return lines;
404
430
  }
405
431
 
406
- interface RedirectHop {
407
- url: string;
408
- status: number;
409
- }
410
-
411
- interface ImageCheck {
412
- url: string;
413
- tag: string;
414
- status: number | null;
415
- contentType: string | null;
416
- contentLength: number | null;
417
- error: string | null;
418
- }
419
-
420
432
  async function followRedirects(
421
433
  url: string,
422
434
  ua: string,
@@ -491,6 +503,15 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
491
503
  const author = getMeta(parsed.meta, "author");
492
504
  if (author) lines.push(`Author: ${author}`);
493
505
 
506
+ // Length warnings
507
+ if (parsed.title && parsed.title.length > 60) {
508
+ lines.push(`WARN: title is ${parsed.title.length} chars — may truncate in search results (recommended: ≤60)`);
509
+ }
510
+ const desc = getMeta(parsed.meta, "description");
511
+ if (desc && desc.length > 155) {
512
+ lines.push(`WARN: description is ${desc.length} chars — may truncate in search results (recommended: ≤155)`);
513
+ }
514
+
494
515
  // HTML entity warnings
495
516
  const entityCheck = [
496
517
  { label: "title", value: parsed.title },
@@ -574,130 +595,458 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
574
595
  return lines.join("\n");
575
596
  }
576
597
 
577
- export function registerMetatagsTool(server: McpServer): void {
598
+ // --- Links helpers ---
599
+
600
+ async function checkLink(href: string): Promise<LinkResult> {
601
+ const chain: Array<{ url: string; status: number }> = [];
602
+ let current = href;
603
+
604
+ try {
605
+ for (let i = 0; i < 10; i++) {
606
+ const res = await fetch(current, {
607
+ method: "GET",
608
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
609
+ redirect: "manual",
610
+ });
611
+
612
+ chain.push({ url: current, status: res.status });
613
+
614
+ if (res.status >= 300 && res.status < 400) {
615
+ const location = res.headers.get("location");
616
+ if (!location) break;
617
+ current = new URL(location, current).href;
618
+ continue;
619
+ }
620
+
621
+ return {
622
+ href,
623
+ status: res.status,
624
+ redirectChain: chain,
625
+ finalUrl: chain.length > 1 ? current : null,
626
+ error: null,
627
+ };
628
+ }
629
+
630
+ return {
631
+ href,
632
+ status: chain[chain.length - 1]?.status ?? null,
633
+ redirectChain: chain,
634
+ finalUrl: current,
635
+ error: "Too many redirects",
636
+ };
637
+ } catch (err) {
638
+ return {
639
+ href,
640
+ status: null,
641
+ redirectChain: chain,
642
+ finalUrl: null,
643
+ error: err instanceof Error ? err.message : String(err),
644
+ };
645
+ }
646
+ }
647
+
648
+ function extractInternalLinks(html: string, origin: string): string[] {
649
+ const links = new Set<string>();
650
+
651
+ for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
652
+ const raw = match[1].trim();
653
+ if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
654
+
655
+ try {
656
+ const resolved = new URL(raw, origin).href;
657
+ if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
658
+ links.add(resolved);
659
+ }
660
+ } catch {
661
+ // Invalid URL, skip
662
+ }
663
+ }
664
+
665
+ return [...links];
666
+ }
667
+
668
+ function formatLinkResults(url: string, results: LinkResult[]): string {
669
+ const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
670
+
671
+ const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
672
+ const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
673
+ const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
674
+
675
+ // Summary
676
+ lines.push("--- Summary ---", "");
677
+ lines.push(`OK: ${ok.length}`);
678
+ if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
679
+ if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
680
+ lines.push("");
681
+
682
+ // Broken links
683
+ if (broken.length > 0) {
684
+ lines.push("--- Broken Links ---", "");
685
+ for (const r of broken) {
686
+ if (r.error) {
687
+ lines.push(`FAIL ${r.href}`);
688
+ lines.push(` Error: ${r.error}`);
689
+ } else {
690
+ lines.push(`FAIL ${r.href} → ${r.status}`);
691
+ }
692
+ }
693
+ lines.push("");
694
+ }
695
+
696
+ // Redirect chains
697
+ if (redirected.length > 0) {
698
+ lines.push("--- Redirect Chains ---", "");
699
+ for (const r of redirected) {
700
+ const hops = r.redirectChain.length - 1;
701
+ const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
702
+ lines.push(`REDIRECT ${r.href}`);
703
+ lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
704
+ }
705
+ lines.push("");
706
+ }
707
+
708
+ return lines.join("\n");
709
+ }
710
+
711
+ // --- Contrast helpers ---
712
+
713
+ function parseHex(hex: string): [number, number, number] | null {
714
+ const clean = hex.replace(/^#/, "");
715
+ if (clean.length === 3) {
716
+ const r = Number.parseInt(clean[0] + clean[0], 16);
717
+ const g = Number.parseInt(clean[1] + clean[1], 16);
718
+ const b = Number.parseInt(clean[2] + clean[2], 16);
719
+ return [r, g, b];
720
+ }
721
+ if (clean.length === 6) {
722
+ const r = Number.parseInt(clean.slice(0, 2), 16);
723
+ const g = Number.parseInt(clean.slice(2, 4), 16);
724
+ const b = Number.parseInt(clean.slice(4, 6), 16);
725
+ return [r, g, b];
726
+ }
727
+ return null;
728
+ }
729
+
730
+ function toHex(r: number, g: number, b: number): string {
731
+ return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
732
+ }
733
+
734
+ function relativeLuminance(r: number, g: number, b: number): number {
735
+ const [rs, gs, bs] = [r, g, b].map((c) => {
736
+ const s = c / 255;
737
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
738
+ });
739
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
740
+ }
741
+
742
+ function contrastRatio(l1: number, l2: number): number {
743
+ const lighter = Math.max(l1, l2);
744
+ const darker = Math.min(l1, l2);
745
+ return (lighter + 0.05) / (darker + 0.05);
746
+ }
747
+
748
+ function wcagLevel(ratio: number, isLargeText: boolean): string {
749
+ if (isLargeText) {
750
+ if (ratio >= 4.5) return "AAA";
751
+ if (ratio >= 3) return "AA";
752
+ return "FAIL";
753
+ }
754
+ if (ratio >= 7) return "AAA";
755
+ if (ratio >= 4.5) return "AA";
756
+ return "FAIL";
757
+ }
758
+
759
+ function findNearestPassing(fg: [number, number, number], bg: [number, number, number], targetRatio: number): string {
760
+ const bgLum = relativeLuminance(...bg);
761
+
762
+ // Try both directions: darken and lighten, pick the one closest to original
763
+ let bestDarken: [number, number, number] | null = null;
764
+ let bestLighten: [number, number, number] | null = null;
765
+
766
+ for (let step = 0; step <= 100; step++) {
767
+ const t = step / 100;
768
+ const dark: [number, number, number] = [fg[0] * (1 - t), fg[1] * (1 - t), fg[2] * (1 - t)];
769
+ if (!bestDarken && contrastRatio(relativeLuminance(...dark), bgLum) >= targetRatio) {
770
+ bestDarken = dark;
771
+ }
772
+ const light: [number, number, number] = [
773
+ fg[0] + (255 - fg[0]) * t,
774
+ fg[1] + (255 - fg[1]) * t,
775
+ fg[2] + (255 - fg[2]) * t,
776
+ ];
777
+ if (!bestLighten && contrastRatio(relativeLuminance(...light), bgLum) >= targetRatio) {
778
+ bestLighten = light;
779
+ }
780
+ if (bestDarken && bestLighten) break;
781
+ }
782
+
783
+ // Pick the direction that changes the color least
784
+ if (bestDarken && bestLighten) {
785
+ const darkDist =
786
+ Math.abs(fg[0] - bestDarken[0]) + Math.abs(fg[1] - bestDarken[1]) + Math.abs(fg[2] - bestDarken[2]);
787
+ const lightDist =
788
+ Math.abs(fg[0] - bestLighten[0]) + Math.abs(fg[1] - bestLighten[1]) + Math.abs(fg[2] - bestLighten[2]);
789
+ return toHex(...(darkDist <= lightDist ? bestDarken : bestLighten));
790
+ }
791
+ return toHex(...(bestDarken ?? bestLighten ?? fg));
792
+ }
793
+
794
+ // --- Tool registration ---
795
+
796
+ export function registerPageTool(server: McpServer): void {
578
797
  server.tool(
579
- "metatags",
580
- "Fetch a page and report its meta tags, Open Graph, Twitter Card, canonical URL, structured data (JSON-LD), and hreflang. Shows what search engines and social platforms see.",
798
+ "page",
799
+ "Analyze what's on a page — meta tags, Open Graph, Twitter Card, structured data (JSON-LD), internal links, and redirect chains. Also includes a WCAG contrast checker for accessibility fixes.",
581
800
  {
582
- url: z.string().url().describe("The URL to fetch and analyze."),
583
- user_agent: z.string().optional().describe("Custom User-Agent string. Default: Googlebot-compatible."),
801
+ url: z
802
+ .string()
803
+ .url()
804
+ .optional()
805
+ .describe("Page URL to analyze. Returns meta tags, structured data, and optionally internal links."),
806
+ check_links: z
807
+ .boolean()
808
+ .optional()
809
+ .describe("Also check all internal links for broken links and redirect chains. Default: false."),
810
+ user_agent: z.string().optional().describe("Custom User-Agent for page fetch. Default: Googlebot-compatible."),
811
+ foreground: z
812
+ .string()
813
+ .optional()
814
+ .describe("Foreground (text) hex color for WCAG contrast check (e.g., '#D4594C'). Use with background."),
815
+ background: z.string().optional().describe("Background hex color for contrast check (e.g., '#FFFFFF')."),
816
+ large_text: z
817
+ .boolean()
818
+ .optional()
819
+ .describe("Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1."),
584
820
  },
585
- async ({ url, user_agent }) => {
586
- try {
587
- const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
588
- const { chain, response: res } = await followRedirects(url, ua);
589
-
590
- if (!res.ok) {
591
- return {
592
- content: [
593
- {
594
- type: "text",
595
- text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
596
- },
597
- ],
598
- };
821
+ async ({ url, check_links, user_agent, foreground, background, large_text }) => {
822
+ // --- Contrast-only mode (no URL) ---
823
+ if (foreground && background && !url) {
824
+ const fg = parseHex(foreground);
825
+ const bg = parseHex(background);
826
+
827
+ if (!fg) return { content: [{ type: "text", text: `Invalid foreground color: ${foreground}` }] };
828
+ if (!bg) return { content: [{ type: "text", text: `Invalid background color: ${background}` }] };
829
+
830
+ const fgLum = relativeLuminance(...fg);
831
+ const bgLum = relativeLuminance(...bg);
832
+ const ratio = contrastRatio(fgLum, bgLum);
833
+ const isLarge = large_text ?? false;
834
+ const level = wcagLevel(ratio, isLarge);
835
+
836
+ const aaThreshold = isLarge ? 3 : 4.5;
837
+ const aaaThreshold = isLarge ? 4.5 : 7;
838
+
839
+ const lines: string[] = [
840
+ "=== Contrast Check ===",
841
+ "",
842
+ `Foreground: ${toHex(...fg)}`,
843
+ `Background: ${toHex(...bg)}`,
844
+ `Text size: ${isLarge ? "large (≥18pt)" : "normal"}`,
845
+ "",
846
+ `Contrast ratio: ${ratio.toFixed(2)}:1`,
847
+ `WCAG AA (${aaThreshold}:1): ${ratio >= aaThreshold ? "PASS" : "FAIL"}`,
848
+ `WCAG AAA (${aaaThreshold}:1): ${ratio >= aaaThreshold ? "PASS" : "FAIL"}`,
849
+ `Result: ${level}`,
850
+ ];
851
+
852
+ if (ratio < aaThreshold) {
853
+ const suggested = findNearestPassing(fg, bg, aaThreshold);
854
+ lines.push("", `Nearest AA-passing foreground: ${suggested}`);
855
+
856
+ const sugParsed = parseHex(suggested);
857
+ if (sugParsed) {
858
+ const sugRatio = contrastRatio(relativeLuminance(...sugParsed), bgLum);
859
+ lines.push(`New ratio: ${sugRatio.toFixed(2)}:1`);
860
+ }
599
861
  }
600
862
 
601
- const html = await res.text();
602
- const parsed = parseHead(html);
603
- const output: string[] = [];
604
-
605
- // Redirect chain (only if there were redirects)
606
- if (chain.length > 1) {
607
- output.push("--- Redirect Chain ---", "");
608
- const parts: string[] = [];
609
- for (const hop of chain) {
610
- parts.push(hop.url);
611
- parts.push(String(hop.status));
863
+ return { content: [{ type: "text", text: lines.join("\n") }] };
864
+ }
865
+
866
+ // --- Page analysis mode ---
867
+ if (url) {
868
+ try {
869
+ const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
870
+ const { chain, response: res } = await followRedirects(url, ua);
871
+
872
+ if (!res.ok) {
873
+ return {
874
+ content: [
875
+ {
876
+ type: "text",
877
+ text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
878
+ },
879
+ ],
880
+ };
612
881
  }
613
- // Format: URL → status → URL → status (drop trailing status)
614
- parts.pop();
615
- output.push(parts.join(" → "));
616
- output.push(`Hops: ${chain.length - 1}`);
617
- output.push("");
618
- }
619
882
 
620
- // Main meta tag report
621
- const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
622
- output.push(formatMetatags(finalUrl, parsed));
623
-
624
- // OG/Twitter image validation
625
- const ogImage = getMeta(parsed.meta, "og:image");
626
- const twitterImage = getMeta(parsed.meta, "twitter:image");
627
- const imagesToCheck: Array<{ url: string; tag: string }> = [];
628
- if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
629
- if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
630
-
631
- if (imagesToCheck.length > 0) {
632
- const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
633
- output.push("", "--- Image Validation ---", "");
634
- for (const check of checks) {
635
- if (check.error) {
636
- output.push(`${check.tag}: FAILED — ${check.error}`);
637
- output.push(` URL: ${check.url}`);
638
- } else if (check.status && check.status >= 400) {
639
- output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
640
- output.push(` URL: ${check.url}`);
641
- } else {
642
- const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
643
- const size = sizeKB ? ` (${sizeKB} KB)` : "";
644
- const type = check.contentType ? ` ${check.contentType}` : "";
645
- output.push(`${check.tag}: OK —${type}${size}`);
646
- if (sizeKB && sizeKB > 1024) {
647
- output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
648
- }
883
+ const html = await res.text();
884
+ const parsed = parseHead(html);
885
+ const output: string[] = [];
886
+
887
+ // Redirect chain (only if there were redirects)
888
+ if (chain.length > 1) {
889
+ output.push("--- Redirect Chain ---", "");
890
+ const parts: string[] = [];
891
+ for (const hop of chain) {
892
+ parts.push(hop.url);
893
+ parts.push(String(hop.status));
649
894
  }
895
+ // Format: URL → status → URL → status (drop trailing status)
896
+ parts.pop();
897
+ output.push(parts.join(" → "));
898
+ output.push(`Hops: ${chain.length - 1}`);
899
+ output.push("");
900
+ }
650
901
 
651
- // Check declared dimensions from meta tags
652
- if (check.tag === "og:image") {
653
- const w = getMeta(parsed.meta, "og:image:width");
654
- const h = getMeta(parsed.meta, "og:image:height");
655
- if (w && h) {
656
- output.push(` Declared dimensions: ${w}x${h}`);
657
- const wn = Number(w);
658
- const hn = Number(h);
659
- if (wn && hn && (wn < 1200 || hn < 630)) {
660
- output.push(` Note: recommended minimum for og:image is 1200x630`);
902
+ // Main meta tag report
903
+ const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
904
+ output.push(formatMetatags(finalUrl, parsed));
905
+
906
+ // OG/Twitter image validation
907
+ const ogImage = getMeta(parsed.meta, "og:image");
908
+ const twitterImage = getMeta(parsed.meta, "twitter:image");
909
+ const imagesToCheck: Array<{ url: string; tag: string }> = [];
910
+ if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
911
+ if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
912
+
913
+ if (imagesToCheck.length > 0) {
914
+ const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
915
+ output.push("", "--- Image Validation ---", "");
916
+ for (const check of checks) {
917
+ if (check.error) {
918
+ output.push(`${check.tag}: FAILED — ${check.error}`);
919
+ output.push(` URL: ${check.url}`);
920
+ } else if (check.status && check.status >= 400) {
921
+ output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
922
+ output.push(` URL: ${check.url}`);
923
+ } else {
924
+ const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
925
+ const size = sizeKB ? ` (${sizeKB} KB)` : "";
926
+ const type = check.contentType ? ` ${check.contentType}` : "";
927
+ output.push(`${check.tag}: OK —${type}${size}`);
928
+ if (sizeKB && sizeKB > 1024) {
929
+ output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
930
+ }
931
+ }
932
+
933
+ // Check declared dimensions from meta tags
934
+ if (check.tag === "og:image") {
935
+ const w = getMeta(parsed.meta, "og:image:width");
936
+ const h = getMeta(parsed.meta, "og:image:height");
937
+ if (w && h) {
938
+ output.push(` Declared dimensions: ${w}x${h}`);
939
+ const wn = Number(w);
940
+ const hn = Number(h);
941
+ if (wn && hn && (wn < 1200 || hn < 630)) {
942
+ output.push(" Note: recommended minimum for og:image is 1200x630");
943
+ }
661
944
  }
662
945
  }
663
946
  }
664
947
  }
665
- }
666
948
 
667
- // Structured data validation
668
- if (parsed.jsonLd.length > 0) {
669
- const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
670
- if (validatedTypes.size > 0 || imageUrls.length > 0) {
671
- output.push("", "--- Structured Data Validation ---", "");
672
- output.push(...formatValidation(issues, validatedTypes));
673
-
674
- // HEAD-check image URLs from structured data
675
- if (imageUrls.length > 0) {
676
- const deduped = [...new Set(imageUrls)];
677
- const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
678
- for (const check of imgChecks) {
679
- if (check.error) {
680
- output.push(`BROKEN Image: ${check.url} — ${check.error}`);
681
- } else if (check.status && check.status >= 400) {
682
- output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
683
- } else {
684
- const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
685
- output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
949
+ // Structured data validation
950
+ if (parsed.jsonLd.length > 0) {
951
+ const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
952
+ if (validatedTypes.size > 0 || imageUrls.length > 0) {
953
+ output.push("", "--- Structured Data Validation ---", "");
954
+ output.push(...formatValidation(issues, validatedTypes));
955
+
956
+ // HEAD-check image URLs from structured data
957
+ if (imageUrls.length > 0) {
958
+ const deduped = [...new Set(imageUrls)];
959
+ const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
960
+ for (const check of imgChecks) {
961
+ if (check.error) {
962
+ output.push(`BROKEN Image: ${check.url} — ${check.error}`);
963
+ } else if (check.status && check.status >= 400) {
964
+ output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
965
+ } else {
966
+ const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
967
+ output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
968
+ }
686
969
  }
687
970
  }
688
971
  }
689
972
  }
690
- }
691
973
 
692
- return {
693
- content: [{ type: "text", text: output.join("\n") }],
694
- };
695
- } catch (err) {
696
- const msg = err instanceof Error ? err.message : String(err);
697
- return {
698
- content: [{ type: "text", text: `Error fetching meta tags: ${msg}` }],
699
- };
974
+ // --- Link check (appended after metatags output) ---
975
+ if (check_links) {
976
+ const origin = new URL(url).origin;
977
+ const links = extractInternalLinks(html, origin);
978
+
979
+ if (links.length === 0) {
980
+ output.push(
981
+ "",
982
+ "--- Internal Links ---",
983
+ "",
984
+ "No internal links found.",
985
+ "Note: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.",
986
+ );
987
+ } else {
988
+ // Check links with concurrency 5
989
+ const concurrency = 5;
990
+ const results: LinkResult[] = [];
991
+ let i = 0;
992
+
993
+ while (i < links.length) {
994
+ const batch = links.slice(i, i + concurrency);
995
+ const settled = await Promise.all(batch.map((href) => checkLink(href)));
996
+ results.push(...settled);
997
+ i += concurrency;
998
+ }
999
+
1000
+ output.push("", formatLinkResults(url, results));
1001
+ }
1002
+ }
1003
+
1004
+ // Append contrast check if colors were also provided
1005
+ if (foreground && background) {
1006
+ const fg = parseHex(foreground);
1007
+ const bg = parseHex(background);
1008
+ if (fg && bg) {
1009
+ const fgLum = relativeLuminance(...fg);
1010
+ const bgLum = relativeLuminance(...bg);
1011
+ const ratio = contrastRatio(fgLum, bgLum);
1012
+ const isLarge = large_text ?? false;
1013
+ const level = wcagLevel(ratio, isLarge);
1014
+ const aaThreshold = isLarge ? 3 : 4.5;
1015
+ const aaaThreshold = isLarge ? 4.5 : 7;
1016
+ output.push(
1017
+ "",
1018
+ "--- Contrast Check ---",
1019
+ "",
1020
+ `Foreground: ${toHex(...fg)} Background: ${toHex(...bg)}`,
1021
+ `Ratio: ${ratio.toFixed(2)}:1 AA: ${ratio >= aaThreshold ? "PASS" : "FAIL"} AAA: ${ratio >= aaaThreshold ? "PASS" : "FAIL"} Result: ${level}`,
1022
+ );
1023
+ if (ratio < aaThreshold) {
1024
+ const suggested = findNearestPassing(fg, bg, aaThreshold);
1025
+ output.push(`Nearest AA-passing foreground: ${suggested}`);
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ return {
1031
+ content: [{ type: "text", text: output.join("\n") }],
1032
+ };
1033
+ } catch (err) {
1034
+ const msg = err instanceof Error ? err.message : String(err);
1035
+ return {
1036
+ content: [{ type: "text", text: `Error analyzing page: ${msg}` }],
1037
+ };
1038
+ }
700
1039
  }
1040
+
1041
+ // --- No valid input ---
1042
+ return {
1043
+ content: [
1044
+ {
1045
+ type: "text",
1046
+ text: "Provide 'url' for page analysis or 'foreground' + 'background' for contrast check.",
1047
+ },
1048
+ ],
1049
+ };
701
1050
  },
702
1051
  );
703
1052
  }