From a04c53bae406e7eddbcc9800b4b6fe9f06a8d392 Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Tue, 20 Jan 2026 16:21:07 +0100 Subject: [PATCH] [dyad] Added link analysis feature - wrote 4 file(s) --- src/app/actions.ts | 36 ++++++++- src/components/links-display.tsx | 134 +++++++++++++++++++++++++++++++ src/components/meta-form.tsx | 22 +++++ src/lib/types.ts | 3 +- 4 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/components/links-display.tsx diff --git a/src/app/actions.ts b/src/app/actions.ts index f16d9da..80f068d 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -21,6 +21,13 @@ export interface ImageAltData { size: number | null; } +export interface LinkData { + href: string; + text: string; + type: "internal" | "external" | "anchor" | "other"; + rel: string; +} + export async function extractMetaData(url: string, keyword?: string) { if (!url) { return { error: "URL is required." }; @@ -175,7 +182,6 @@ export async function extractMetaData(url: string, keyword?: string) { const imageSizePromises = imageSrcs.map(async (img) => { try { - // Use a HEAD request for efficiency const res = await fetch(img.src, { method: "HEAD" }); if (res.ok) { const contentLength = res.headers.get("content-length"); @@ -192,6 +198,33 @@ export async function extractMetaData(url: string, keyword?: string) { const imageAltData: ImageAltData[] = await Promise.all(imageSizePromises); + const links: LinkData[] = []; + const pageUrl = new URL(formattedUrl); + + $("a").each((i, el) => { + const href = $(el).attr("href"); + if (!href) return; + + const text = $(el).text().trim(); + const rel = $(el).attr("rel") || ""; + let type: LinkData["type"] = "external"; + let absoluteUrl = href; + + try { + const linkUrl = new URL(href, formattedUrl); + absoluteUrl = linkUrl.href; + if (linkUrl.hostname === pageUrl.hostname) { + type = "internal"; + } + } catch (e) { + if (href.startsWith("#")) type = "anchor"; + else if (href.startsWith("mailto:") || href.startsWith("tel:")) + type = "other"; + } + + links.push({ href: absoluteUrl, text, type, rel }); + }); + return { data: { title, @@ -207,6 +240,7 @@ export async function extractMetaData(url: string, keyword?: string) { keyword: trimmedKeyword || null, keywordCount, images: imageAltData.length > 0 ? imageAltData : null, + links: links.length > 0 ? links : null, }, }; } catch (error) { diff --git a/src/components/links-display.tsx b/src/components/links-display.tsx new file mode 100644 index 0000000..67a6356 --- /dev/null +++ b/src/components/links-display.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ExternalLink, Link as LinkIcon } from "lucide-react"; +import type { LinkData } from "@/app/actions"; + +interface LinksDisplayProps { + links: LinkData[]; +} + +const StatCard = ({ title, value }: { title: string; value: number }) => ( + + + {title} + + +
{value}
+
+
+); + +export function LinksDisplay({ links }: LinksDisplayProps) { + const [filter, setFilter] = useState<"all" | "internal" | "external">("all"); + + const stats = useMemo(() => { + return { + total: links.length, + internal: links.filter((l) => l.type === "internal").length, + external: links.filter((l) => l.type === "external").length, + nofollow: links.filter((l) => l.rel.includes("nofollow")).length, + }; + }, [links]); + + const filteredLinks = useMemo(() => { + if (filter === "all") return links; + return links.filter((l) => l.type === filter); + }, [links, filter]); + + return ( +
+
+ + + + +
+ +
+ + + +
+ + + + + + URL + Anchor Text + Type + Attributes + + + + {filteredLinks.map((link, index) => ( + + + + {link.type === "external" ? ( + + ) : ( + + )} + {link.href} + + + {link.text || "No anchor text"} + + + {link.type} + + + + {link.rel ? ( +
+ {link.rel.split(" ").map((r) => ( + + {r} + + ))} +
+ ) : ( + "-" + )} +
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/meta-form.tsx b/src/components/meta-form.tsx index 4aff96b..9a4c5fc 100644 --- a/src/components/meta-form.tsx +++ b/src/components/meta-form.tsx @@ -14,6 +14,7 @@ import { SchemaDisplay } from "./schema-display"; import { MetaFormInputs } from "./meta-form-inputs"; import { AnalysisTab } from "./analysis-tab"; import { SocialTab } from "./social-tab"; +import { LinksDisplay } from "./links-display"; import type { MetaData } from "@/lib/types"; export function MetaForm() { @@ -108,6 +109,10 @@ export function MetaForm() { } } + // Links Tab + const linksColor: IndicatorColor = + metaData.links && metaData.links.length > 0 ? "green" : "gray"; + // FAQ Tab let faqColor: IndicatorColor = "gray"; if (metaData.faq && metaData.faq.length > 0) { @@ -125,6 +130,7 @@ export function MetaForm() { headlines: headlinesColor, images: imagesColor, social: socialColor, + links: linksColor, faq: faqColor, schema: schemaColor, }; @@ -200,6 +206,12 @@ export function MetaForm() { Images )} + {metaData.links && metaData.links.length > 0 && ( + + {tabColors && } + Links + + )} {metaData.openGraph && metaData.twitter && ( {tabColors && } @@ -263,6 +275,16 @@ export function MetaForm() { )} + {metaData.links && metaData.links.length > 0 && ( + + + + + + + + )} + {metaData.openGraph && metaData.twitter && (