Website auditing is the digital equivalent of checking if your blinker works by getting out of the car, walking to the back, looking, walking back to the seat, and repeating.
If you’re a web developer (god help you) or an SEO (god help you), you know the ritual:
Right-click → View Page Source to see the raw response.
F12 → Elements to see if the JavaScript actually rendered them.
Switch tabs to a schema validator for the JSON-LD hell.
Copy-paste the URL into an Open Graph social previewer.
A tedious, productivity killing process. And "View Page Source" is almost meaningless nowadays.
For those outside of the ugly web-dev world: with modern JavaScript tools like Svelte, React, and Vue, the server sends a near-empty HTML document, and relies on JavaScript to generate everything (rather than just sending everything at the start, for some reason, because CDNs I guess). If you’re debugging based on the raw HTML source, you’d be looking at a ghost.
I needed a live debugger. Something that lived alongside the DOM. I needed something that:
was a part of DevTools (not some popup that vanishes the moment I try to copy a string),
reflected the DOM state, and, optimally
didn't break the host page.
So, I built SEOdin Page Analyzer. Ridiculous name, I get it. Anyway, here’s how I survived the absolute nightmare that is browser extension development.
Comlink and Svelte 5
To make this feel like a live debugger rather than an old one-time scanner, I went with the cutting edge (and barely survived). I started with SolidJS years ago, and enjoyed it a lot, but eventually settled on the following:
- Svelte 5 for the (with the new Runes API)
- Vite (with rolldown), to compile the Svelte components, and
- Comlink (RPC wrapper), to communicate between each extension context
Comlink
Not the cutting-edge part. Chrome extension architecture is basically “design by committee.” You have Content Scripts, Service Workers, and DevTools Panels all needing to talk to each other through chrome.runtime.sendMessage, chrome.runtime.connect, chrome.tabs.sendMessage, and chrome.tabs.connect. You have one Service Worker for all Content Scripts and DevTools panels, and one DevTools panel handling the Content Script in each frame in a given tab. It’s wildly complicated for a fool like myself.
I used a modified version of Comlink (since extension ports are auto-disconnecting nightmares) to wrap this in an RPC layer. It lets me call functions across contexts as if they were local modules.
Svelte 5 Runes
The cutting-edge part. Handling an SEO audit is surprisingly state-heavy. You’re tracking hundreds of rules involving canonical links, hreflang links, heading hierarchies, image alt text… all while you might be live-editing the page.
I didn't want to re-run everything on every keystroke. This is where Svelte’s runes come in. Using $state and $derived, I built a reactive analysis engine that only reanalyzes the specific elements that changed. The flow is as follows:
The DevTools panel is opened.
DevTools RPC is created.
Content Scripts are injected into the page.
Content Script RPC is created.
DevTools requests data for the current displayed panel.
-
Content Script creates and returns various
$stateand$derivedobjects representing a stateful list of individual element states.- Using
$effect, whenever the state is updated, the content script attempts to send the updated state to DevTools.
- Using
DevTools performs some analyses on the state.
The reports are displayed.
I’m simplifying a bit here, but that’s the overall flow.
CSS Isolation (i.e. Stop Screwing With My Font Face)
Injecting a UI into a website is messy business. If, for example, I use a .container class, and the website I’m auditing also uses .container, the user is going to have a bad time. Of course, that particular issue is easy to avoid, but you eventually find yourself patching a chain of edge cases.
To get around this, I wrapped the on-page portion of the extension (the tooltip) in a shadow DOM. It creates a "style firewall" so that the tooltip looks the same whether you’re auditing a pristine landing page or a 1998 GeoCities revival.
// ...
function get_mount_target_shadow_root() {
mount_target = document.getElementById(seodin_root_id);
if (!mount_target) {
console.log("Creating SEOdin mount root...");
mount_target = document.createElement("seodin-ext");
mount_target.id = seodin_root_id;
//
// The font stylesheet needs to *also* be appended outside the
// shadow DOM until https://issues.chromium.org/issues/41085401
// is fixed.
//
const font_link = document.createElement("link");
font_link.rel = "stylesheet";
font_link.href = font_css_url_resolved;
mount_target.append(font_link);
document.body.append(mount_target);
}
let mount_target_shadow_root = mount_target.shadowRoot;
if (!mount_target_shadow_root) {
console.log("Creating SEOdin mount target...");
mount_target_shadow_root = mount_target.attachShadow({
mode: "open", // must be "open" in order to access any existing shadowRoot
});
}
return mount_target_shadow_root;
}
// ...
let mounted_component: object | null = null;
async function mount_seodin_component() {
await unmount_seodin_component();
console.log("Mounting SEOdin...");
try {
mounted_component = mount(Main, { target: get_mount_target_shadow_root() });
mover.observe(document.body, { childList: true });
} catch (e) {
console.error("Failed to mount SEOdin!", e);
}
}
// ...
This is the best use case I’ve found for the shadow DOM. In every other situation, I avoid it like the plague...
The JSON-LD Chaos
For example, a Product schema requires specific properties like offers or review. Google's Rich Results test is great, but it's slow and requires a context switch.
I decided to bring that validation directly into the DevTools panel. To do this, I implemented a collection of validators for Google Structured Data types. This allows the extension to analyze the structure of the data in real-time.
import type { Product } from "./schema/types/Product";
export async function* analyzeProduct(data: Product) {
if (!data.offers) {
yield {
status: AnalysisStatus.WARNING,
message: "Product is missing 'offers'",
};
}
if (!data.review) {
yield {
status: AnalysisStatus.WARNING,
message: "Product is missing 'reviews'",
};
}
// ...
}
With Svelte 5's runes and CodeMirror's editor, SEOdin can instantly provide a simple interface (similar to VS Code) and reactively analyze the schema as a user alters it.
That’s a Known Issue (The Roadmap)
Moving forward, I’m looking at:
Moving the tooltip UI entirely into the sidebar to avoid overlapping with the actual page content.
Using Chrome's built-in Gemini Nano for language-related SEO issues.
Covering the less common corners of Schema.org and Google Structured Data schema.
It’s Free, You Know
SEOdin is a labor of love at Bruce Clay Japan. It’s functional, it’s fast, and it’s free. If you’re tired of tedious website auditing, give SEOdin Page Analyzer a spin.
If you have any ideas or requests, or want to tell me how wrong my choices were, please leave a comment!
Top comments (0)