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:

  1. i need to calculate the bytes
  2. 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:

  1. link tags where rel is stylesheet, preload, modulepreload, icon, or apple-touch-icon.
  2. script tags where src is set (inline scripts are included in the HTML, so they dont count)
  3. img tags (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 --bun flag.

Footnotes

  1. my implementation of the Sustainable Web Design Model v4 can be found on tangled