HaulFare is a free US car shipping price guide. Quote requests go to one licensed auto transport broker, which confirms the actual price, so the site's job is to publish honest, current estimates. Nearly every page quotes a dollar figure, and those figures have to move when diesel or the season moves.
That is awkward for a static site: more than a hundred prerendered pages, much of them prose, must agree on what a 1,000 mile trip costs and move to new prices on the same day. Here is how we do it with Astro 7.
One pricing module, used everywhere
The model is one TypeScript file, src/lib/pricing.ts. Its core is a per-mile curve for an open trailer carrying a running sedan, linearly interpolated:
/** Base $/mile breakpoints (open, sedan), linearly interpolated. */
export const RATE_POINTS: [number, number][] = [
[50, 3.4],
[100, 2.6],
[250, 1.8],
[500, 1.36],
[750, 1.12],
[1000, 0.98],
[1500, 0.8],
[2000, 0.68],
[2500, 0.6],
[3000, 0.56],
[3500, 0.53],
];
The rate falls with distance because pickup, inspection and loading take about the same time on any trip. Multipliers sit on top: vehicle class (0.97 for a compact up to 1.45 for oversized), enclosed trailer 1.42, EV 1.05, expedited 1.25, a monthly seasonal index, and a snowbird factor of +15% with the seasonal flow and -7% against it. The result is a range, not a promise: the midpoint is rounded to $5, the low end is 90% of it and the high end 112%. Unit tests pin the output to published 2026 rate cards, and the method is public on the car shipping cost page.
The same file runs in Astro components at build time and in the browser (the cost calculator calls estimatePrice client-side). Library modules use relative imports with .ts extensions, and Node 24 strips types natively, so scripts and node:test import it with no compile step.
The monthly price edition
Pricing everything with today's date fails on a static site. A page built on September 30 and one built on October 1 would quote different months, and a table rendered from the model would disagree with the prose beside it.
So prices follow an edition instead of the clock: src/data/market/edition.json, a month and a year. Every page that quotes a price imports one object:
export const EDITION = {
month: edition.month,
year: edition.year,
/** e.g. "September 2026" */
label: `${MONTHS[edition.month - 1]} ${edition.year}`,
// ...
};
Diesel follows the same rule. The latest weekly EIA price is saved to a state file but reaches prices only when the edition rolls. The model passes through 10% of the change against a $5.60 baseline:
const fuelMult = 1 + fuel.passThrough * (fuel.current / fuel.baseline - 1);
At the October 2026 figure of $6.382 a gallon, that is about +1.4%.
At the start of each month, scripts/new-edition.mjs rolls the edition. It generates every lane's numbers for the current month (the "before"), applies the saved diesel price, generates the new month's numbers (the "after"), and rewrites the route narratives: each dollar figure is matched to a key in the "before" set, exactly or within 2%, and replaced with the "after" value at that key. A range changes only when both ends match. No language model writes these numbers.
One rule matters more than it looks: the month label moves only in sentences that contain a $. A seasonal sentence such as "Late September 2026 comes just ahead of the rush" would turn false if the label changed, so the script leaves it and reports it for a human. Each roll also archives that edition's Cost Index as a CSV, so old editions stay citable.
Live price tokens for articles
Route narratives are short and structured, so the roll can rewrite them. Guides and blog posts are longer and freer, so their writers never type a price. They write a token:
A 1,000 mile trip on an open trailer runs {{price:miles=1000}} in {{edition}}.
In the October 2026 edition that renders as "runs $895 to $1,115 in October 2026." Parameters mirror the model's inputs (class, enclosed, running, ev, from, to, month) plus show for range, low, high, mid or permile. A 1,108 mile New York to Florida move renders as $1,095 to $1,360 with month=10 and $905 to $1,125 with month=4: the snowbird effect in two tokens.
The renderer is a regex and a switch:
const TOKEN = /\{\{\s*(price|edition)\s*(?::\s*([^}]*))?\}\}/g;
export function renderPriceTokens(
text: string | undefined
): string | undefined {
if (!text || !text.includes('{{')) return text;
return text.replace(TOKEN, (_m, kind: string, raw: string | undefined) =>
kind === 'edition' ? EDITION.label : renderPriceToken(raw ?? '')
);
}
Unknown parameters, a missing miles or a bad month throw, so a typo such as {{price:mile=1000}} fails the build instead of reaching readers.
Why the tokens render after the build
A remark plugin would be the natural home, but Astro 7's default Markdown processor, Sätteri, does not run remark plugins. So tokens render where every page ends up anyway, in the post-build step that minifies the HTML:
// Live price tokens in guide and post bodies ({{price:...}}, {{edition}}):
// rendered here from the current edition on every build.
if (html.includes('{{')) html = renderPriceTokens(html);
Frontmatter fields render in the view that displays them. The quality gate validates every token in the source, and the smoke test fails if any built page still contains {{price or {{edition. The trade-off: astro dev shows body tokens raw.
A hast plugin for tables and lead paragraphs
Sätteri does run hast plugins, and ours (src/lib/rehype-article.ts) does two jobs. It wraps every Markdown table in a focusable, labelled scroll region named after the heading above it, so wide price tables scroll on phones and keyboard and screen reader users can reach them; header cells get scope="col". A visually hidden <caption> was tried first and dropped, because the trafilatura text extractor printed it as an extra table row. On blog posts it also lifts the opening paragraph, which carries the answer, into frontmatter, so the template can show it under the H1 instead of below the photo.
Sätteri calls the plugin factory once per document, and the plugin queues changes on a visitor context:
before(root: HastNode, ctx: VisitorContext) {
const plan = planArticle(root, isBlog);
if (plan.lead) {
const astro = (ctx.data.astro ??= {});
(astro.frontmatter ??= {}).leadHtml = leadToHtml(plan.lead);
ctx.removeNode(plan.lead);
}
for (const t of plan.tables) {
ctx.setProperty(t.table, 'ariaLabelledBy', t.id);
for (const cell of t.headerCells) ctx.setProperty(cell, 'scope', 'col');
ctx.wrapNode(t.table, { raw: tableWrapper(t) });
}
},
The decisions live in planArticle, a pure function of the tree, so unit tests cover it without a Markdown processor.
A quality gate in CI
scripts/quality-gate.mjs runs on every push and fails when content breaks a rule. A blog post needs 650 to 1,400 words, a table, two sources and three FAQs. In a route narrative, every dollar figure over $300 must match that lane's current numbers within 2%. Uniqueness is 5-word shingles compared with Jaccard similarity:
function jaccard(a, b) {
if (!a.size || !b.size) return 0;
let inter = 0;
for (const x of a) if (b.has(x)) inter++;
return inter / (a.size + b.size - inter);
}
Route narratives may not score above 0.12 against each other or share a sentence of ten or more words; articles are held to 0.10. The gate also rejects em-dashes, filler phrases and guarantees.
Keeping Lighthouse happy
Most of the performance work was subtraction:
-
Only the UI plugins in use. Preline's per-plugin builds initialise on import, but its package.json marks them side-effect free, so the bundler dropped them. A small Vite plugin marks
preline/plugins/*as having side effects, which cut about 330 KB of JavaScript from every page. -
Low-priority module scripts. Post-build adds
fetchpriority="low"so scripts do not compete with the document, stylesheet and hero image on slow phones. -
Deferred rendering below the fold, with
content-visibility: autoplusoverflow: hidden, because axe measured skipped content at full height and reported it covering the next section's links. - No critical CSS. It was tried and reverted after a 0.21 layout shift; small page-only stylesheets are inlined and the shared one stays cacheable.
In the latest lab run over every page, desktop performance, accessibility and best practices scored 100 throughout, SEO scored 100 on indexable pages, and mobile performance landed between 90 and 100 from run to run.
The takeaway
Pick one source of truth for every number a page shows, decide when it may change, and make the build fail when content drifts from it. For us that is one pricing module, one edition file, and a token that turns {{price:miles=1000}} into whatever this month's model says.
Top comments (0)