i’ve long been a fan of website carbon, but they have a strong tendency to have various issues when you’re trying to display the data on your OWN site; the badge always has been intermittent but the API completely fell apart; now you have to supply your own byte count to the API, and at that point i might as well calculate it myself.
getting the formula together was easy1; the group behind website carbon published a specification of their model, including a calculation walkthrough. my issues arose when i had to figure out how to get the carbon estimation into the page. i knew i wasn’t going to use client-side JS for this, but if:
- i need to calculate the bytes
- to calculate the bytes, i need to build the page first…
how do i show the estimation on an already-built page?
i ended up deciding to edit the bundled output files; first i used bun’s HTMLRewriter and then i ended up doing a normal string replace cus that was way overkill
calculating the byte size was more complex than i would’ve considered, though, as you need to account for linked resources like images, stylesheets, javascript, etc.
accurate page sizes
i DID keep bun’s HTMLRewriter for this, as it’s fast, lightweight, and makes parsing easy without needing to install some random package for it.
i looked for three element types:
linktags whererelisstylesheet,preload,modulepreload,icon, orapple-touch-icon.scripttags wheresrcis set (inline scripts are included in the HTML, so they dont count)imgtags (duh)
when found, i’d find the linked resource from the dist/ folder and add its file size to the total. pretty simple
i use the size of the HTML itself as a starting point. it’s important to include that in the final calculation, especially on pages where there’s no JS or fancy styles
my initial parser looked like something like this:
const rewriter = new HTMLRewriter()
.on("link", {
async element(el) {
const rel = el.getAttribute("rel") as string;
const href = el.getAttribute("href") as string;
if (
![
"stylesheet",
"preload",
"modulepreload",
"icon",
"apple-touch-icon",
].includes(rel)
) {
return // ignore `link` tags with other `rel`s
}
weight += Bun.file(`dist/${href}`).size;
},
})
.on("script[src]", {
element(el) {
const src = el.getAttribute("src") as string;
const foundSize = Bun.file(`dist/${src}`).size;
weight += foundSize;
},
})
.on("img[src]", {
element(el) {
const src = el.getAttribute("src") as string;
const foundSize = Bun.file(`dist/${src}`).size;
weight += foundSize;
},
});nothing too crazy!
fonts
i quickly realized though that my byte sizes were wayy lower than what the devtools network tab was saying, and i realized that it was because i was failing to include fonts in the bundle.
i had expected my fonts to be under one of the link elements, but they were actually included in my CSS bundle as a url(...). i added a section to look for that next:
.on("link", {
async element(el) {
// ...
if (href.endsWith(".css")) {
const newFile = Bun.file(`dist/${href}`);
const text = await newFile.text();
const urlRegex = /url\(([^)]+)\)/g;
const matches = text.match(urlRegex);
if (matches) {
for (const match of matches) {
const url = match.slice(4, -1).replace(/['"]/g, "");
if (url.startsWith("data:")) continue; // if it's a data URL then it's included in the HTML (and we also can't open it as a file)
weight += Bun.file(`dist/${url}`).size;
}
}
}
// ...
},
})
with that, the size calculation was done
running it after build
my first thought was to use it as a package.json postbuild hook, but that felt inelegant
i realized i could make it an astro integration with the astro:build:done hook, but under the docs, they say that If you plan to transform generated assets, we recommend exploring the Vite Plugin API and configuring via astro:config:setup instead.
i lost about 3 hours of my life on that. do not use the vite pugin API; for whatever reason it was refusing to save my files??
when i finally gave up an went back to building a normal astro integration, it was actually super simple to do:
export default {
name: "carbon-calculator",
hooks: {
"astro:build:done": async ({
logger,
dir
}) => {
const dist = dir.toString().replace("file://", ""); // dir is a full file URL for whatever reason
const files = (await readdir(dist, {
recursive: true
})).filter((path) =>
path.endsWith(".html"),
);
for await (const path of files) {
const file = Bun.file(`${dist}${path}`);
const emissions = (await calculateCarbon(file)).toFixed(3);
const content = await file.text();
const new_content = content.replace("{% PAGE_EMISSIONS %}", emissions); // my placeholder from before
logger.info(`${path} ~ ${emissions} gCO2e per visit`);
await file.write(new_content);
}
},
},
}
as AstroIntegration;and such is life.
my final result:

crucially, this calculator is lacking some of the statistics you’d get from their API, like the carbon rating and emissions percentile. none of that is information i care to show though, so womp
a note: i exclusively use bun as my JS/TS runtime. i have node installed for compatibility, but my integration needs to be run with the
--bunflag.