Add cross-browser popup status handling

This commit is contained in:
Jordan Wages 2026-08-24 23:32:27 -05:00
commit 7e17470356
38 changed files with 1242 additions and 199 deletions

1
.gitignore vendored
View file

@ -534,3 +534,4 @@ node_modules/
dist/
coverage/
.DS_Store
release/

View file

@ -61,26 +61,23 @@ WebGPU inference may be investigated later, but CPU/WASM should be preferred ini
## Site Integration
The extension will initially support only explicitly implemented websites rather than attempting to generically interpret arbitrary web pages.
The extension supports websites through declarative, user-configurable site definitions rather than site-specific code. A definition supplies URL patterns, page markers, post selectors, text selectors, and stable-ID sources. The content script is loaded broadly but exits without examining content unless a definition matches.
Each supported website implements a common post parser interface.
Each active definition is executed by the same generic discovery engine.
Conceptually:
```text
IPostParser
Site definition
|
+-- FacebookPostParser
+-- TwitterPostParser
+-- RedditPostParser
+-- Generic discovery engine
```
A parser is responsible for:
A definition is responsible for declaring:
* Detecting posts and comments.
* Extracting their textual content.
* Providing a stable or locally generated identifier.
* Maintaining a reference to the associated DOM element.
* Where posts and comments appear.
* Which rendered text should be extracted.
* Which attributes or links provide stable identifiers.
A normalized post might resemble:
@ -348,6 +345,6 @@ Filtering policy
Page presentation
```
Site parsers should not contain ML logic. The inference backend should not understand individual websites. The classifier should only determine toxicity probability, while filtering policy determines whether that probability warrants hiding content.
Site definitions and their discovery engine should not contain ML logic. The inference backend should not understand individual websites. The classifier should only determine toxicity probability, while filtering policy determines whether that probability warrants hiding content.
This separation should allow website support, ML implementation, and user-facing filtering behavior to evolve independently.

View file

@ -2,6 +2,19 @@
VibeGuard is a local-first browser extension that hides toxic social-media posts. Content is classified on-device; post text is never sent to a classification service.
## Features
- Local toxicity classification using Transformers.js and a quantized ONNX model.
- Configurable toxicity threshold and filtering mode: collapse posts with a Show button or hide them completely.
- Optional toxicity scores on filtered-post placeholders.
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
- Built-in support for the official Mastodon web interface.
- Import and export of custom site definitions as JSON.
- Toolbar popup with current-site status, matched definition, pause/resume control, and settings access.
- Firefox Manifest V2 and Chromium Manifest V3 builds.
The content script is loaded broadly so it can support navigation and dynamically rendered pages, but it does not inspect page content until a validated site definition matches the current page. Site definitions are data, not executable user code.
## Development
```sh
@ -34,8 +47,86 @@ Preparation writes the Transformers.js-compatible files and `model-manifest.json
If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping.
The first release targets Reddit, X/Twitter, and Facebook. Site selectors are isolated under `src/content/parsers/` because these sites frequently change their DOM structures.
VibeGuard injects a lightweight content script on ordinary web pages, but reads and processes content only when a validated site definition matches the page. The extension includes a definition for the official Mastodon web interface; custom definitions and overrides are managed as JSON in the options page. Definitions are declarative selectors and URL patterns, never executable code.
## Runtime architecture
Firefox uses a persistent MV2 background page and inference worker. Chromium uses an MV3 service worker as a router, an offscreen document, and a worker-backed classifier. Both builds share one bounded, prioritized inference queue and in-memory text-result cache.
The high-level processing path is:
```text
Supported page
-> validated site definition
-> generic content discovery
-> shared inference queue
-> local classifier
-> threshold and filtering mode
```
The model is loaded once by the shared inference backend for each browser context. Content scripts send normalized text to the background runtime and apply the returned score to the matching DOM element. Results are cached in memory to avoid repeating work when dynamic sites recreate elements.
## Options page
Open the extensions options page to change the threshold, filtering mode, and score display. The Site definitions section accepts a JSON object containing `customDefinitions` and `disabledDefinitionIds`. Definitions can be imported from or exported to a file, and are validated before they are saved.
The options page uses the locally bundled [Bulma CSS](https://bulma.io/) v1.0.3 stylesheet. No options-page styling or runtime dependency is loaded from a CDN.
Click the toolbar icon to open the VibeGuard popup. It reports whether the current page is supported, names the matched site definition, and provides a persistent pause/resume control for that definition. Pausing immediately stops filtering in matching open tabs and removes VibeGuards current placeholders; resuming restarts discovery without requiring a page reload.
## Browser permissions
VibeGuard requests the following permissions:
- `storage` to save settings and definition configuration.
- `tabs` to associate inference requests with browser tabs.
- `<all_urls>` host access so supported sites can be detected and filtered.
- `offscreen` in the Chromium Manifest V3 build to host the long-lived inference document.
## Model and third-party licenses
The packaged toxicity model is distributed under the Apache License 2.0. Its source revision, label mapping, sequence length, and quantization details are recorded in `public/models/toxicity/model-manifest.json`.
VibeGuard also bundles [Bulma CSS v1.0.3](https://github.com/jgthms/bulma), which is distributed under the MIT License. The projects own code is licensed under the terms in [LICENSE](LICENSE).
## Repository layout
| Path | Purpose |
| --- | --- |
| `src/content/` | Generic site discovery and DOM filtering. |
| `src/background/` | Firefox and Chromium background runtimes. |
| `src/inference/` | Worker, classifier, queue, and model metadata. |
| `src/options/` | Options-page behavior and VibeGuard-specific styling. |
| `src/shared/` | Settings, site-definition validation, types, and shared utilities. |
| `public/` | Browser manifests, options HTML, bundled CSS, and model files. |
| `tests/` | Unit tests for queues, filtering, definitions, hashing, and model metadata. |
## Testing and packaging
Run the type checker and test suite before building a package:
```sh
npm run typecheck
npm test
```
Build the browser-specific packages with:
```sh
npm run build:firefox
npm run build:chromium
```
The generated directories are `dist/firefox/` and `dist/chromium/`. Each contains the browser manifest, bundled JavaScript, the local `bulma.css` asset, and the model files required by that build. Load the generated directory as a temporary/unpacked extension during development.
To build versioned install packages for both browsers, run the shell packaging script from any directory:
```sh
./build.sh
```
The script runs both browser builds and writes `release/vibeguard-firefox-<version>.zip` and `release/vibeguard-chromium-<version>.zip`. The archives contain the generated extension files at their root and are ignored by Git.
### Logo and icons
Place the source logo at `public/logo.png`. The options page displays the generated 64 px version, and `build.sh` uses ImageMagick to generate the browser icon sizes `16`, `32`, `48`, `64`, `96`, and `128` under `icons/` in each release package. Install ImageMagick before running the packaging script; it accepts either the `magick` or `convert` command.

113
build.sh Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
release_dir="$script_dir/release"
logo_source="$script_dir/public/logo.png"
firefox_manifest="$script_dir/public/manifest.firefox.json"
chromium_manifest="$script_dir/public/manifest.chromium.json"
for command_name in npm zip; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name is required to build release packages." >&2
exit 1
fi
done
if command -v magick >/dev/null 2>&1; then
image_tool=(magick)
elif command -v convert >/dev/null 2>&1; then
image_tool=(convert)
else
echo "ImageMagick (magick or convert) is required to process public/logo.png." >&2
exit 1
fi
if [[ ! -f "$logo_source" ]]; then
echo "Logo not found at $logo_source. Place the source PNG there before building." >&2
exit 1
fi
for manifest in "$firefox_manifest" "$chromium_manifest"; do
if [[ ! -f "$manifest" ]]; then
echo "Manifest not found: $manifest" >&2
exit 1
fi
done
manifest_version() {
local manifest="$1"
if command -v jq >/dev/null 2>&1; then
jq -er '.version // empty' "$manifest"
elif command -v python3 >/dev/null 2>&1; then
python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", ""))' "$manifest"
else
echo "jq or python3 is required to read manifest versions." >&2
exit 1
fi
}
firefox_version="$(manifest_version "$firefox_manifest")"
chromium_version="$(manifest_version "$chromium_manifest")"
if [[ -z "$firefox_version" || -z "$chromium_version" ]]; then
echo "Both manifests must contain a version." >&2
exit 1
fi
if [[ "$firefox_version" != "$chromium_version" ]]; then
echo "Firefox and Chromium manifest versions differ: $firefox_version vs $chromium_version" >&2
exit 1
fi
version="$firefox_version"
echo "Building Firefox package..."
(cd "$script_dir" && npm run build:firefox)
echo "Building Chromium package..."
(cd "$script_dir" && npm run build:chromium)
for browser in firefox chromium; do
dist_dir="$script_dir/dist/$browser"
if [[ ! -f "$dist_dir/manifest.json" ]]; then
echo "Build output is missing manifest.json: $dist_dir" >&2
exit 1
fi
done
process_icons() {
local browser="$1"
local dist_dir="$script_dir/dist/$browser"
local icon_dir="$dist_dir/icons"
mkdir -p "$icon_dir"
for size in 16 32 48 64 96 128; do
"${image_tool[@]}" "$logo_source" \
-resize "${size}x${size}^" \
-gravity center \
-background none \
-extent "${size}x${size}" \
"$icon_dir/logo${size}.png"
done
}
process_icons firefox
process_icons chromium
mkdir -p "$release_dir"
package_browser() {
local browser="$1"
local dist_dir="$script_dir/dist/$browser"
local archive="$release_dir/vibeguard-$browser-$version.zip"
rm -f "$archive"
(
cd "$dist_dir"
zip -q -9 -r "$archive" .
)
echo "Built $archive"
}
package_browser firefox
package_browser chromium

3
public/bulma.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -3,10 +3,19 @@
"name": "VibeGuard",
"version": "0.1.0",
"description": "Hide toxic social-media posts locally.",
"icons": {
"16": "icons/logo16.png",
"32": "icons/logo32.png",
"48": "icons/logo48.png",
"64": "icons/logo64.png",
"96": "icons/logo96.png",
"128": "icons/logo128.png"
},
"permissions": ["storage", "tabs", "offscreen"],
"host_permissions": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js", "type": "module" },
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
"action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
"options_page": "options.html",
"web_accessible_resources": [{ "resources": ["inference.js", "models/*"], "matches": ["<all_urls>"] }]
"web_accessible_resources": [{ "resources": ["inference.js", "models/toxicity/*", "models/toxicity/onnx/*"], "matches": ["<all_urls>"] }]
}

View file

@ -3,9 +3,18 @@
"name": "VibeGuard",
"version": "0.1.0",
"description": "Hide toxic social-media posts locally.",
"permissions": ["storage", "tabs", "https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
"icons": {
"16": "icons/logo16.png",
"32": "icons/logo32.png",
"48": "icons/logo48.png",
"64": "icons/logo64.png",
"96": "icons/logo96.png",
"128": "icons/logo128.png"
},
"browser_action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
"permissions": ["storage", "tabs", "<all_urls>"],
"background": { "scripts": ["background.js"], "persistent": true },
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
"options_ui": { "page": "options.html", "open_in_tab": true },
"web_accessible_resources": ["inference.js", "models/*"]
"web_accessible_resources": ["inference.js", "models/toxicity/*", "models/toxicity/onnx/*"]
}

View file

@ -1,7 +1,70 @@
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>VibeGuard settings</title></head><body><main><h1>VibeGuard settings</h1><form id="settings">
<label for="threshold">Toxicity threshold: <output id="threshold-value">80%</output></label><input id="threshold" type="range" min="0" max="1" step=".01">
<label for="filter-mode">Filtering mode</label><select id="filter-mode"><option value="collapse">Collapse with Show button</option><option value="hide">Hide completely</option></select>
<label><input id="show-score" type="checkbox"> Show toxicity score</label>
<fieldset><legend>Supported sites</legend><label><input id="site-reddit" type="checkbox"> Reddit</label><label><input id="site-twitter" type="checkbox"> X/Twitter</label><label><input id="site-facebook" type="checkbox"> Facebook</label></fieldset>
<button type="submit">Save settings</button> <span id="status" role="status"></span></form></main><script type="module" src="/options.js"></script></body></html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VibeGuard settings</title>
<link rel="stylesheet" href="/bulma.css">
</head>
<body>
<section class="section">
<main class="container vibeguard-options">
<div class="box">
<figure class="has-text-centered mb-4">
<img src="/icons/logo64.png" alt="VibeGuard" class="vibeguard-logo">
</figure>
<h1 class="title">VibeGuard settings</h1>
<p class="subtitle">Configure local toxicity filtering for supported sites.</p>
<form id="settings">
<div class="field">
<label class="label" for="threshold">Toxicity threshold: <output id="threshold-value">80%</output></label>
<div class="control">
<input class="slider is-fullwidth" id="threshold" type="range" min="0" max="1" step=".01">
</div>
</div>
<div class="field">
<label class="label" for="filter-mode">Filtering mode</label>
<div class="control">
<div class="select is-fullwidth">
<select id="filter-mode">
<option value="collapse">Collapse with Show button</option>
<option value="hide">Hide completely</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="checkbox"><input id="show-score" type="checkbox"> Show toxicity score</label>
</div>
<fieldset class="field">
<legend class="label">Site definitions</legend>
<div class="content">
<p>VibeGuard includes <code>mastodon</code> for the official Mastodon web interface. Add a custom definition with the same ID to override it, or add its ID to <code>disabledDefinitionIds</code> to disable it.</p>
</div>
<div class="field">
<label class="label" for="definitions">Custom definitions (JSON)</label>
<div class="control">
<textarea class="textarea vibeguard-json" id="definitions" rows="18" spellcheck="false"></textarea>
</div>
</div>
<div class="buttons">
<button class="button" type="button" id="import-definitions">Import JSON</button>
<button class="button" type="button" id="export-definitions">Export JSON</button>
<input id="definition-file" type="file" accept="application/json" hidden>
</div>
</fieldset>
<div class="is-flex is-align-items-center is-flex-wrap-wrap mt-5">
<button class="button is-primary" type="submit">Save settings</button>
<span class="vibeguard-status ml-3" id="status" role="status"></span>
</div>
</form>
</div>
</main>
</section>
<script type="module" src="/options.js"></script>
</body>
</html>

26
public/popup.html Normal file
View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VibeGuard</title>
<link rel="stylesheet" href="/bulma.css">
</head>
<body>
<main class="section">
<div class="container vibeguard-popup">
<div class="has-text-centered mb-4">
<img src="/icons/logo64.png" alt="VibeGuard" class="vibeguard-logo">
<h1 class="title is-5 mt-2 mb-1">VibeGuard</h1>
</div>
<div id="status-panel" class="notification is-light" role="status" aria-live="polite">Checking this page…</div>
<p id="definition" class="help mb-4"></p>
<div class="buttons is-flex is-flex-direction-column">
<button class="button is-primary is-fullwidth" id="pause" type="button" hidden></button>
<button class="button is-fullwidth" id="settings" type="button">Open settings</button>
</div>
</div>
</main>
<script type="module" src="/popup.js"></script>
</body>
</html>

View file

@ -2,6 +2,7 @@ import { createRuntime } from "./runtime";
import type { InferenceResult } from "../shared/types";
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
createRuntime((requests) => new Promise((resolve, reject) => {
const listener = (event: MessageEvent<{ results?: InferenceResult[]; error?: string }>) => {
@ -10,5 +11,5 @@ createRuntime((requests) => new Promise((resolve, reject) => {
else resolve(event.data.results ?? []);
};
worker.addEventListener("message", listener);
worker.postMessage({ requests });
worker.postMessage({ requests, modelBaseUrl });
}));

View file

@ -1,6 +1,7 @@
import type { RuntimeMessage } from "../shared/types";
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
if (message.type !== "OFFSCREEN_INFER") return false;
@ -10,6 +11,6 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResp
else sendResponse({ results: event.data.results });
};
worker.addEventListener("message", listener);
worker.postMessage({ requests: message.requests });
worker.postMessage({ requests: message.requests, modelBaseUrl });
return true;
});

View file

@ -1,5 +1,5 @@
import { InferenceQueue } from "../inference/queue";
import { loadSettings, saveSettings } from "../shared/settings";
import { loadSettings, saveSettings, setDefinitionPaused } from "../shared/settings";
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
@ -17,6 +17,13 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis
}
if (message.type === "GET_SETTINGS") { loadSettings().then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
if (message.type === "SET_SETTINGS") { saveSettings(message.settings).then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
if (message.type === "SET_DEFINITION_PAUSED") {
loadSettings()
.then((settings) => saveSettings(setDefinitionPaused(settings, message.definitionId, message.paused)))
.then((settings) => sendResponse({ type: "SETTINGS", settings }))
.catch((error) => sendResponse({ error: String(error) }));
return true;
}
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
return false;
});

View file

@ -0,0 +1,145 @@
import { hashText, normalizeText } from "../shared/hash";
import type { NormalizedPost, SiteDefinition } from "../shared/types";
export class DefinitionEngine {
constructor(readonly definition: SiteDefinition) {}
discover(root: Document | Element): NormalizedPost[] {
const posts: NormalizedPost[] = [];
const elements = this.findPostElements(root);
for (const element of elements) {
const text = this.extractText(element);
if (!text) continue;
const id = this.resolveId(element, text);
element.setAttribute("data-vibeguard-id", id);
posts.push({ id, text, element, site: this.definition.id });
}
return posts;
}
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
const observer = new MutationObserver((mutations) => {
const candidates = new Set<Element>();
for (const mutation of mutations) {
const target = mutation.target.nodeType === Node.ELEMENT_NODE ? mutation.target as Element : mutation.target.parentElement;
if (target) this.addCandidateAncestors(target, candidates);
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
this.addCandidateAncestors(element, candidates);
this.findPostElements(element).forEach((post) => candidates.add(post));
});
}
if (candidates.size > 0) onPosts(this.normalizeElements(candidates));
});
observer.observe(document.body, {
childList: true,
characterData: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "aria-expanded"]
});
return () => observer.disconnect();
}
dispose(): void {}
private findPostElements(root: Document | Element): Element[] {
const elements = new Set<Element>();
const selectors = this.definition.post.rootSelectors.join(",");
if (root instanceof Element && root.matches(selectors)) elements.add(root);
root.querySelectorAll(selectors).forEach((element) => elements.add(element));
return [...elements];
}
private normalizeElements(elements: Iterable<Element>): NormalizedPost[] {
const posts: NormalizedPost[] = [];
for (const element of elements) {
const text = this.extractText(element);
if (!text) continue;
const id = this.resolveId(element, text);
element.setAttribute("data-vibeguard-id", id);
posts.push({ id, text, element, site: this.definition.id });
}
return posts;
}
private addCandidateAncestors(element: Element, candidates: Set<Element>): void {
const selectors = this.definition.post.rootSelectors.join(",");
let current: Element | null = element;
while (current) {
if (current.matches(selectors)) candidates.add(current);
current = current.parentElement;
}
}
private extractText(post: Element): string {
for (const selector of this.definition.post.textSelectors) {
const content = post.matches(selector) ? post : post.querySelector(selector);
if (content) return visibleText(content, this.definition.post.excludedSelectors ?? []);
}
return "";
}
private resolveId(post: Element, text: string): string {
for (const attribute of this.definition.post.idAttributes ?? []) {
const value = post.getAttribute(attribute);
if (value) return `${this.definition.id}:${value}`;
}
for (const selector of this.definition.post.permalinkSelectors ?? []) {
const href = post.querySelector<HTMLAnchorElement>(selector)?.href;
if (href) return `${this.definition.id}:${href}`;
}
return `${this.definition.id}:${location.origin}:${hashText(text)}`;
}
}
export function selectDefinition(definitions: SiteDefinition[], currentUrl = location.href, root: Document = document): SiteDefinition | undefined {
return definitions.find((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null));
}
export function matchesAnyUrlPattern(patterns: string[], url: string): boolean {
return patterns.some((pattern) => matchesUrlPattern(pattern, url));
}
export function matchesUrlPattern(pattern: string, value: string): boolean {
const url = new URL(value);
if (pattern === "<all_urls>") return url.protocol === "http:" || url.protocol === "https:";
const match = pattern.match(/^(\*|https?|file):\/\/(\*|\*\.[^/*]+|[^/*]+)\/(.*)$/);
if (!match) return false;
const [, scheme, host, path] = match;
if (scheme !== "*" && `${scheme}:` !== url.protocol) return false;
if (host !== "*" && !(host?.startsWith("*.") ? url.hostname === host.slice(2) || url.hostname.endsWith(`.${host.slice(2)}`) : url.hostname === host)) return false;
return wildcardMatches(path ?? "", `${url.pathname}${url.search}`);
}
function wildcardMatches(pattern: string, value: string): boolean {
const expression = `^${pattern.split("*").map(escapeRegex).join(".*")}$`;
return new RegExp(expression).test(value);
}
function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function visibleText(element: Element, excludedSelectors: string[]): string {
const chunks: string[] = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const parent = node.parentElement;
if (!parent || excludedSelectors.some((selector) => parent.closest(selector))) continue;
if (isVisible(parent, element)) chunks.push(node.textContent ?? "");
}
return normalizeText(chunks.join(" "));
}
function isVisible(element: Element, boundary: Element): boolean {
let current: Element | null = element;
while (current) {
if (current.hasAttribute("hidden")) return false;
const style = getComputedStyle(current);
if (style.display === "none" || style.visibility === "hidden") return false;
if (current === boundary) break;
current = current.parentElement;
}
return true;
}

View file

@ -0,0 +1,22 @@
import type { SiteDefinition } from "../shared/types";
export const BUILTIN_DEFINITIONS: SiteDefinition[] = [{
id: "mastodon",
name: "Mastodon (official web interface)",
urlPatterns: ["<all_urls>"],
requiredSelectors: [".status__content", ".status, .detailed-status"],
post: {
rootSelectors: [".status", ".detailed-status"],
textSelectors: [".status__content", ".e-content"],
excludedSelectors: [".status__content__spoiler-link", ".status__content__read-more-button"],
idAttributes: ["data-id"],
permalinkSelectors: ["a.status__relative-time[href]", "a.detailed-status__datetime[href]", "a.u-url.u-uid[href]"]
}
}];
export function effectiveDefinitions(customDefinitions: SiteDefinition[], disabledDefinitionIds: string[]): SiteDefinition[] {
const customIds = new Set(customDefinitions.map((definition) => definition.id));
const disabled = new Set(disabledDefinitionIds);
return [...customDefinitions, ...BUILTIN_DEFINITIONS.filter((definition) => !customIds.has(definition.id))]
.filter((definition) => !disabled.has(definition.id));
}

View file

@ -1,41 +1,71 @@
import type { InferenceResult, Settings } from "../shared/types";
const HIDDEN = "data-vibeguard-hidden";
const ORIGINAL_DISPLAY = "data-vibeguard-display";
const ORIGINAL_DISPLAY = "data-vibeguard-original-display";
const PLACEHOLDER = "data-vibeguard-placeholder";
const PLACEHOLDER_FOR = "data-vibeguard-placeholder-for";
export function applyResult(element: Element, result: InferenceResult, settings: Settings): void {
export interface FilterOptions {
postId?: string;
onShow?: () => void;
}
export function applyResult(element: Element, result: InferenceResult, settings: Settings, options: FilterOptions = {}): void {
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
if (!shouldFilter) { restore(element); return; }
const postId = options.postId ?? result.id;
if (!shouldFilter) { restore(element, postId); return; }
element.setAttribute(HIDDEN, "true");
const html = element as HTMLElement;
if (!html.hasAttribute(ORIGINAL_DISPLAY)) html.setAttribute(ORIGINAL_DISPLAY, html.style.display);
html.style.display = "none";
if (settings.filterMode === "hide") {
element.setAttribute(ORIGINAL_DISPLAY, (element as HTMLElement).style.display);
(element as HTMLElement).style.display = "none";
return;
}
const html = element as HTMLElement;
if (!html.dataset.vibeguardOriginalDisplay) html.dataset.vibeguardOriginalDisplay = html.style.display;
html.style.display = "none";
let placeholder = element.nextElementSibling;
if (!placeholder?.matches("[data-vibeguard-placeholder]")) {
let placeholder = ownedPlaceholderAfter(element, postId);
if (!placeholder) {
placeholder = document.createElement("div");
placeholder.setAttribute("data-vibeguard-placeholder", "true");
placeholder.setAttribute(PLACEHOLDER, "true");
placeholder.setAttribute(PLACEHOLDER_FOR, postId);
element.insertAdjacentElement("afterend", placeholder);
}
placeholder.className = "vibeguard-placeholder";
placeholder.textContent = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
const message = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Show";
button.addEventListener("click", () => { restore(element); placeholder?.remove(); });
placeholder.append(" ", button);
button.addEventListener("click", () => {
if (options.onShow) options.onShow();
else restore(element, postId);
});
placeholder.replaceChildren(message, " ", button);
}
export function restore(element: Element): void {
export function hasAppliedFilter(element: Element, postId: string, settings: Settings): boolean {
const html = element as HTMLElement;
html.style.display = html.dataset.vibeguardOriginalDisplay ?? "";
delete html.dataset.vibeguardOriginalDisplay;
element.removeAttribute(HIDDEN);
element.nextElementSibling?.matches("[data-vibeguard-placeholder]") && element.nextElementSibling.remove();
return element.getAttribute(HIDDEN) === "true" && html.style.display === "none"
&& (settings.filterMode === "hide" || ownedPlaceholderAfter(element, postId) !== undefined);
}
export function restore(element: Element, postId?: string): void {
const html = element as HTMLElement;
html.style.display = html.getAttribute(ORIGINAL_DISPLAY) ?? "";
html.removeAttribute(ORIGINAL_DISPLAY);
element.removeAttribute(HIDDEN);
const placeholder = element.nextElementSibling;
if (placeholder?.hasAttribute(PLACEHOLDER) && (!postId || placeholder.getAttribute(PLACEHOLDER_FOR) === postId)) placeholder.remove();
}
export function removeOrphanedPlaceholders(postId: string, elements: Iterable<Element>): void {
const currentElements = new Set(elements);
document.querySelectorAll(`[${PLACEHOLDER_FOR}]`).forEach((placeholder) => {
if (placeholder.getAttribute(PLACEHOLDER_FOR) !== postId) return;
if (!currentElements.has(placeholder.previousElementSibling as Element)) placeholder.remove();
});
}
function ownedPlaceholderAfter(element: Element, postId: string): Element | undefined {
const placeholder = element.nextElementSibling;
return placeholder?.hasAttribute(PLACEHOLDER) && placeholder.getAttribute(PLACEHOLDER_FOR) === postId ? placeholder : undefined;
}

View file

@ -1,40 +1,148 @@
import { createParser, siteForLocation } from "./parsers";
import { applyResult } from "./filter";
import { effectiveDefinitions } from "./definitions";
import { DefinitionEngine, selectDefinition } from "./definition-engine";
import { applyResult, hasAppliedFilter, removeOrphanedPlaceholders, restore } from "./filter";
import { loadSettings } from "../shared/settings";
import { hashText } from "../shared/hash";
import { priorityFor } from "../inference/queue";
import type { InferenceRequest, RuntimeMessage, Settings } from "../shared/types";
import type { InferenceRequest, PageStatus, RuntimeMessage, Settings } from "../shared/types";
const site = siteForLocation();
if (site) void start(site);
void start();
async function start(activeSite: NonNullable<typeof site>): Promise<void> {
let settings = await requestSettings();
if (!settings.enabledSites[activeSite]) return;
const parser = createParser(activeSite);
async function start(): Promise<void> {
const settingsPromise = requestSettings();
let settings: Settings;
console.debug("[VibeGuard] Content script status listener starting", { url: location.href });
let stopCurrent = (): void => {};
let reloadSequence = 0;
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
if (message.type !== "GET_PAGE_STATUS") return false;
console.debug("[VibeGuard] Page status requested", { url: location.href });
settingsPromise.then((initialSettings) => getPageStatus(initialSettings)).then((status) => {
console.debug("[VibeGuard] Sending page status", status);
sendResponse({ type: "PAGE_STATUS", status });
});
return true;
});
settings = await settingsPromise;
const restart = (): void => {
stopCurrent();
restoreAllFilters();
currentReloadSequence = ++reloadSequence;
stopCurrent = runDefinition(settings, currentReloadSequence);
};
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== "local" || !changes["vibeguard.settings"]?.newValue) return;
settings = await loadSettings();
restart();
});
restart();
window.addEventListener("pagehide", () => stopCurrent());
}
function runDefinition(settings: Settings, sequence: number): () => void {
const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, settings.disabledDefinitionIds));
if (!definition) return () => {};
console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href });
const parser = new DefinitionEngine(definition);
const navigationId = crypto.randomUUID();
const seen = new WeakSet<Element>();
const fingerprints = new WeakMap<Element, string>();
const results = new Map<string, Parameters<typeof applyResult>[1]>();
const revealedPostIds = new Set<string>();
let stopped = false;
const process = (posts: ReturnType<typeof parser.discover>): void => {
posts.forEach((post) => {
if (seen.has(post.element)) return;
seen.add(post.element);
const reconcileFilters = (): void => {
if (stopped) return;
const elementsById = new Map<string, Element[]>();
document.querySelectorAll("[data-vibeguard-id]").forEach((element) => {
const id = element.getAttribute("data-vibeguard-id");
if (!id) return;
const elements = elementsById.get(id) ?? [];
elements.push(element);
elementsById.set(id, elements);
});
for (const [id, result] of results) {
const elements = elementsById.get(id) ?? [];
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold && !revealedPostIds.has(id);
for (const element of elements) {
if (!shouldFilter) restore(element, id);
else if (!hasAppliedFilter(element, id, settings)) {
applyResult(element, result, settings, {
postId: id,
onShow: () => { revealedPostIds.add(id); reconcileFilters(); }
});
}
}
removeOrphanedPlaceholders(id, elements);
}
};
const process = (posts: ReturnType<typeof parser.discover>, source: "initial" | "mutation"): void => {
if (stopped) return;
const newPosts = posts.filter((post) => {
const fingerprint = `${post.id}:${hashText(post.text)}`;
if (fingerprints.get(post.element) === fingerprint) return false;
fingerprints.set(post.element, fingerprint);
return true;
});
if (source === "initial" || newPosts.length > 0) {
console.debug("[VibeGuard] Posts discovered for analysis", {
definitionId: definition.id,
source,
count: newPosts.length,
posts: newPosts.map((post) => ({ id: post.id, text: post.text, textHash: hashText(post.text) }))
});
}
newPosts.forEach((post) => {
const request: InferenceRequest = {
id: post.id, text: post.text, site: activeSite, navigationId,
id: post.id, text: post.text, site: definition.id, navigationId,
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
requestId: crypto.randomUUID()
};
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>( { type: "INFER", request })
.then((response) => { if (response?.result) applyResult(post.element, response.result, settings); });
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>({ type: "INFER", request })
.then((response) => {
if (stopped || sequence !== currentReloadSequence || !response?.result || !post.element.isConnected) return;
results.set(post.id, response.result);
reconcileFilters();
});
});
reconcileFilters();
};
process(parser.discover(document));
const stop = parser.observe(process);
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area === "local" && changes["vibeguard.settings"]?.newValue) settings = await loadSettings();
});
window.addEventListener("pagehide", () => { stop(); parser.dispose(); });
process(parser.discover(document), "initial");
const stopObserver = parser.observe((posts) => process(posts, "mutation"));
return () => {
stopped = true;
stopObserver();
parser.dispose();
};
}
let currentReloadSequence = 0;
async function getPageStatus(settings: Settings): Promise<PageStatus> {
const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, []));
if (!definition) {
console.debug("[VibeGuard] No matching definition for page status", { url: location.href });
return { supported: false, paused: false };
}
const status = {
supported: true,
paused: settings.disabledDefinitionIds.includes(definition.id),
definition: { id: definition.id, name: definition.name }
};
console.debug("[VibeGuard] Matched definition for page status", status);
return status;
}
function restoreAllFilters(): void {
document.querySelectorAll('[data-vibeguard-hidden="true"]').forEach((element) => restore(element));
document.querySelectorAll('[data-vibeguard-placeholder="true"]').forEach((element) => element.remove());
}
async function requestSettings(): Promise<Settings> {

View file

@ -1,40 +0,0 @@
import type { IPostParser, NormalizedPost, SupportedSite } from "../../shared/types";
export abstract class SelectorParser implements IPostParser {
abstract readonly site: SupportedSite;
protected abstract readonly selectors: string[];
discover(root: Document | Element): NormalizedPost[] {
const nodes = root.querySelectorAll(this.selectors.join(","));
return Array.from(nodes).flatMap((element, index) => {
const text = this.extractText(element);
if (!text) return [];
const id = this.getId(element, index);
element.setAttribute("data-vibeguard-id", id);
return [{ id, text, element, site: this.site }];
});
}
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) onPosts(this.discover(node as Element));
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}
dispose(): void {}
protected extractText(element: Element): string {
return (element.getAttribute("data-vibeguard-text") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
}
private getId(element: Element, index: number): string {
const nativeId = element.getAttribute("data-testid") ?? element.getAttribute("data-id") ?? element.id;
return `${this.site}:${nativeId || `${index}-${this.extractText(element).slice(0, 32)}`}`;
}
}

View file

@ -1,6 +0,0 @@
import { SelectorParser } from "./base";
export class FacebookParser extends SelectorParser {
readonly site = "facebook" as const;
protected readonly selectors = ["div[role='article']"];
}

View file

@ -1,17 +0,0 @@
import type { IPostParser, SupportedSite } from "../../shared/types";
import { FacebookParser } from "./facebook";
import { RedditParser } from "./reddit";
import { TwitterParser } from "./twitter";
export function siteForLocation(hostname = location.hostname): SupportedSite | undefined {
if (hostname === "reddit.com" || hostname.endsWith(".reddit.com")) return "reddit";
if (hostname === "x.com" || hostname.endsWith(".x.com") || hostname === "twitter.com" || hostname.endsWith(".twitter.com")) return "twitter";
if (hostname === "facebook.com" || hostname.endsWith(".facebook.com")) return "facebook";
return undefined;
}
export function createParser(site: SupportedSite): IPostParser {
if (site === "reddit") return new RedditParser();
if (site === "twitter") return new TwitterParser();
return new FacebookParser();
}

View file

@ -1,6 +0,0 @@
import { SelectorParser } from "./base";
export class RedditParser extends SelectorParser {
readonly site = "reddit" as const;
protected readonly selectors = ["shreddit-post", "shreddit-comment", "article[data-testid='post-container']", "div[data-testid='comment']"];
}

View file

@ -1,6 +0,0 @@
import { SelectorParser } from "./base";
export class TwitterParser extends SelectorParser {
readonly site = "twitter" as const;
protected readonly selectors = ["article[data-testid='tweet']"];
}

View file

@ -1,17 +1,28 @@
import { pipeline, type TextClassificationPipeline } from "@huggingface/transformers";
import { env, pipeline, type TextClassificationPipeline } from "@huggingface/transformers";
import { hashText } from "../shared/hash";
import type { InferenceRequest, InferenceResult } from "../shared/types";
import { loadModelManifest, modelBaseUrl, type ModelManifest } from "./model-metadata";
import { loadModelManifest, type ModelManifest } from "./model-metadata";
let classifier: TextClassificationPipeline | undefined;
let classifierPromise: Promise<TextClassificationPipeline> | undefined;
let manifest: ModelManifest | undefined;
export async function classify(requests: InferenceRequest[]): Promise<InferenceResult[]> {
const modelManifest = manifest ??= await loadModelManifest();
classifier ??= await loadClassifier();
// Transformers.js disables local files in browser workers by default. Our model
// is packaged inside the extension, so never fall back to the network.
env.allowLocalModels = true;
env.allowRemoteModels = false;
env.useBrowserCache = false;
export async function classify(requests: InferenceRequest[], modelBaseUrl: string): Promise<InferenceResult[]> {
const modelManifest = manifest ??= await loadModelManifest(modelBaseUrl);
classifier ??= await loadClassifier(modelBaseUrl);
const invoke = classifier as unknown as (texts: string[], options: Record<string, unknown>) => Promise<Array<Array<{ label: string; score: number }> | { label: string; score: number }>>;
const outputs = await invoke(requests.map((request) => request.text), { top_k: undefined, max_length: modelManifest.maxLength });
const startedAt = performance.now();
// Transformers.js defaults `top_k` to 1. Request both scores because the
// filtering threshold is based on the toxic class probability, including
// when the non-toxic class is the model's highest-confidence prediction.
const outputs = await invoke(requests.map((request) => request.text), { top_k: 2, max_length: modelManifest.maxLength });
const modelDurationMs = performance.now() - startedAt;
return requests.map((request, index) => {
const output = Array.isArray(outputs[index]) ? outputs[index] : [outputs[index]];
const toxic = output.find((item) => resolveOutputIndex(item?.label, modelManifest) === modelManifest.labels.toxic);
@ -24,13 +35,16 @@ export async function classify(requests: InferenceRequest[]): Promise<InferenceR
label: probability >= 0.5 ? "toxic" : "not_toxic",
probability,
navigationId: request.navigationId,
modelRevision: modelManifest.revision
modelRevision: modelManifest.revision,
modelDurationMs
};
});
}
async function loadClassifier(): Promise<TextClassificationPipeline> {
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl(), { device: "wasm" });
async function loadClassifier(modelBaseUrl: string): Promise<TextClassificationPipeline> {
if (!env.backends.onnx.wasm) throw new Error("ONNX WASM backend is unavailable.");
env.backends.onnx.wasm.wasmPaths = { wasm: new URL("../../ort/ort-wasm-simd-threaded.jsep.wasm", modelBaseUrl).href };
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string; local_files_only: boolean }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl, { device: "wasm", local_files_only: true });
return classifierPromise;
}

View file

@ -14,12 +14,8 @@ export interface ModelManifest {
let manifestPromise: Promise<ModelManifest> | undefined;
export function modelBaseUrl(): string {
return chrome.runtime.getURL("models/toxicity/");
}
export function loadModelManifest(): Promise<ModelManifest> {
manifestPromise ??= fetch(`${modelBaseUrl()}model-manifest.json`)
export function loadModelManifest(modelBaseUrl: string): Promise<ModelManifest> {
manifestPromise ??= fetch(`${modelBaseUrl}model-manifest.json`)
.then((response) => {
if (!response.ok) throw new Error(`VibeGuard model metadata unavailable (${response.status})`);
return response.json() as Promise<ModelManifest>;

View file

@ -26,7 +26,9 @@ export class InferenceQueue {
enqueue(request: InferenceRequest): Promise<InferenceResult> {
const cached = this.cache.get(request.text);
if (cached) {
return Promise.resolve({ ...cached, requestId: request.requestId, id: request.id, navigationId: request.navigationId });
const result = { ...cached, requestId: request.requestId, id: request.id, navigationId: request.navigationId, modelDurationMs: 0 };
this.logCompletion(request, result, true);
return Promise.resolve(result);
}
if (this.pending.length >= this.maxSize) this.dropLowestPriority();
@ -66,11 +68,21 @@ export class InferenceQueue {
const byRequest = new Map(results.map((result) => [result.requestId, result]));
for (const item of batch) {
const result = byRequest.get(item.request.requestId);
if (!result) item.reject(new Error("Classifier returned no result"));
else { this.cache.set(item.request.text, result); item.resolve(result); }
if (!result) {
const error = new Error("Classifier returned no result");
this.logFailure(item.request, error);
item.reject(error);
} else {
this.cache.set(item.request.text, result);
this.logCompletion(item.request, result, false);
item.resolve(result);
}
}
} catch (error) {
batch.forEach((item) => item.reject(error));
batch.forEach((item) => {
this.logFailure(item.request, error);
item.reject(error);
});
} finally {
this.running = false;
if (this.pending.length > 0) this.schedule();
@ -87,6 +99,26 @@ export class InferenceQueue {
this.pending[worstIndex]?.reject(new Error("Inference queue is full"));
this.pending.splice(worstIndex, 1);
}
private logCompletion(request: InferenceRequest, result: InferenceResult, cacheHit: boolean): void {
console.debug("[VibeGuard] Post analysis completed", {
id: request.id,
text: request.text,
textHash: hashText(request.text),
result,
modelDurationMs: result.modelDurationMs ?? 0,
cacheHit
});
}
private logFailure(request: InferenceRequest, error: unknown): void {
console.error("[VibeGuard] Post analysis failed", {
id: request.id,
text: request.text,
textHash: hashText(request.text),
error: String(error)
});
}
}
export function requestKey(request: InferenceRequest): string {

View file

@ -1,9 +1,9 @@
import { classify } from "./classifier";
import type { InferenceRequest } from "../shared/types";
self.onmessage = async (event: MessageEvent<{ requests: InferenceRequest[] }>) => {
self.onmessage = async (event: MessageEvent<{ requests: InferenceRequest[]; modelBaseUrl: string }>) => {
try {
const results = await classify(event.data.requests);
const results = await classify(event.data.requests, event.data.modelBaseUrl);
self.postMessage({ results });
} catch (error) {
self.postMessage({ error: error instanceof Error ? error.message : String(error) });

View file

@ -1,4 +1,5 @@
import { loadSettings, saveSettings } from "../shared/settings";
import { loadSettings, saveDefinitionConfiguration, saveSettings } from "../shared/settings";
import { validateDefinitionConfiguration } from "../shared/site-definitions";
import type { FilterMode, Settings } from "../shared/types";
import "./style.css";
@ -8,30 +9,74 @@ const thresholdValue = document.querySelector<HTMLElement>("#threshold-value");
const mode = document.querySelector<HTMLSelectElement>("#filter-mode");
const showScore = document.querySelector<HTMLInputElement>("#show-score");
const status = document.querySelector<HTMLElement>("#status");
const definitions = document.querySelector<HTMLTextAreaElement>("#definitions");
const importButton = document.querySelector<HTMLButtonElement>("#import-definitions");
const exportButton = document.querySelector<HTMLButtonElement>("#export-definitions");
const definitionFile = document.querySelector<HTMLInputElement>("#definition-file");
void loadSettings().then((settings) => {
threshold!.value = String(settings.threshold);
mode!.value = settings.filterMode;
showScore!.checked = settings.showScore;
for (const site of ["reddit", "twitter", "facebook"] as const) document.querySelector<HTMLInputElement>(`#site-${site}`)!.checked = settings.enabledSites[site];
definitions!.value = JSON.stringify(definitionConfiguration(settings), null, 2);
updateThresholdLabel();
});
threshold?.addEventListener("input", updateThresholdLabel);
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const settings: Partial<Settings> = {
threshold: Number(threshold?.value), filterMode: mode?.value as FilterMode, showScore: showScore?.checked,
enabledSites: {
reddit: document.querySelector<HTMLInputElement>("#site-reddit")!.checked,
twitter: document.querySelector<HTMLInputElement>("#site-twitter")!.checked,
facebook: document.querySelector<HTMLInputElement>("#site-facebook")!.checked
}
};
await saveSettings(settings);
if (status) { status.textContent = "Saved"; setTimeout(() => { status.textContent = ""; }, 1500); }
try {
const configuration = parseDefinitionConfiguration();
await saveDefinitionConfiguration(configuration);
await saveSettings({ threshold: Number(threshold?.value), filterMode: mode?.value as FilterMode, showScore: showScore?.checked });
showStatus("Saved. Reload matching pages to apply definition changes.");
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
});
importButton?.addEventListener("click", () => definitionFile?.click());
definitionFile?.addEventListener("change", async () => {
const file = definitionFile.files?.[0];
if (!file) return;
try {
const parsed = JSON.parse(await file.text()) as unknown;
const validation = validateDefinitionConfiguration(parsed);
if (!validation.valid) throw new Error(validation.errors.join("\n"));
if (definitions) definitions.value = JSON.stringify(validation.value, null, 2);
showStatus("Definition JSON imported. Save to apply it.");
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
definitionFile.value = "";
});
exportButton?.addEventListener("click", () => {
try {
const blob = new Blob([JSON.stringify(parseDefinitionConfiguration(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "vibeguard-definitions.json";
link.click();
URL.revokeObjectURL(url);
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
});
function updateThresholdLabel(): void {
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
}
function definitionConfiguration(settings?: Settings): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } {
return { customDefinitions: settings?.customDefinitions ?? [], disabledDefinitionIds: settings?.disabledDefinitionIds ?? [] };
}
function parseDefinitionConfiguration(): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } {
let parsed: unknown;
try { parsed = JSON.parse(definitions?.value ?? "{}"); } catch { throw new Error("Definition JSON is invalid."); }
const validation = validateDefinitionConfiguration(parsed);
if (!validation.valid) throw new Error(validation.errors.join("\n"));
return validation.value;
}
function showStatus(message: string, isError = false): void {
if (!status) return;
status.textContent = message;
status.style.color = isError ? "#b42318" : "";
}

View file

@ -1,8 +1,6 @@
:root { font: 16px system-ui, sans-serif; color: #18212f; background: #f7f8fb; }
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
main { background: white; border-radius: .75rem; padding: 1.5rem; box-shadow: 0 2px 14px #18212f18; }
label, fieldset { display: block; margin: 1rem 0; }
fieldset { border: 0; padding: 0; }
button { padding: .5rem 1rem; border: 0; border-radius: .4rem; background: #4355db; color: white; cursor: pointer; }
.vibeguard-options { max-width: 52rem; }
.vibeguard-logo { max-height: 4rem; max-width: min(100%, 20rem); object-fit: contain; }
.vibeguard-json { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9rem; }
.vibeguard-status { white-space: pre-line; }
.vibeguard-placeholder { padding: .75rem; margin: .25rem 0; border: 1px solid #d5d9e8; background: #f1f3f9; color: #5c6475; }
.vibeguard-placeholder button { padding: .25rem .5rem; margin-left: .5rem; }

150
src/popup/main.ts Normal file
View file

@ -0,0 +1,150 @@
import type { PageStatus, RuntimeMessage } from "../shared/types";
import "./style.css";
const statusPanel = document.querySelector<HTMLElement>("#status-panel");
const definitionLabel = document.querySelector<HTMLElement>("#definition");
const pauseButton = document.querySelector<HTMLButtonElement>("#pause");
const settingsButton = document.querySelector<HTMLButtonElement>("#settings");
let activeTabId: number | undefined;
let currentStatus: PageStatus | undefined;
void loadStatus();
pauseButton?.addEventListener("click", async () => {
if (activeTabId === undefined || !currentStatus?.definition || !pauseButton) return;
pauseButton.disabled = true;
try {
const response = await chrome.runtime.sendMessage({
type: "SET_DEFINITION_PAUSED",
definitionId: currentStatus.definition.id,
paused: !currentStatus.paused
} satisfies RuntimeMessage);
if (response?.error) throw new Error(response.error);
await loadStatus();
} catch (error) {
showError(error instanceof Error ? error.message : String(error));
pauseButton.disabled = false;
}
});
settingsButton?.addEventListener("click", () => void chrome.runtime.openOptionsPage());
async function loadStatus(): Promise<void> {
setLoading(true);
try {
// Firefox exposes the `chrome` namespace with callback-style APIs in some
// versions. Supplying a callback keeps this working there as well as in
// browsers that return a Promise when no callback is supplied.
const [tab] = await queryTabs({ active: true, currentWindow: true });
activeTabId = tab?.id;
console.debug("[VibeGuard popup] Active tab selected", { id: tab?.id, url: tab?.url, status: tab?.status });
if (activeTabId === undefined) {
console.warn("[VibeGuard popup] Active tab has no usable ID", { tab });
showUnsupported("The active tab is unavailable.");
return;
}
console.debug("[VibeGuard popup] Requesting page status", { tabId: activeTabId });
const response = await sendTabMessage(activeTabId, { type: "GET_PAGE_STATUS" } satisfies RuntimeMessage);
console.debug("[VibeGuard popup] Page status response", { tabId: activeTabId, response });
if (!response?.status) throw new Error("No page status was returned.");
renderStatus(response.status);
} catch (error) {
console.warn("[VibeGuard popup] Unable to read page status", { tabId: activeTabId, error });
showUnsupported("VibeGuard is not available on this page.");
} finally {
setLoading(false);
}
}
function queryTabs(query: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]> {
return new Promise((resolve, reject) => {
chrome.tabs.query(query, (tabs) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tabs);
});
});
}
function sendTabMessage(tabId: number, message: RuntimeMessage): Promise<{ type?: string; status?: PageStatus } | undefined> {
return sendTabMessageOnce(tabId, message).catch(async (error: unknown) => {
// A tab that was already open when the extension was loaded may not have
// received the content script yet. Firefox reports that as a missing
// receiving end; inject the MV2 bundle and retry once.
if (!isMissingReceiver(error) || typeof chrome.tabs.executeScript !== "function") throw error;
await executeContentScript(tabId);
return sendTabMessageOnce(tabId, message);
});
}
function sendTabMessageOnce(tabId: number, message: RuntimeMessage): Promise<{ type?: string; status?: PageStatus } | undefined> {
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tabId, message, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response as { type?: string; status?: PageStatus } | undefined);
});
});
}
function executeContentScript(tabId: number): Promise<void> {
return new Promise((resolve, reject) => {
chrome.tabs.executeScript(tabId, { file: "content.js" }, () => {
const error = chrome.runtime.lastError;
if (error) reject(new Error(error.message));
else resolve();
});
});
}
function isMissingReceiver(error: unknown): boolean {
return error instanceof Error && error.message.includes("Receiving end does not exist");
}
function renderStatus(status: PageStatus): void {
console.debug("[VibeGuard popup] Rendering page status", status);
currentStatus = status;
if (!status.supported || !status.definition) {
showUnsupported("No supported site definition matches this page.");
return;
}
statusPanel!.className = `notification ${status.paused ? "is-warning" : "is-success"}`;
statusPanel!.textContent = status.paused ? "Filtering is paused" : "Filtering is active";
definitionLabel!.textContent = `Site definition: ${status.definition.name}`;
if (pauseButton) {
pauseButton.hidden = false;
pauseButton.disabled = false;
pauseButton.textContent = status.paused ? "Resume on this site" : "Pause on this site";
}
}
function showUnsupported(message: string): void {
currentStatus = undefined;
statusPanel!.className = "notification is-light";
statusPanel!.textContent = message;
definitionLabel!.textContent = "";
if (pauseButton) {
pauseButton.hidden = true;
pauseButton.disabled = true;
}
}
function showError(message: string): void {
statusPanel!.className = "notification is-danger is-light";
statusPanel!.textContent = message;
}
function setLoading(loading: boolean): void {
if (loading) {
statusPanel!.className = "notification is-light";
statusPanel!.textContent = "Checking this page…";
}
if (pauseButton) pauseButton.disabled = loading;
}

4
src/popup/style.css Normal file
View file

@ -0,0 +1,4 @@
body { min-width: 20rem; }
.vibeguard-popup { padding: 1rem; }
.vibeguard-logo { max-height: 3rem; max-width: 12rem; object-fit: contain; }
.buttons.is-flex-direction-column { gap: .5rem; }

View file

@ -1,4 +1,5 @@
import { DEFAULT_SETTINGS, type Settings } from "./types";
import { DEFAULT_SETTINGS, type Settings, type SiteDefinition } from "./types";
import { validateDefinitionConfiguration } from "./site-definitions";
const KEY = "vibeguard.settings";
@ -34,14 +35,32 @@ function setStorageItem(items: Record<string, unknown>): Promise<void> {
}
export function mergeSettings(input?: Partial<Settings>): Settings {
const definitionConfiguration = validateDefinitionConfiguration({
customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions,
disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds
});
return {
...DEFAULT_SETTINGS,
...input,
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
enabledSites: { ...DEFAULT_SETTINGS.enabledSites, ...input?.enabledSites }
customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions,
disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds
};
}
export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[] }): Promise<Settings> {
const validation = validateDefinitionConfiguration(configuration);
if (!validation.valid) return Promise.reject(new Error(validation.errors.join("\n")));
return saveSettings(validation.value);
}
export function setDefinitionPaused(settings: Settings, definitionId: string, paused: boolean): Settings {
const disabledDefinitionIds = new Set(settings.disabledDefinitionIds);
if (paused) disabledDefinitionIds.add(definitionId);
else disabledDefinitionIds.delete(definitionId);
return mergeSettings({ ...settings, disabledDefinitionIds: [...disabledDefinitionIds] });
}
function clamp(value: number, min: number, max: number): number {
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
}

View file

@ -0,0 +1,76 @@
import type { SiteDefinition } from "./types";
export interface DefinitionConfiguration {
customDefinitions: SiteDefinition[];
disabledDefinitionIds: string[];
}
export type DefinitionValidation =
| { valid: true; value: DefinitionConfiguration }
| { valid: false; errors: string[] };
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
export function validateDefinitionConfiguration(value: unknown): DefinitionValidation {
if (!isRecord(value)) return invalid("Definition configuration must be an object.");
const customDefinitions = value.customDefinitions;
const disabledDefinitionIds = value.disabledDefinitionIds;
if (!Array.isArray(customDefinitions)) return invalid("customDefinitions must be an array.");
if (!Array.isArray(disabledDefinitionIds) || disabledDefinitionIds.some((id) => typeof id !== "string" || !ID_PATTERN.test(id))) {
return invalid("disabledDefinitionIds must be an array of valid definition IDs.");
}
const errors: string[] = [];
const definitions: SiteDefinition[] = [];
const ids = new Set<string>();
customDefinitions.forEach((definition, index) => {
const validation = validateSiteDefinition(definition, `customDefinitions[${index}]`);
if (!validation.valid) errors.push(...validation.errors);
else if (ids.has(validation.value.id)) errors.push(`Duplicate definition ID: ${validation.value.id}.`);
else { ids.add(validation.value.id); definitions.push(validation.value); }
});
return errors.length > 0 ? { valid: false, errors } : { valid: true, value: { customDefinitions: definitions, disabledDefinitionIds: [...new Set(disabledDefinitionIds)] } };
}
export function validateSiteDefinition(value: unknown, path = "definition"): { valid: true; value: SiteDefinition } | { valid: false; errors: string[] } {
if (!isRecord(value)) return invalid(`${path} must be an object.`);
const errors: string[] = [];
const id = value.id;
const name = value.name;
if (typeof id !== "string" || !ID_PATTERN.test(id)) errors.push(`${path}.id must use lowercase letters, digits, and hyphens.`);
if (typeof name !== "string" || !name.trim()) errors.push(`${path}.name is required.`);
const urlPatterns = stringArray(value.urlPatterns, `${path}.urlPatterns`, errors, isUrlPattern);
const requiredSelectors = stringArray(value.requiredSelectors, `${path}.requiredSelectors`, errors, isCssSelector);
const post = value.post;
if (!isRecord(post)) errors.push(`${path}.post must be an object.`);
const rootSelectors = isRecord(post) ? stringArray(post.rootSelectors, `${path}.post.rootSelectors`, errors, isCssSelector) : [];
const textSelectors = isRecord(post) ? stringArray(post.textSelectors, `${path}.post.textSelectors`, errors, isCssSelector) : [];
const excludedSelectors = isRecord(post) && post.excludedSelectors !== undefined ? stringArray(post.excludedSelectors, `${path}.post.excludedSelectors`, errors, isCssSelector, false) : undefined;
const idAttributes = isRecord(post) && post.idAttributes !== undefined ? stringArray(post.idAttributes, `${path}.post.idAttributes`, errors, (attribute) => /^[\w:-]+$/.test(attribute), false) : undefined;
const permalinkSelectors = isRecord(post) && post.permalinkSelectors !== undefined ? stringArray(post.permalinkSelectors, `${path}.post.permalinkSelectors`, errors, isCssSelector, false) : undefined;
if (errors.length > 0) return { valid: false, errors };
return { valid: true, value: { id: id as string, name: (name as string).trim(), urlPatterns, requiredSelectors, post: { rootSelectors, textSelectors, ...(excludedSelectors?.length ? { excludedSelectors } : {}), ...(idAttributes?.length ? { idAttributes } : {}), ...(permalinkSelectors?.length ? { permalinkSelectors } : {}) } } };
}
export function isUrlPattern(pattern: string): boolean {
return pattern === "<all_urls>" || /^(?:\*|https?|file):\/\/(?:\*|\*\.[^/*]+|[^/*]+)\/.*$/.test(pattern);
}
function stringArray(value: unknown, path: string, errors: string[], predicate: (item: string) => boolean, required = true): string[] {
if (!Array.isArray(value) || (required && value.length === 0) || value.some((item) => typeof item !== "string" || !predicate(item))) {
errors.push(`${path} must be ${required ? "a non-empty " : "an optional "}array of valid values.`);
return [];
}
return value as string[];
}
function isCssSelector(selector: string): boolean {
if (typeof document === "undefined") return selector.trim().length > 0;
try { document.createElement("div").matches(selector); return true; } catch { return false; }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function invalid(error: string): { valid: false; errors: string[] } { return { valid: false, errors: [error] }; }

View file

@ -1,19 +1,32 @@
export type SupportedSite = "reddit" | "twitter" | "facebook";
export type QueuePriority = 0 | 1 | 2 | 3;
export type FilterMode = "collapse" | "hide";
export interface SiteDefinition {
id: string;
name: string;
urlPatterns: string[];
requiredSelectors: string[];
post: {
rootSelectors: string[];
textSelectors: string[];
excludedSelectors?: string[];
idAttributes?: string[];
permalinkSelectors?: string[];
};
}
export interface NormalizedPost {
id: string;
text: string;
element: Element;
site: SupportedSite;
site: string;
tabId?: number;
}
export interface PostDescriptor {
id: string;
text: string;
site: SupportedSite;
site: string;
tabId?: number;
navigationId: string;
}
@ -31,34 +44,40 @@ export interface InferenceResult {
probability: number;
navigationId: string;
modelRevision?: string;
/** Duration of the model invocation that produced this result, in milliseconds. */
modelDurationMs?: number;
}
export interface Settings {
threshold: number;
filterMode: FilterMode;
showScore: boolean;
enabledSites: Record<SupportedSite, boolean>;
customDefinitions: SiteDefinition[];
disabledDefinitionIds: string[];
}
export interface PageStatus {
supported: boolean;
paused: boolean;
definition?: { id: string; name: string };
}
export const DEFAULT_SETTINGS: Settings = {
threshold: 0.8,
filterMode: "collapse",
showScore: true,
enabledSites: { reddit: true, twitter: true, facebook: true }
customDefinitions: [],
disabledDefinitionIds: []
};
export interface IPostParser {
readonly site: SupportedSite;
discover(root: Document | Element): NormalizedPost[];
observe(onPosts: (posts: NormalizedPost[]) => void): () => void;
dispose(): void;
}
export type RuntimeMessage =
| { type: "INFER"; request: InferenceRequest }
| { type: "INFERENCE_RESULT"; result: InferenceResult }
| { type: "GET_SETTINGS" }
| { type: "SET_SETTINGS"; settings: Partial<Settings> }
| { type: "SETTINGS"; settings: Settings }
| { type: "GET_PAGE_STATUS" }
| { type: "PAGE_STATUS"; status: PageStatus }
| { type: "SET_DEFINITION_PAUSED"; definitionId: string; paused: boolean }
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
| { type: "PING" };

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { DefinitionEngine, matchesUrlPattern, selectDefinition } from "../src/content/definition-engine";
import { BUILTIN_DEFINITIONS } from "../src/content/definitions";
import type { SiteDefinition } from "../src/shared/types";
const definition: SiteDefinition = {
id: "example-social",
name: "Example Social",
urlPatterns: ["https://*.example.test/*"],
requiredSelectors: [".feed"],
post: { rootSelectors: ["article.post"], textSelectors: [".content"], excludedSelectors: [".exclude"], idAttributes: ["data-id"], permalinkSelectors: ["a.permalink[href]"] }
};
describe("DefinitionEngine", () => {
it("discovers the supplied root, extracts only visible text, and uses a stable ID", () => {
document.body.innerHTML = '<article class="post" data-id="42"><div class="content">Visible <span class="exclude">skip</span><span style="display:none">hidden</span></div></article>';
const post = new DefinitionEngine(definition).discover(document.querySelector("article")!)[0];
expect(post).toMatchObject({ id: "example-social:42", text: "visible" });
});
it("uses permalink IDs and selects only definitions with URL and marker matches", () => {
document.body.innerHTML = '<div class="feed"><article class="post"><div class="content">Hello</div><a class="permalink" href="/users/a/statuses/1">link</a></article></div>';
const engine = new DefinitionEngine(definition);
expect(engine.discover(document)[0]?.id).toContain("/users/a/statuses/1");
expect(selectDefinition([definition], "https://social.example.test/home")).toBe(definition);
expect(selectDefinition([definition], "https://other.test/home")).toBeUndefined();
});
it("matches browser-style URL patterns", () => {
expect(matchesUrlPattern("https://*.example.test/*", "https://a.example.test/path?q=1")).toBe(true);
expect(matchesUrlPattern("https://*.example.test/*", "http://a.example.test/path")).toBe(false);
});
it("covers Mastodon timeline, profile, and detailed reply status containers", () => {
document.body.innerHTML = `
<div class="status" data-id="timeline"><div class="status__content">Timeline post</div></div>
<div class="status" data-id="profile"><div class="status__content">Profile post</div></div>
<div class="detailed-status"><a class="detailed-status__datetime" href="/users/a/statuses/3"></a><div class="status__content">Reply in thread</div></div>`;
const posts = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!).discover(document);
expect(posts.map((post) => post.id)).toEqual(["mastodon:timeline", "mastodon:profile", "mastodon:http://localhost:3000/users/a/statuses/3"]);
expect(posts.map((post) => post.text)).toEqual(["timeline post", "profile post", "reply in thread"]);
});
it("does not include hidden Mastodon content-warning text until it is revealed", () => {
document.body.innerHTML = '<div class="status" data-id="cw"><div class="status__content"><p>Content warning <a class="status__content__spoiler-link">Show more</a></p><div id="hidden" style="display:none">Hidden post text</div></div></div>';
const engine = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!);
expect(engine.discover(document)[0]?.text).toBe("content warning");
document.querySelector("#hidden")?.removeAttribute("style");
expect(engine.discover(document)[0]?.text).toBe("content warning hidden post text");
});
it("rediscovers a Mastodon status when a content warning is revealed", async () => {
document.body.innerHTML = '<div class="status" data-id="cw"><div class="status__content"><p>Content warning</p><div id="hidden" style="display:none">Revealed text</div></div></div>';
const engine = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!);
const observed: string[] = [];
const stop = engine.observe((posts) => observed.push(...posts.map((post) => post.text)));
document.querySelector("#hidden")?.removeAttribute("style");
await new Promise((resolve) => setTimeout(resolve, 0));
stop();
expect(observed).toContain("content warning revealed text");
});
});

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import { applyResult, restore } from "../src/content/filter";
import { afterEach, describe, expect, it } from "vitest";
import { applyResult, removeOrphanedPlaceholders, restore } from "../src/content/filter";
import { DEFAULT_SETTINGS } from "../src/shared/types";
describe("content filtering", () => {
afterEach(() => document.body.replaceChildren());
it("collapses toxic content and restores it", () => {
const element = document.createElement("article");
document.body.append(element);
@ -13,4 +15,27 @@ describe("content filtering", () => {
expect(element.style.display).toBe("");
expect(document.querySelector("[data-vibeguard-placeholder]")).toBeNull();
});
it("preserves the original display value when filtering is reconciled repeatedly", () => {
const element = document.createElement("article");
document.body.append(element);
const result = { requestId: "1", id: "1", textHash: "x", label: "toxic" as const, probability: .91, navigationId: "n" };
applyResult(element, result, DEFAULT_SETTINGS);
applyResult(element, result, DEFAULT_SETTINGS);
restore(element);
expect(element.style.display).toBe("");
});
it("removes placeholders no longer paired with the current post element", () => {
const stale = document.createElement("div");
const orphan = document.createElement("div");
orphan.setAttribute("data-vibeguard-placeholder-for", "mastodon:1");
const current = document.createElement("article");
const currentPlaceholder = document.createElement("div");
currentPlaceholder.setAttribute("data-vibeguard-placeholder-for", "mastodon:1");
document.body.append(stale, orphan, current, currentPlaceholder);
removeOrphanedPlaceholders("mastodon:1", [current]);
expect(orphan.isConnected).toBe(false);
expect(currentPlaceholder.isConnected).toBe(true);
});
});

20
tests/settings.test.ts Normal file
View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { setDefinitionPaused } from "../src/shared/settings";
import { DEFAULT_SETTINGS } from "../src/shared/types";
describe("definition pause settings", () => {
it("adds and removes a definition ID without disturbing other settings", () => {
const settings = setDefinitionPaused({ ...DEFAULT_SETTINGS, threshold: .65 }, "mastodon", true);
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
expect(settings.threshold).toBe(.65);
const resumed = setDefinitionPaused(settings, "mastodon", false);
expect(resumed.disabledDefinitionIds).toEqual([]);
expect(resumed.threshold).toBe(.65);
});
it("does not duplicate an already paused definition", () => {
const settings = setDefinitionPaused({ ...DEFAULT_SETTINGS, disabledDefinitionIds: ["mastodon"] }, "mastodon", true);
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
});
});

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { BUILTIN_DEFINITIONS, effectiveDefinitions } from "../src/content/definitions";
import { validateDefinitionConfiguration } from "../src/shared/site-definitions";
const customDefinition = {
id: "example-social",
name: "Example Social",
urlPatterns: ["https://*.example.test/*"],
requiredSelectors: [".feed"],
post: { rootSelectors: ["article"], textSelectors: [".content"], idAttributes: ["data-id"] }
};
describe("site definitions", () => {
it("accepts declarative custom definitions and deduplicates disabled IDs", () => {
const result = validateDefinitionConfiguration({ customDefinitions: [customDefinition], disabledDefinitionIds: ["mastodon", "mastodon"] });
expect(result.valid).toBe(true);
if (result.valid) expect(result.value.disabledDefinitionIds).toEqual(["mastodon"]);
});
it("rejects executable or malformed definition fields", () => {
const result = validateDefinitionConfiguration({ customDefinitions: [{ ...customDefinition, id: "Bad ID", post: { ...customDefinition.post, rootSelectors: ["["] } }], disabledDefinitionIds: [] });
expect(result.valid).toBe(false);
});
it("lets a custom definition replace a bundled definition by ID", () => {
const override = { ...BUILTIN_DEFINITIONS[0]!, name: "Custom Mastodon" };
const definitions = effectiveDefinitions([override], []);
expect(definitions.filter((definition) => definition.id === "mastodon")).toEqual([override]);
});
});

View file

@ -39,6 +39,7 @@ function inputsFor(mode: string): Record<string, string> {
background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts",
inference: "src/inference/worker.ts",
options: "src/options/main.ts",
popup: "src/popup/main.ts",
offscreen: "src/background/offscreen.ts"
};
}
@ -49,6 +50,7 @@ function manifestPlugin(mode: string): Plugin {
generateBundle() {
const filename = mode === "firefox" ? "public/manifest.firefox.json" : "public/manifest.chromium.json";
this.emitFile({ type: "asset", fileName: "manifest.json", source: readFileSync(filename, "utf8") });
this.emitFile({ type: "asset", fileName: "ort/ort-wasm-simd-threaded.jsep.wasm", source: readFileSync("node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm") });
}
};
}