Skip to content

Commit 3b5f774

Browse files
fix(links): honor HTML base URLs and repair six blog posts
1 parent 7b51467 commit 3b5f774

8 files changed

Lines changed: 81 additions & 9 deletions

‎.hermes/linkcheck.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,36 @@ def __init__(self):
2424
super().__init__(convert_charrefs=True)
2525
self.links = []
2626
self.anchors = set()
27+
self.base_href = None
2728

2829
def handle_starttag(self, tag, attrs):
2930
for name, value in attrs:
31+
if tag == "base" and name == "href" and self.base_href is None:
32+
# The first base with href wins, including an empty href.
33+
self.base_href = value or ""
3034
if value is None:
3135
continue
32-
if name in {"href", "src"}:
36+
# A base URL is a resolution directive, not a fetched resource.
37+
if tag != "base" and name in {"href", "src"}:
3338
self.links.append(value)
3439
if name == "id" or (tag == "a" and name == "name"):
3540
self.anchors.add(value)
3641

3742

43+
def document_base_url(parser: PageLinks, document_url: str) -> str:
44+
"""Resolve the first HTML base href against the document's own URL."""
45+
if parser.base_href is None:
46+
return document_url
47+
try:
48+
base_url = urljoin(document_url, parser.base_href.strip(" \t\n\r\f"))
49+
if urlsplit(base_url).scheme.lower() in {"data", "javascript"}:
50+
return document_url
51+
return base_url
52+
except ValueError:
53+
# Invalid bases fall back to the document URL, as in browsers.
54+
return document_url
55+
56+
3857
def collect_html_files(root_dir: str) -> set[str]:
3958
"""Walk root_dir and return set of all .html file paths."""
4059
files = set()
@@ -102,8 +121,9 @@ def check_links(root_dir: str, verbose: bool = False) -> int:
102121

103122
site = urlsplit(SITE_URL)
104123
for source_rel, parser in sorted(pages.items()):
124+
base_url = document_base_url(parser, SITE_URL + source_rel)
105125
for link in parser.links:
106-
target = urlsplit(urljoin(SITE_URL + source_rel, link))
126+
target = urlsplit(urljoin(base_url, link))
107127
# Other projects on the same host are external to this artifact.
108128
if target.scheme not in {"http", "https"} or target.netloc != site.netloc:
109129
continue

‎blog/apicontractguardian-git-branch-openapi-diff-pr-review.html‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<base href="/devforge/">
65
<meta name="viewport" content="width=device-width, initial-scale=1.0">
76
<title>API Contract Guardian: Git-Branch-Aware OpenAPI Diffing for Every PR | DevForge</title>
87
<meta name="description" content="Stop manually reviewing OpenAPI spec changes. API Contract Guardian diffs specs between git branches — detect breaking changes, generate migration guides, and gate PRs automatically. Works with feature branches, release tags, and monorepo paths.">

‎blog/click-to-mcp-three-distribution-channels.html‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<base href="/devforge/">
65
<meta name="viewport" content="width=device-width, initial-scale=1.0">
76
<title>Three Ways to Distribute Your CLI to AI Agents: MCP, Claude Code Plugins, and Agent Skills | DevForge</title>
87
<meta name="description" content="click-to-mcp now reaches AI agents through three distribution channels — MCP servers, Claude Code plugins, and Agent Skills. Learn how to package your CLI for every agent platform with zero code changes.">

‎blog/deadcode-remove-dead-code-nextjs-app-router.html‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<base href="/devforge/">
65
<meta name="viewport" content="width=device-width, initial-scale=1.0">
76
<title>Find and Remove Dead Code in Next.js App Router Projects | DevForge</title>
87
<meta name="description" content="Dead code accumulates fast in Next.js App Router projects. Learn how DeadCode CLI finds unused exports, dead routes, orphaned CSS, and unreferenced components — and removes them safely.">

‎blog/json2sql-cicd-automated-database-seeding.html‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<base href="/devforge/">
65
<meta name="viewport" content="width=device-width, initial-scale=1.0">
76
<title>json2sql in CI/CD: Automate Database Seeding from JSON Fixtures | DevForge</title>
87
<meta name="description" content="Stop committing .sql seed files. Use json2sql in GitHub Actions to generate INSERT statements from JSON fixtures on every CI run — deterministic test databases, zero manual SQL, and pipe-friendly workflows for PostgreSQL, MySQL, and SQLite.">

‎blog/saas-churn-predictor-launch.html‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<base href="/devforge/">
65
<meta name="viewport" content="width=device-width, initial-scale=1.0">
76
<title>SaaS Churn Predictor v1.0: Stripe-Native Churn Scoring for Founders | DevForge</title>
87
<meta name="description" content="SaaS Churn Predictor connects to Stripe in 60 seconds and scores every customer 0-100 on churn risk. Not a CS platform — a prediction engine that tells you who to save this week. 597 tests, 135 suites, free tier available.">

‎blog/schemaforge-convert-prisma-to-drizzle-migration-guide.html‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<base href="/devforge/">
65
<meta name="viewport" content="width=device-width, initial-scale=1.0">
76
<title>Migrate from Prisma to Drizzle ORM: Step-by-Step with SchemaForge | DevForge</title>
87
<meta name="description" content="Migrating from Prisma to Drizzle ORM? SchemaForge converts your schema automatically — models, relations, enums, indexes — in one command. No manual rewriting.">

‎tests/test_linkcheck_fragments.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / ".hermes"))
10-
from linkcheck import check_links
10+
from linkcheck import PageLinks, SITE_URL, check_links, document_base_url
1111

1212

1313
@pytest.mark.parametrize("link", ["#missing", "target.html#missing", "target.html?q=1#missing"])
@@ -63,3 +63,61 @@ def test_github_compatibility_wrapper_reports_broken_links(tmp_path):
6363
result = subprocess.run([sys.executable, str(wrapper), str(tmp_path), "--exit-code"], capture_output=True)
6464
assert result.returncode == 1
6565
assert b"missing fragment" in result.stdout
66+
67+
68+
def test_root_base_changes_nested_stylesheet_resolution(tmp_path):
69+
blog = tmp_path / "blog"
70+
blog.mkdir()
71+
(blog / "post.html").write_text('<base href="/devforge/"><link href="blog.css" rel="stylesheet">')
72+
(blog / "blog.css").write_text("body {}")
73+
# A browser requests /devforge/blog.css, not /devforge/blog/blog.css.
74+
assert check_links(str(tmp_path)) == 1
75+
(tmp_path / "blog.css").write_text("body {}")
76+
assert check_links(str(tmp_path)) == 0
77+
78+
79+
def test_first_base_href_wins_and_is_not_a_resource_link(tmp_path):
80+
(tmp_path / "blog").mkdir()
81+
(tmp_path / "assets").mkdir()
82+
(tmp_path / "blog/post.html").write_text(
83+
'<base target="_blank"><base href="../assets/">'
84+
'<base href="/devforge/missing/"><a href="target.html#ok">target</a>'
85+
)
86+
(tmp_path / "assets/target.html").write_text('<h1 id="ok">Heading</h1>')
87+
# There is no assets/index.html. The base href itself does not fetch one.
88+
assert check_links(str(tmp_path)) == 0
89+
90+
91+
def test_fragment_uses_base_target_instead_of_source_page(tmp_path):
92+
(tmp_path / "blog").mkdir()
93+
(tmp_path / "blog/post.html").write_text(
94+
'<base href="../target.html"><h1 id="section">Source</h1><a href="#section">target</a>'
95+
)
96+
(tmp_path / "target.html").write_text("No target anchor")
97+
assert check_links(str(tmp_path)) == 1
98+
(tmp_path / "target.html").write_text('<h1 id="section">Target</h1>')
99+
assert check_links(str(tmp_path)) == 0
100+
101+
102+
def test_external_base_makes_relative_urls_external(tmp_path):
103+
(tmp_path / "index.html").write_text(
104+
'<base href="https://example.com/assets/"><a href="missing.html">external</a>'
105+
'<a href="https://coding-dev-tools.github.io/devforge/target.html#ok">local</a>'
106+
)
107+
(tmp_path / "target.html").write_text('<h1 id="ok">Target</h1>')
108+
assert check_links(str(tmp_path)) == 0
109+
110+
111+
@pytest.mark.parametrize("href", ["", "data:text/html,example", "javascript:alert(1)", "http://[invalid"])
112+
def test_empty_or_disallowed_first_base_falls_back_without_using_second_base(href):
113+
parser = PageLinks()
114+
parser.feed(f'<base href="{href}"><base href="/devforge/ignored/">')
115+
document_url = SITE_URL + "blog/post.html"
116+
assert document_base_url(parser, document_url) == document_url
117+
118+
119+
def test_escaped_base_example_does_not_change_document_base():
120+
parser = PageLinks()
121+
parser.feed('<code>&lt;base href="/devforge/"&gt;</code>')
122+
document_url = SITE_URL + "blog/post.html"
123+
assert document_base_url(parser, document_url) == document_url

0 commit comments

Comments
 (0)