Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/cowork-auto-pr.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches.
# Opens a PR automatically when such a branch is pushed (sandbox cannot reach
# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN).
name: cowork-auto-pr
on:
push:
branches: ['cowork/improve-**']
permissions:
contents: read
pull-requests: write
jobs:
ensure-pr:
runs-on: ubuntu-latest
steps:
- name: Open PR for this branch if none exists
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length')
if [ "$existing" = "0" ]; then
gh pr create --repo "$GITHUB_REPOSITORY" \
--head "$GITHUB_REF_NAME" \
--title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \
--body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones."
else
echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do."
fi
28 changes: 18 additions & 10 deletions .hermes/linkcheck.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,17 +37,25 @@ def build_actual_pages(all_files: set[str]) -> set[str]:

def resolve_link(link: str, source_dir: str) -> str:
"""Resolve a relative href to an absolute site-relative path."""
if link.startswith("../"):
parts = source_dir.split("/") if source_dir else []
up_count = link.count("../")
resolved_parts = parts[:-up_count] if len(parts) >= up_count else []
remaining = link[3 * up_count :]
resolved = "/".join(resolved_parts + [remaining]) if resolved_parts else remaining
elif link.startswith("./"):
resolved = (source_dir + "/" + link[2:]) if source_dir else link[2:]
if link.startswith("http://") or link.startswith("https://") or link.startswith("#"):
return link
if link.startswith("/"):
return link.lstrip("/")

if source_dir:
path = source_dir + "/" + link
else:
resolved = (source_dir + "/" + link) if source_dir else link
return resolved.replace("//", "/")
path = link

normalized = os.path.normpath(path).replace(os.sep, "/")

# Cap at site root: strip any leading ../ that goes above root
while normalized.startswith("../"):
normalized = normalized[3:]
if normalized.startswith("/"):
normalized = normalized[1:]

return normalized


def check_links(root_dir: str, verbose: bool = False) -> int:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
[![Deploy](https://img.shields.io/badge/deploy-GitHub%20Pages-brightgreen)](https://coding-dev-tools.github.io/devforge/)
[![Stars](https://img.shields.io/github/stars/Coding-Dev-Tools/devforge?style=social)](https://github.com/Coding-Dev-Tools/devforge/stargazers)

Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **60 articles and growing.**
Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **70 articles and growing.**

**Live site:** [coding-dev-tools.github.io/devforge/](https://coding-dev-tools.github.io/devforge/)

Expand All@@ -14,7 +14,7 @@ Landing page, documentation, pricing, alternatives, and blog for the **DevForge
| [Home](https://coding-dev-tools.github.io/devforge/) | Hero, stats, feature cards, CTA |
| [Pricing](https://coding-dev-tools.github.io/devforge/pricing.html) | Free / Pro / Team / Enterprise tiers |
| [Alternatives](https://coding-dev-tools.github.io/devforge/alternatives.html) | 11 comparison tables vs competitors |
| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 60+ articles and tutorials |
| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 70+ articles and tutorials |
| [Docs](https://coding-dev-tools.github.io/devforge/docs.html) | Tool documentation hub |
| [Quickstart](https://coding-dev-tools.github.io/devforge/quickstart.html) | Get started in 60 seconds |
| [FAQ](https://coding-dev-tools.github.io/devforge/#faq) | Common questions |
Expand Down
67 changes: 47 additions & 20 deletions sitemap.xml
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,9 +33,6 @@
<url>
<loc>https://coding-dev-tools.github.io/devforge/quickstart.html</loc>
<lastmod>2026-05-17</lastmod>

<loc>https://coding-dev-tools.github.io/revenueholdings.dev/blog/openapi-diff-tools-comparison.html</loc>
<lastmod>2026-05-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
Expand DownExpand Up@@ -447,28 +444,58 @@
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/traction-hits-680-developers.html</loc>
<lastmod>2026-05-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>

<loc>https://coding-dev-tools.github.io/revenueholdings.dev/alternatives.html</loc>
<lastmod>2026-05-15</lastmod>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-distribution-channels.html</loc>
<lastmod>2026-06-13</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-transport-modes.html</loc>
<lastmod>2026-05-28</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/configdrift-scan-multi-environment-configs-one-command.html</loc>
<lastmod>2026-05-28</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/deploydiff-cost-governance-before-you-deploy.html</loc>
<lastmod>2026-05-28</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/saas-churn-predictor-launch.html</loc>
<lastmod>2026-05-28</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/apicontractguardian-git-branch-openapi-diff-pr-review.html</loc>
<lastmod>2026-08-26</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/revenueholdings.dev/quickstart.html</loc>
<lastmod>2026-05-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
<loc>https://coding-dev-tools.github.io/devforge/blog/deadcode-remove-dead-code-nextjs-app-router.html</loc>
<lastmod>2026-08-26</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/devforge/blog/json2sql-cicd-automated-database-seeding.html</loc>
<lastmod>2026-08-26</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://coding-dev-tools.github.io/revenueholdings.dev/blog/clean-up-react-dead-code.html</loc>
<lastmod>2026-05-15</lastmod>
<changefreq>monthly</changefreq>
<loc>https://coding-dev-tools.github.io/devforge/blog/schemaforge-convert-prisma-to-drizzle-migration-guide.html</loc>
<lastmod>2026-08-26</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
</urlset>
Expand Down
18 changes: 18 additions & 0 deletions tests/test_linkcheck.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,24 @@ def test_resolve_link_double_slash():
assert result == "blog/page.html"


def test_resolve_link_absolute_path():
"""Absolute path /about.html strips leading slash."""
result = resolve_link("/about.html", "blog")
assert result == "about.html"


def test_resolve_link_up_with_intermediate_segments():
"""../foo/../bar.html correctly cancels the intermediate segment."""
result = resolve_link("../foo/../bar.html", "blog/2024")
assert result == "blog/bar.html"


def test_resolve_link_up_capped_at_root():
"""../../ from a shallow dir is capped at site root."""
result = resolve_link("../../page.html", "blog")
assert result == "page.html"


# ---- CLI argument parsing ----


Expand Down
2 changes: 1 addition & 1 deletion tests/test_linkcheck_additional.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
_SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes"
sys.path.insert(0, str(_SCRIPT_DIR))

from linkcheck import build_actual_pages
from linkcheck import build_actual_pages # noqa: E402


class TestBuildActualPages:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_linkcheck_edge_cases.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
_SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes"
sys.path.insert(0, str(_SCRIPT_DIR))

from linkcheck import check_links, main
from linkcheck import check_links, main # noqa: E402


class TestCheckLinksEdgeCases:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_sitemap.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Regression tests: sitemap.xml must exactly cover the site's real pages.

Every non-404 HTML file in the repo must have a <loc> entry, and every
<loc> must resolve to a real page (index.html is represented as "/").
"""

import os
import re
import xml.etree.ElementTree as ET
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
SITEMAP = REPO / "sitemap.xml"
BASE = "https://coding-dev-tools.github.io/devforge/"
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"


def _actual_pages():
pages = set()
for root, dirs, files in os.walk(REPO):
rel_root = Path(root).relative_to(REPO)
dirs[:] = [d for d in dirs if d not in (".git", "drafts", ".hermes", "tests")]
for fn in files:
if fn.endswith(".html") and fn != "404.html":
p = str(rel_root / fn).replace(os.sep, "/")
pages.add(BASE if p == "index.html" else BASE + p)
return pages


def _sitemap_urls():
tree = ET.parse(SITEMAP)
return {e.text for e in tree.getroot().iter(NS + "loc")}


def test_sitemap_parses():
ET.parse(SITEMAP)


def test_sitemap_covers_every_page():
missing = _actual_pages() - _sitemap_urls()
assert not missing, f"pages missing from sitemap.xml: {sorted(missing)}"


def test_sitemap_has_no_stale_urls():
stale = _sitemap_urls() - _actual_pages()
assert not stale, f"stale sitemap.xml URLs with no page: {sorted(stale)}"


def test_no_duplicate_locs():
urls = re.findall(r"<loc>(.*?)</loc>", SITEMAP.read_text(encoding="utf-8"))
assert len(urls) == len(set(urls))
Loading