npm Resolves Your README's Relative Links Against the repository Field — and a Published Version Can Never Be Repaired
📅 July 31, 2026 · ⏱️ 12 min read
There is a specific kind of packaging mistake that has no undo button, and almost every write-up about it stops one paragraph before the part that matters.
The mistake: you omit repository from package.json. The consequence everybody documents: relative links in your README break on the package page. The consequence nobody documents: you cannot go back and fix the listing you already published. npm versions are immutable. The record for a version that shipped without those fields keeps returning nothing for them, forever, and the only mechanism that changes what a reader sees on npmjs.com is a different version number.
We shipped this. Below is our own registry document, our own README’s link inventory, and an honest account of what is and is not repairable — every number printed next to the command that produced it, all re-run on 2026-07-31.
The mechanism
When npm packs a tarball, it does not just copy your README verbatim into the registry. It rewrites the README’s relative markdown targets into absolute URLs, and the base it rewrites them against comes from the repository field of package.json.
That transform is the whole feature. It is why a README that reads [the proofs doc](docs/PROOFS.md) in your repo can still be a working link on a page hosted at a completely different origin. It is also, predictably, a rich source of bugs — npm/cli#6206, titled “…relative url of local files in README are not transformed to absolute url of GitLab repository” and opened 2023-02-27, is one of several issues about the transform mis-firing for particular repo URL shapes.
Every one of those issues assumes the field is there and the transform is wrong. The failure below is the more boring and more common one: the field is not there at all, so there is no base to resolve against, and the relative target is left to resolve against whatever origin the page happens to be served from.
What our own registry document says
The registry serves a plain JSON document per package. No auth, no tooling, just a GET:
$ curl -s https://registry.npmjs.org/verifyhash | node -e '
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
const j=JSON.parse(s), l=j["dist-tags"].latest, v=j.versions[l];
console.log("latest ", l);
for (const k of ["homepage","repository","bugs","keywords"])
console.log(k.padEnd(10), JSON.stringify(v[k]));
});'
latest 0.1.8
homepage undefined
repository undefined
bugs undefined
keywords undefined
undefined here is not a formatting artifact. The keys are absent from the published manifest entirely — a consumer that asks for repository on this version gets null. Printing the key list for that same version document makes it unambiguous:
name, version, description, license, main, exports, bin, engines, scripts,
dependencies, devDependencies, _id, gitHead, _nodeVersion, _npmVersion, dist,
_npmUser, directories, maintainers, _npmOperationalInternal, _hasShrinkwrap
Twenty-one keys. homepage, repository, bugs and keywords are not among them. The same document reports that version’s publish time as 2026-07-06T21:49:37.513Z and the packument’s modified as 2026-07-06T21:49:37.633Z — 120 milliseconds later, and unchanged since. Nothing has touched that record in the weeks since it landed, because nothing can.
Meanwhile, in the package’s own source tree, all four fields are present and committed:
$ node -e 'const j=require("./package.json");
console.log(j.homepage, "|", j.repository.url, "|", j.bugs.url, "|", j.keywords.length+" keywords")'
https://verifyhash.com | git+https://github.com/verifyhash/verifyhash.git | https://github.com/verifyhash/verifyhash/issues | 10 keywords
That gap between the tree and the registry is the entire subject of this post.
How much of the README this actually touches
The interesting question is not “is a field missing” but “how many links does that field’s absence strand”. Counting is a two-line job, but only if you say precisely what you counted — so here is each command and exactly what its figure means.
$ grep -o '](' README.md | wc -l
90
$ grep -o '](http' README.md | wc -l
7
$ grep -o '](#' README.md | wc -l
6
Read that as: 90 markdown link-or-image targets in total (the ]( sequence is the target-open for both [text](target) and , so images are included in the 90 — exactly one of the 90 is an image, from grep -o '!\[[^]]*\](' README.md | wc -l → 1). Of the 90, 7 are absolute http/https URLs, and 6 are in-page anchors beginning #, which resolve inside the rendered document and do not care about repository at all.
Subtracting: 77 repo-relative targets — docs/PROOFS.md, docs/ANCHORING.md, challenge/, and so on. Those 77 are the ones with nothing to resolve against.
Two caveats, because a count you cannot defend is worse than no count. First, all 90 hits are in prose, not inside fenced code blocks — checked by walking the file line by line, flipping an “in fence” flag on every line whose first non-space characters are a triple backtick, and tallying the two buckets separately. Result: in fenced code: 0, outside: 90. So none of the 90 is a false positive from a sample snippet. Second, this figure was quoted to us as “84 links, 7 absolute” from an earlier review; re-running the command today gives 90, so the older figure was stale. The README grew. That is the ordinary fate of any hardcoded count in a document that is not regenerated, which is why the command is printed above rather than the number alone.
Third — and this matters more than it looks — these counts are of the README in the repository today, which is the file a future release would carry. It is not the copy the registry is currently serving; that one was packed a while ago and is a different file, as the section on what the registry stores measures directly.
The half nobody writes down: the version is immutable
Here is where the existing coverage stops and the real cost starts.
Search this problem and you will find the npm CLI issues, a feedback discussion about the repository and directory fields for monorepos, threads on the old npm.community forum, and a scattering of framework-specific reports. They all explain the field. Not one of them says the next sentence out loud:
A published npm version is immutable, so a listing that shipped without those fields can never be repaired — only superseded.
The registry will not accept a second upload for a version number it already has. There is no “edit metadata” surface, no re-index, no admin path that reaches back into a published manifest and adds a repository key. Fixing package.json in your repo — which we have done, as the command above shows — changes nothing about what npmjs.com serves. The corrected manifest reaches the registry only by being attached to a version number the registry has never seen, and until that happens the old record stands exactly as published.
That immutability is a feature, and a load-bearing one. It is the reason a lockfile means anything, the reason an integrity hash pinned in CI keeps matching, the reason a dependency you audited on Tuesday is the same bytes on Friday. Nobody sane wants mutable published versions. But the same property that makes your supply chain auditable makes your metadata mistakes permanent, and package authors meet the second half of that trade without warning.
The practical shape of it: the moment a version goes out, its manifest is a historical artifact. Whatever repository said at pack time is what it says for the lifetime of that version — including for anyone who installs it two years from now, and including for the rendered page that anyone reaching your package from search lands on.
What is mutable, honestly
“Immutable” gets stated too broadly, so here is the precise line, because the exceptions matter if you are trying to reason about what a published package can still become:
- A version’s manifest fields and its tarball: frozen.
repository,homepage,bugs,keywords,dependencies, the file contents, the integrity hash. None of these can change for an existing version. - Dist-tags: mutable.
latestis a pointer, not a property of a version, and it can be repointed at any published version at any time. - Deprecation state: mutable. A published version can be flagged deprecated with a message after the fact, and the flag shows up for installers. It does not alter the manifest fields.
- Maintainer/owner lists: mutable.
- Removal: possible, and separate from editing. A version can be removed under npm’s unpublish policy — but removal is not repair, and it does not hand the number back.
Our own document happens to illustrate that last point. The version list it returns is:
0.1.0, 0.1.1, 0.1.2, 0.1.3, 0.1.4, 0.1.5, 0.1.7, 0.1.8
Eight entries, and 0.1.6 is in neither the versions map nor the time map. Being scrupulous: from this document alone you cannot tell whether 0.1.6 was published and later removed or simply never existed — the packument is the public record, and the public record has a hole in it. Which is itself the lesson. The registry document is the only artifact a stranger can see, and you get exactly one chance to write each line of it.
Where we actually stand
To be exact, because vagueness here would be a lie by omission:
The four fields —
homepage,repository,bugs,keywords— are committed in the package’s source tree. The published record forverifyhash@0.1.8still returns null for all four, as thecurloutput above shows, and it will keep returning null for that version permanently.
Nothing in this post fixes our npm listing. The listing is not fixed. Whether and when a new version carries the corrected manifest to the registry is a release decision, made by a human on their own schedule, and it is deliberately not something this post has an opinion about. If you are reading this and the listing looks repaired, that is because a later version shipped — not because 0.1.8 changed. 0.1.8 cannot change.
One more thing the registry does to your README
While measuring the above, a second surprise fell out that is worth any package author’s attention.
$ curl -s https://registry.npmjs.org/verifyhash | node -e '
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
console.log("registry readme chars:", JSON.parse(s).readme.length); });'
registry readme chars: 65536
$ node -e 'const s=require("fs").readFileSync("README.md","utf8");
console.log("local chars:", s.length, "bytes:", Buffer.byteLength(s));'
local chars: 76533 bytes: 77013
The packument’s readme field is exactly 65536 characters — 2^16, a hard cap, not a coincidence. And the cut is not at a section boundary. The field’s last 30 characters are:
ck), **NOT a trusted timestamp
That is mid-sentence and mid-emphasis: the closing ** never arrives, and neither does the rest of the paragraph. A README longer than 64 KiB is truncated in the registry document at exactly that boundary, wherever in your prose it happens to fall.
Two things stop that from being a clean subtraction against the local file, and both are worth stating rather than papering over.
The first is arithmetic: the local README is 76,533 characters today, and 76533 − 65536 = 10997, so at today’s size at least 10,997 characters — the last 14.4% — would not fit under the cap.
The second is that the published readme is the one packed with 0.1.8 on 2026-07-06, not today’s file. Walking both strings until they disagree:
// r = the registry document's readme string
// loc = readFileSync('README.md', 'utf8')
let i = 0; while (i < r.length && r[i] === loc[i]) i++;
console.log(i); // -> 14
They diverge at character 14 — immediately after # verifyhash and its blank line. The published copy goes straight into a one-line tagline; the current file now opens with a sponsor badge.
Which is the immutability point again, from a different angle. The README a stranger reads on the package page is not your README. It is a frozen copy of whatever your README was at pack time, truncated at 64 KiB, and it has been drifting from your repo ever since.
What I could not measure
Two honest limits, stated because the alternative is quoting a number I did not obtain.
I did not scrape the rendered package page. Fetching the package page over HTTP returns a client-rendered shell with no README markup in it:
curl -s https://www.npmjs.com/package/verifyhash | wc -c
# 5479
That is 5,479 bytes of application shell. Grepping that response for a rendered docs/ href returns nothing, because the hrefs do not exist until JavaScript runs. Every claim above about field presence comes from the registry JSON, which is a real, complete, server-rendered artifact. Claims about the exact URL the website’s markdown renderer produces for a relative target when repository is absent are the documented behaviour of the transform, not something I re-derived from the live DOM today.
I did not test the republish refusal by attempting one. Verifying “the registry rejects a second upload of an existing version” empirically would require performing a publish, which is not a thing you do to check a blog post. That behaviour is npm’s stated registry policy and is not in dispute anywhere in the issue trackers above; it is stated here as policy, not as a measurement I took.
The takeaway
If you maintain a package:
repositoryis not decoration. It is the base URL that every relative link and image in your README is resolved against, and without it those targets have nothing to resolve to. On our README that is 77 of 90 targets.- The cost is asymmetric in time. Getting the field right costs one line before the first release. Getting it wrong costs a permanent record plus a version number, and there is no state in between.
- Check the registry, not your repo. Your
package.jsonon disk tells you what a future version will say. Onlyhttps://registry.npmjs.org/<name>tells you what strangers currently see, and the two can disagree for as long as you let them. - The generalisation past npm: any publish step that snapshots metadata into an immutable artifact — a container tag pinned by digest, a Maven release, a signed archive — has this same shape. The instinct that “I’ll fix it later” is a normal, cheap operation is a habit picked up from mutable systems, and it does not survive contact with an immutable one.
The reason this post exists is that the searched question is not “what does repository do” — that is answered in a dozen places. It is “I forgot it, how do I fix the page”. The answer is that you don’t fix that page. You write a better one next to it.
Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.