The dependency with no version number

There's a file in this repository that belongs to a different repository. web/public/pforex.js is a hand-copied duplicate of static/pforex.js from pforex, sitting in the static directory of a site that has nothing to do with maintaining it.

That's not sloppiness, it's the only option that fits — but it does mean throwing away most of what a package manager is for. This is about what gets thrown away, and what you have to build to get it back.

Why there's a copy at all

pforex converts prices on a page into whatever currency the reader picks. Its entire design constraint is that it gets injected into storefront themes nobody on our end will ever see: no build step, no framework, no cooperation from the page. One script tag and some HTML tags.

The demo page for it on this site could have reimplemented the rendering in Svelte — it's not much code, and the page is already a Svelte app with a bundler sitting right there. It doesn't, and the reason is that a demo which reimplements the thing it's demonstrating is a lie. If the library's claim is "one script tag into a page you don't control," the honest demo loads it with one script tag. So the page appends a <script src="/pforex.js"> at mount and lets the library find its own tags, exactly as a storefront would.

Which means the site has to serve /pforex.js. Which means the file has to be here.

Why not just publish it to npm

Nothing stops you from publishing it. npm publish is free and takes a minute. The question is who's on the other end.

npm's real guarantees — a version number, a lockfile, a resolved tree, one command that tells you what's out of date — are delivered by a bundler, at build time. That's the machinery that turns "pforex": "^0.5.0" into bytes in a page. A Shopify theme doesn't have that machinery. Neither does the legacy template nobody wants to reopen, or the CMS-owned page where the most you can do is paste one line into a settings field. For the people this library is actually for, npm install pforex ends with a folder in node_modules and the original problem completely untouched: how does this get onto the page?

The usual answer is a CDN — unpkg.com/pforex@0.5.0/pforex.js — and it's a reasonable one. It's also a trade rather than a solution. Every page load now depends on a third party being up and being uncompromised, on a storefront where a broken script tag means broken prices in front of customers. integrity hashes fix the compromise half and make the outage half worse, since a mismatched hash is a script that doesn't load at all. Serving the file yourself costs you a copy to maintain and buys you a dependency that can't go down separately from your site.

And there's a smaller irony in packaging it: to be useful from npm it would want an ES module for bundler consumers and an IIFE build for script tags. Two artifacts, from a build step, in a library whose entire pitch is having neither.

What the copy gives up

Everything in that first paragraph. No version number, no lockfile, no npm outdated, no npm audit. What it had instead, for a while, was a comment:

/*
 * VENDORED -- do not edit here.
 *
 * This is a verbatim copy of static/pforex.js from
 * https://github.com/japherwocky/pforex (commit 1df6c3f), served at
 * /pforex.js so the demo page at /pforex can load it the same way a
 * storefront would: one script tag, no build step.
 */

That comment is doing real work — it says where the file came from, which commit, and not to edit it here. It's also the weakest possible mechanism, because a comment cannot notice that it has become false. Upstream ships a fix; this file stays exactly as it is; the comment goes on cheerfully naming a commit from three months ago. Nothing anywhere turns red. The demo page keeps working, which is the worst part — you find out when someone reads the source and asks why the version on the site is behind the one in the README.

The part that isn't about packaging: injection

Worth separating two things that both get called injection, because only one of them is the design decision that mattered.

The first is delivery — how the code physically arrives on the page. Bundler, CDN, or a copy you serve. That's the section above, and it's a logistics question.

The second is what the library does with the things it can't know. pforex needs exchange rates. The tempting version hardcodes a fetch to a rates API and moves on. What it does instead is take the rate source as a parameter:

Pforex.init({
    base: 'USD',              // what your prices are authored in
    ratesUrl: '/rates.json',  // where to fetch exchange rates
});

That's dependency injection in its plainest form — the thing that varies is handed in rather than reached for — and in a library of a few hundred lines with no framework anywhere near it, the payoff is immediate and concrete.

It's what makes the test suite deterministic. Because rates are something the library is given rather than something it reaches for halfway through rendering, they're a value sitting on the object, and the suite can just put its own there:

Pforex.rates = { USD: 1, EUR: 0.5, JPY: 100, SEK: 10, INR: 100 };

Every assertion then gets to be an exact string. ₹5,00,000.00 — the lakh grouping, the symbol, the two decimal places, the exact digits. Without that separation, the suite either hits a live rates API (flaky, and a different answer tomorrow) or asserts against a regex that would happily pass on ₹0.00. Rates arriving from outside is what lets the formatting be tested as formatting.

And it's what keeps a shop from forking. A store that already has rates in its own backend, or wants a margin baked into them, or is required to use a particular provider, points ratesUrl at itself. The alternative — a fork, diverging quietly from upstream forever — is the same drift problem as the vendored copy, just with nobody watching it at all.

The default still has to be good, or the seam is just homework: it points at the European Central Bank's daily reference rates by way of Frankfurter, which needs no key. Injectable, with a default that works, beats configurable.

CI is how you buy the guarantee back

Here's the shape both of these problems have in common. Something in the repo is supposed to be identical to something else, and the only thing enforcing that is somebody remembering.

pforex had this exact bug in a second place. static/pforex.min.js is committed to the repo — it has to be, there's no build step for consumers — and it was maintained by running build.sh and remembering to commit the result. It had already silently drifted from the source at least once, so the shipped minified build and the readable source were different programs.

The fix is a few lines of YAML, and it isn't a test:

- name: Check pforex.min.js is in sync with source
  run: |
    ./build.sh
    if ! git diff --exit-code -- static/pforex.min.js; then
      echo "static/pforex.min.js does not match static/pforex.js."
      echo "Run ./build.sh and commit the result."
      exit 1
    fi

Regenerate the derived artifact and fail on any diff. It asserts nothing about behavior — it asserts that two things that are supposed to be the same still are. That's a category of check worth reaching for more often than most people do, and it's strictly better than a documented step, because a documented step is a thing you can forget and this isn't.

The vendored copy on this site is the same shape with a network hop in the middle. A small script fetches upstream's file, strips the header comment that this repo owns, and compares the rest:

$ python scripts/check_vendored_pforex.py
in sync -- web/public/pforex.js matches japherwocky/pforex@1df6c3f

Same script with --update pulls the new version in, so the fix for a failing check is one command rather than a manual copy-paste that might introduce the next drift while fixing this one.

Three details that decide whether it's any good

Run it on a schedule, not on push. Upstream committing something has nothing to do with whatever pull request is open here. Wiring this into the main CI job would mean somebody's unrelated frontend change goes red because a library in a different repository moved. Do that twice and people learn that red doesn't mean anything, which costs more than the check was ever worth. It's a weekly cron and a workflow_dispatch button.

Keep "it drifted" and "the check broke" apart. The script exits 0 in sync, 1 drifted, 2 couldn't tell — a network failure, a rate limit, a malformed header. If those collapse into one non-zero code, then the day GitHub's API times out you get a pull request that says nothing, or worse, a check that quietly passes:

case "$status" in
  0) echo "drifted=false" >> "$GITHUB_OUTPUT" ;;
  1) echo "drifted=true"  >> "$GITHUB_OUTPUT" ;;
  *) echo "::error::the vendor check could not run"; exit 1 ;;
esac

A check that fails open is worse than no check, because it also produces a green tick that somebody will believe. Exit 2 has to fail the job loudly and not touch the branch.

Open a pull request, not an issue. An issue says "go do a thing." A pull request says "here is the thing, look at the diff." Since --update already produced the new file, the workflow may as well commit it. One long-lived branch, force-pushed, and a new PR only if none is open — a fresh branch per run would leave a pile of stale pull requests behind any week upstream committed twice.

It still wants a human on it. The demo page renders every price through that file; the diff being small is not the same as the page still working. The PR body says so.

What it actually costs

A weekly cron job, a hundred-odd lines of Python, and a workflow file, to replace one line in a package.json and a command that already exists.

That's the honest accounting, and it's only worth paying because the constraint that forced the copy isn't going anywhere. The library is for pages that have no build step. A site demonstrating it with a build step would be demonstrating something else.

What's left is a duplicate file with a comment on top saying where it came from — the same arrangement as before, and the same one people have been quietly getting wrong for as long as vendoring has existed. The only difference is that now something checks.