I run a Concrete Cost Calculator that estimates what a home concrete job costs from public wage and price data. The data refreshes every month. That makes every dollar amount I type into an article a small liability: it is right on the day I write it, and nobody, including me, will notice the day it stops being right.
So I made a rule for my own writing. The body of an article may not contain a dollar sign followed by a digit. Numbers come from the same code that runs the calculator, charts are drawn from the same function as the tables, and a test fails the moment someone breaks the rule. Here is how that works in an Astro site, what it catches, and what it does not.
The Problem With a Number Inside a Sentence
A price in an article usually lives in two places. It sits in a table, where someone remembers to update it, and it sits in a sentence three paragraphs later ("a small pad runs about ..."), where nobody does. The table and the sentence drift apart, and a careful reader finds the contradiction before you do.
My inputs move more than most. One of them is the Producer Price Index for ready-mix concrete, which the Bureau of Labor Statistics publishes monthly as series PCU327320327320. Over the last 24 months on record, it changed every single month.

The price index behind the material costs moved in all 24 months. Image by the author.
A hand-typed price is like a paper tag tied to a product on a shelf. It looked right when someone wrote it.

A Test That Bans "$" Followed by a Digit
The rule is one regular expression, /\$\s?\d/, run over every file that holds prose. It matches $5, $ 5 and $1,250. I use Vitest's it.each so each file is its own test case, and the failure names the file and the position:
// excerpt from no-manual-dollars.test.ts const GUIDES_DIR = join(import.meta.dirname, '../../content/guides'); const DOLLAR_DIGIT_RE = /\$\s?\d/; describe('src/content/guides/*.mdx: no hand-written dollar figures', () => { const files = readdirSync(GUIDES_DIR).filter((f) => f.endsWith('.mdx')); it.each(files)('%s has no literal "$<digit>"', (file) => { const content = readFileSync(join(GUIDES_DIR, file), 'utf-8'); const match = DOLLAR_DIGIT_RE.exec(content); expect(match, match ? `found "${match[0]}" in ${file} at index ${match.index}` : undefined).toBeNull(); }); }); Two details matter more than the regex. Drafts are scanned too, because a typed price in a draft ships the moment its draft flag flips to false. And for the calculator pages, a second assertion checks that there is exactly one content file for every page in the site's page config, so a new page cannot slip in unscanned.
To show what a failure looks like, I dropped a throwaway article into the folder with one sentence in it: "A small pad costs about $2,170 nationally." This is the real output.

The real test output, with my local path shortened. Image by the author.
The One Folder Allowed to Hold a Price
Some prices have to be typed by a person. When I wrote a guide on winter concrete, the interesting numbers were what ready-mix suppliers charge for cold-weather work: a winter fee per cubic yard, heated water, accelerators, a ground heater rented by the day. Those come from the suppliers' own published price lists. No function can compute them.
They live in one data file, and that folder is the only place the test does not scan. Every record carries the supplier, the state, the amount, the unit, the date the list took effect, the URL, and a quote of the original line. The components that lay those numbers out on the page are scanned like everything else.

Where a typed dollar amount fails the test. Image by the author.
The split gives a reviewer a short question to ask. A price in the data file needs a source next to it. A price anywhere else is a bug.
Putting Numbers Inside Sentences With Components
Tables are easy to generate. Sentences are where hand-typed numbers hide, so the sentences need a way to hold a computed number. In MDX, you can use components inline, so a number in running text is a tag:

What I write, and what the reader sees. Image by the author.
The component is three lines of frontmatter around one call:
// excerpt from cold-weather.ts export function winterFigureText({ size, metric }: WinterFigureQuery): string { const column = winterShareColumns().find((c) => c.sizeLabel === size); if (!column) throw new Error(`winterFigureText: "${size}" is not one of ${SHARE_TABLE_SIZE_LABELS.join(', ')}`); switch (metric) { case 'fee-share': return formatPercentRange(column.yardFeeShare.low, column.yardFeeShare.high, ' to '); case 'heater-share': return formatPercent(column.heaterShare); // ... } } That throw is the one place where the build itself, rather than the test suite, stops me. Astro renders the component at build time, so if I write a pad size the table does not price, the build fails with that message.
A Chart That Can't Disagree With Its Table
The cold-weather guide has a table of winter charges for three pad sizes and a chart of the same numbers. Both read winterShareColumns(), a pure function that prices each pad with the site's cost model and divides the supplier charges by the result.

One function feeds every place a number appears. Image by the author.
The chart is an Astro component that emits plain SVG at build time. No charting library, and no JavaScript reaches the browser; a separate test asserts that the article contains no client: directive, which is how Astro would ship a component's JavaScript. This is the chart as it renders on the live page:

The chart on the live page, rendered from the same function as the table. Image by the author.
Two choices came from the data. The panels have separate scales because the fee is under 2% while the heater runs from about 7% to 18%; on one shared axis the fee would be a flat line on the floor. And the chart's accessible text comes from the same numbers. The SVG carries a <title> and a <desc>, and the description is written by a function:
// excerpt, trimmed export function describeWinterShare(points: WinterSharePoint[]): string { const fee = points.map((p) => `${formatPercentRange(p.fee.low, p.fee.high, ' to ')} on the ${p.sizeLabel} pad`); const heater = points.map((p) => `${formatPercent(p.heater)} on the ${p.sizeLabel} pad`); return ( `The winter fee billed per cubic yard takes a larger share of the base price as the pad gets bigger: ${fee.join('; ')}. ` + `One day of ground heater, a flat charge, takes a smaller share as the pad gets bigger: ${heater.join('; ')}.` ); } The W3C's guidance on complex images asks for a text version that carries the same information as the chart. When the alt text is generated from the chart's own data, it cannot fall behind the chart. If you want a broader take on charts and screen readers, this HackerNoon piece on accessible data visualization covers the ground well, and this one on SVG for data visualization explains why SVG suits the job.
Testing the Claim, Not Just the Number
Generated numbers fix one failure and expose another. The article says, in words, that a fee billed per cubic yard takes a bigger share of a bigger job, while a flat charge takes a smaller one. After the next data refresh, the numbers will update by themselves. The sentence will not.
So the tests also pin the direction of the claims the prose makes:
// excerpt from cold-weather.test.ts it('a per-yard fee takes a larger share as the pour grows', () => { for (let i = 1; i < columns.length; i++) { expect(columns[i].yardFeeShare.low).toBeGreaterThan(columns[i - 1].yardFeeShare.low); expect(columns[i].yardFeeShare.high).toBeGreaterThan(columns[i - 1].yardFeeShare.high); } }); it('a flat charge takes a smaller share as the pour grows', () => { for (let i = 1; i < columns.length; i++) { expect(columns[i].heaterShare).toBeLessThan(columns[i - 1].heaterShare); } }); A third test pins the reason. The cost model charges a fixed setup fee plus a rate per unit, so the base price per cubic yard falls as the pour grows. A per-yard fee is then a bigger share of each cheaper yard, and a one-time charge is spread across a bigger bill.

Why both directions hold: the setup fee is spread over more yards. Image by the author.
If a refresh ever flips one of these, the test fails, and I have to rewrite a paragraph. That is the point: the numbers can change on their own; the claims cannot. This HackerNoon piece on testing generated code makes a similar case for snapshot tests: output that must not change silently deserves a test that fails loudly.
What It Doesn't Catch
The rule has holes, and I would rather list them than have a reader find them.
It is a test, not a build step. My build script runs Astro and a check on the output, and it does not run Vitest. There is no CI on this project yet. The rule holds because I run the tests before I deploy, which is a habit and nothing stronger. The throw inside the component is the only part enforced by the build.
The regex is narrow on purpose. Here is the real expression run against a list of ways to write money:

What the one-line rule catches, and what it misses. Image by the author.
Spelled-out amounts and currency codes pass. So does a typed percentage, which matters here, because the percentages in this very article are computed too. I kept the narrowest pattern that matches how prices are normally written in US English, and so far it has never flagged legitimate text. MDN's regular expressions guide is the place to start if you want to widen it.
The data file is still typed by a person. The test moves hand-typed prices into one place with a source next to each. It does not check that I copied the supplier's number correctly.
Direction tests only cover what I thought to state. If I add a new claim to the prose and forget its test, nothing stops me.
The Rule I'd Reuse Anywhere
If a number can change, do not let it appear anywhere a person can type it. Give sentences a component that renders the number. Feed the table, the chart, and the alt text from one pure function. Then write one test for the boundary and one for each claim the words make.
None of this needs a framework beyond Astro's content collections and a test runner. It took one regex, a few components, and the discipline to keep every price in one folder.