DEV Community

linkbruno.com
linkbruno.com

Posted on

Markdown links in a monorepo: 7 things I'd do differently after the third big folder move

Every monorepo I've worked in has the same quiet failure. The code is refactored carefully: tests pass, imports are updated by the IDE, the build is green. And somewhere in docs/, packages/*/README.md and a dozen CONTRIBUTING.md files, a few hundred Markdown links now point at folders that no longer exist.

Nobody notices for weeks, because nothing fails. GitHub renders a broken relative link as a perfectly normal-looking link. You only find out when a new hire clicks "see the architecture notes" and lands on a 404.

After going through three large folder reorganisations, here's what I'd do differently if I started a monorepo's docs again today.

1. Decide on one link style, and write it down

The first mess is mixed styles. In one repo we had all of these pointing at the same file:

[setup](../../docs/setup.md)
[setup](/docs/setup.md)
[setup](https://github.com/acme/platform/blob/main/docs/setup.md)
[setup](https://github.com/acme/platform/tree/main/docs/setup.md)
Enter fullscreen mode Exit fullscreen mode

Each style breaks for a different reason. Relative links break when the linking file moves. Root-relative links (/docs/...) work on GitHub but break on most static site generators. Absolute GitHub URLs survive folder moves of the source file, but break when the target moves, when the default branch is renamed, or when the repo is renamed or transferred.

What I'd do: relative links for anything inside the repo, absolute URLs only for things outside it, and a one-paragraph rule in CONTRIBUTING.md saying so. A consistent style is easier to check and easier to rewrite automatically.

2. Check links in CI from the first week, not after the first complaint

It's tempting to postpone link checking until the docs are "big enough". By then, there's a backlog of 300 broken links and nobody wants to own fixing them.

A local-only check is cheap and fast because it doesn't touch the network. Here's the core of a script I'd add on day one:

#!/usr/bin/env python3
"""Fail if any relative Markdown link points at a file that doesn't exist."""
import re, sys, pathlib

LINK = re.compile(r'\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)')
root = pathlib.Path(".").resolve()
bad = 0

for md in root.rglob("*.md"):
    if "node_modules" in md.parts:
        continue
    text = md.read_text(encoding="utf-8", errors="ignore")
    for target in LINK.findall(text):
        if target.startswith(("http://", "https://", "mailto:", "#")):
            continue
        path = target.split("#", 1)[0]
        if not path:
            continue
        resolved = (md.parent / path).resolve()
        if not resolved.exists():
            print(f"{md.relative_to(root)}: missing {target}")
            bad += 1

print(f"{bad} broken relative link(s)")
sys.exit(1 if bad else 0)
Enter fullscreen mode Exit fullscreen mode

It ignores code blocks, reference-style links and anchors, so it's a starting point rather than a finished tool. But it runs in under a second on a large repo, and it catches the most common breakage: a moved folder.

3. Make the move tool update links, not just humans

When you git mv packages/auth packages/identity, your IDE updates imports. It does not update Markdown. That's the gap.

What I'd do: a tiny scripts/move-doc wrapper that moves the file and rewrites every relative link that pointed at it. Even a crude version (find all .md files, resolve each relative link, rewrite the ones whose target equals the old path) removes most of the pain. If people move docs through the script, the link check in CI rarely fires.

4. Link to headings sparingly, and check anchors too

[retry policy](../ops/runbook.md#retry-policy) is wonderfully precise until someone renames the heading to "Retries and backoff". The file still exists, so a file-only check passes, but the reader lands at the top of a 3,000-word runbook.

What I'd do: extend the check to anchors. Generating GitHub-style slugs is a few lines (lowercase, strip punctuation, spaces to hyphens, add -1, -2 for duplicates). And I'd add a soft rule: link to a heading only when the document is long enough that it matters.

5. Keep external links in a separate, slower job

Mixing internal and external checks in the same CI step is a recipe for flaky builds. External sites rate limit you, time out, or block bots, and suddenly a typo fix in a README can't merge because a vendor's docs returned 429.

What I'd do: internal links block the pull request, because they're deterministic and the author can fix them. External links run on a nightly schedule and open an issue with a list. Different failure modes deserve different consequences.

6. Don't link to main for code you're explaining

A doc that says "see the handler in server.ts" is wrong the next time someone adds a line above 120. Line anchors on a moving branch drift silently.

What I'd do: for "this is how it works" explanations, link to the file without a line number. For "this exact code" references in issues and postmortems, use a permalink to a commit. Never a line number on main.

7. Treat the README tree as public API

Package READMEs get linked from places you don't control: npm, internal wikis, Slack messages, old tickets. Moving packages/auth/README.md breaks all of those, and no CI job will tell you.

What I'd do: when a package is renamed or moved, leave a stub README at the old path for a release or two: one line saying where it went. It costs nothing and turns a dead end into a signpost.

The pattern behind all seven

Every one of these comes down to the same thing: links are references, and references need the same care as imports. We'd never accept a refactor that left 40 imports pointing at deleted files. We routinely accept one that leaves 40 doc links pointing nowhere, simply because nothing goes red.

The fix is mostly cultural, with a little tooling: one style, a fast local check that blocks merges, a slower external check that doesn't, and a habit of leaving signposts. For the published side of your docs (the site your users actually read), a crawler such as linkbruno.com can watch for broken links after deploy, which catches the cases a repo-level script can't see, like a redirect removed on the hosting side.

Related reading

How does your team keep doc links working through big refactors: a script, a CI check, or hope? I'd love to see your setups in the comments.

Top comments (0)