Skip to content

Commit debb99e

Browse files
ralyodioclaude
andauthored
Unlink confirmed-dead external URLs at render time (#361)
Link Watch found 404 external links (2026-10-17). Post bodies are webhook/user data, so anchors to known-dead URLs are unwrapped at render (text kept) instead of editing rows. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 417fe26 commit debb99e

4 files changed

Lines changed: 79 additions & 2 deletions

File tree

‎app/(marketing)/blog/[slug]/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { notFound, permanentRedirect } from "next/navigation";
22
import { allSlugs, findAnyPost } from "@/lib/blog/posts";
33
import { fixBlogLinks, resolveMissingSlug } from "@/lib/blog/links";
4+
import { stripDeadLinks } from "@/lib/dead-links";
45
import { splitHtmlForMidAd } from "@/lib/blog/split-html";
56
import { AdUnit } from "@/components/ads/ad-unit";
67
import { env } from "@/lib/env";
@@ -56,7 +57,7 @@ export default async function BlogPost({
5657
if (target) permanentRedirect(target);
5758
notFound();
5859
}
59-
if (post.html) post.html = fixBlogLinks(post.html, slugs);
60+
if (post.html) post.html = stripDeadLinks(fixBlogLinks(post.html, slugs));
6061

6162
// Null for short posts and for plain-text (non-HTML) bodies — those render
6263
// straight through with just the top and bottom units.

‎components/report/markdown-view.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { markdownToHtml } from "@/lib/markdown";
2+
import { stripDeadLinks } from "@/lib/dead-links";
23

34
function wrapTables(html: string): string {
45
return html.replace(
@@ -8,7 +9,7 @@ function wrapTables(html: string): string {
89
}
910

1011
export async function MarkdownView({ markdown }: { markdown: string }) {
11-
const html = wrapTables(await markdownToHtml(markdown));
12+
const html = stripDeadLinks(wrapTables(await markdownToHtml(markdown)));
1213
return (
1314
<article
1415
className="card prose max-w-none overflow-hidden p-4 sm:p-6 [&_*]:min-w-0 [&_a]:break-words [&_a]:underline [&_code]:break-words [&_h1]:mt-0 [&_h1]:break-words [&_h1]:text-2xl [&_h1]:font-extrabold sm:[&_h1]:text-3xl [&_h2]:mt-8 [&_h2]:break-words [&_h2]:border-t [&_h2]:border-[var(--color-border)] [&_h2]:pt-6 [&_h2]:text-xl [&_h2]:font-bold [&_h3]:mt-4 [&_h3]:break-words [&_h3]:font-semibold [&_hr]:my-8 [&_hr]:border-[var(--color-border)] [&_li]:my-1 [&_li]:break-words [&_p]:my-3 [&_p]:break-words [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-[var(--color-bg)] [&_pre]:p-3 [&_pre]:text-xs [&_table]:my-0 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[var(--color-border)] [&_td]:px-3 [&_td]:py-2 [&_td]:text-sm [&_th]:border [&_th]:border-[var(--color-border)] [&_th]:bg-[var(--color-bg)] [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:text-xs [&_th]:uppercase [&_th]:text-[var(--color-muted)] [&_ul]:list-disc [&_ul]:pl-6 [&_.md-table-wrap]:my-4 [&_.md-table-wrap]:-mx-4 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:px-4 sm:[&_.md-table-wrap]:-mx-6 sm:[&_.md-table-wrap]:px-6"

‎lib/dead-links.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Links to other sites that are confirmed dead (404 on re-check 2026-10-17, from
2+
// the ThreatCrush Link Watch report). Post bodies arrive by webhook or as user
3+
// content, so rather than hand-edit rows the dead anchors are unwrapped at
4+
// render time: the link goes, the text stays.
5+
6+
const DEAD_URLS = new Set<string>([
7+
"https://analytics.ahrefs.com",
8+
"https://www.googletagmanager.com",
9+
"https://bxiie.artsfol.io/artwork/in-paint-bxiie-br-34",
10+
"https://c0upons.com/blog/doordash-promo-codes-for-existing-users-2026-workflow",
11+
"https://c0upons.com/blog/encrypted-messaging-coupon-codes-private-deal-sharing",
12+
"https://c0upons.com/blog/shutterfly-promo-codes-2026-checkout-workflow",
13+
"https://c0upons.com/blog/walgreens-coupon-code-workflow",
14+
"https://crates.io/crates/graphforge-rel",
15+
"https://crates.io/crates/piu_node_resolver",
16+
"https://crates.io/crates/rightkit-credentials",
17+
"https://crates.io/crates/willbooster-tree-sitter-rust",
18+
"https://crates.io/crates/wsi-dicom-annotations",
19+
"https://devdrafts.netlify.app",
20+
"https://feedbackminer.co/cookies",
21+
"https://feedbackminer.co/privacy",
22+
"https://feedbackminer.co/sub-processors",
23+
"https://feedbackminer.co/terms",
24+
"https://seniorcontact.app/en/about-us",
25+
]);
26+
27+
/** Placeholder hosts (RFC 2606): never a real page, so never worth a link. */
28+
const PLACEHOLDER_HOST = /^(?:www\.)?example\.(?:com|org|net)$/i;
29+
30+
function normalize(href: string): string {
31+
return href.replace(/#.*$/, "").replace(/\/+$/, "").toLowerCase();
32+
}
33+
34+
export function isDeadExternalUrl(href: string): boolean {
35+
let url: URL;
36+
try {
37+
url = new URL(href);
38+
} catch {
39+
return false;
40+
}
41+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
42+
if (PLACEHOLDER_HOST.test(url.hostname)) return true;
43+
if (url.pathname.startsWith("/cdn-cgi/l/email-protection")) return true;
44+
return DEAD_URLS.has(normalize(href));
45+
}
46+
47+
const ANCHOR = /<a\s[^>]*?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
48+
49+
/** Unwrap every anchor that points at a known-dead URL, keeping its text. */
50+
export function stripDeadLinks(html: string): string {
51+
return html.replace(ANCHOR, (whole, href: string, inner: string) =>
52+
isDeadExternalUrl(href.replace(/&amp;/g, "&")) ? inner : whole,
53+
);
54+
}

‎tests/dead-links.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isDeadExternalUrl, stripDeadLinks } from "@/lib/dead-links";
3+
4+
describe("stripDeadLinks", () => {
5+
it("unwraps dead anchors and keeps the text", () => {
6+
const html = '<p>See <a href="https://c0upons.com/blog/walgreens-coupon-code-workflow">this</a>.</p>';
7+
expect(stripDeadLinks(html)).toBe("<p>See this.</p>");
8+
});
9+
it("matches bare origins with or without a trailing slash", () => {
10+
expect(isDeadExternalUrl("https://www.googletagmanager.com/")).toBe(true);
11+
expect(isDeadExternalUrl("https://www.googletagmanager.com")).toBe(true);
12+
});
13+
it("drops placeholder hosts and email-protection links", () => {
14+
expect(isDeadExternalUrl("https://example.com/blog/post/")).toBe(true);
15+
expect(isDeadExternalUrl("https://x.org/cdn-cgi/l/email-protection#ab")).toBe(true);
16+
});
17+
it("leaves live links alone", () => {
18+
const html = '<a href="https://crawlproof.com/blog/x">x</a> <a href="https://crates.io/crates/serde">s</a>';
19+
expect(stripDeadLinks(html)).toBe(html);
20+
});
21+
});

0 commit comments

Comments
 (0)