Add JSON definition subscriptions
This commit is contained in:
parent
7e17470356
commit
bedeb8495d
15 changed files with 321 additions and 37 deletions
|
|
@ -9,7 +9,7 @@ VibeGuard is a local-first browser extension that hides toxic social-media posts
|
||||||
- Optional toxicity scores on filtered-post placeholders.
|
- Optional toxicity scores on filtered-post placeholders.
|
||||||
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
|
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
|
||||||
- Built-in support for the official Mastodon web interface.
|
- Built-in support for the official Mastodon web interface.
|
||||||
- Import and export of custom site definitions as JSON.
|
- Versioned JSON definition collections with weekly subscriptions and manual refresh.
|
||||||
- Toolbar popup with current-site status, matched definition, pause/resume control, and settings access.
|
- Toolbar popup with current-site status, matched definition, pause/resume control, and settings access.
|
||||||
- Firefox Manifest V2 and Chromium Manifest V3 builds.
|
- Firefox Manifest V2 and Chromium Manifest V3 builds.
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ 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.
|
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.
|
||||||
|
|
||||||
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.
|
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 bundled definition collection for the official Mastodon web interface. The default subscription follows the repository's raw JSON collection and refreshes weekly, while the bundled copy remains available offline. Additional JSON subscriptions, local overrides, and disabled definitions are managed in the options page. Definitions are declarative selectors and URL patterns, never executable code.
|
||||||
|
|
||||||
## Runtime architecture
|
## Runtime architecture
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ The model is loaded once by the shared inference backend for each browser contex
|
||||||
|
|
||||||
## Options page
|
## Options page
|
||||||
|
|
||||||
Open the extension’s 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.
|
Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section manages JSON subscriptions, which refresh automatically once per week and can also be refreshed manually. Local overrides can be imported from or exported to JSON and are validated before they are saved. A failed or invalid subscription update leaves the last-known-good definitions active.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"96": "icons/logo96.png",
|
"96": "icons/logo96.png",
|
||||||
"128": "icons/logo128.png"
|
"128": "icons/logo128.png"
|
||||||
},
|
},
|
||||||
"permissions": ["storage", "tabs", "offscreen"],
|
"permissions": ["storage", "tabs", "alarms", "offscreen"],
|
||||||
"host_permissions": ["<all_urls>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
"background": { "service_worker": "background.js", "type": "module" },
|
"background": { "service_worker": "background.js", "type": "module" },
|
||||||
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
|
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
"128": "icons/logo128.png"
|
"128": "icons/logo128.png"
|
||||||
},
|
},
|
||||||
"browser_action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
|
"browser_action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
|
||||||
"permissions": ["storage", "tabs", "<all_urls>"],
|
"permissions": ["storage", "tabs", "alarms", "<all_urls>"],
|
||||||
"background": { "scripts": ["background.js"], "persistent": true },
|
"background": { "scripts": ["background.js"], "persistent": true },
|
||||||
"content_scripts": [{ "matches": ["<all_urls>"], "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 },
|
"options_ui": { "page": "options.html", "open_in_tab": true },
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,21 @@
|
||||||
<fieldset class="field">
|
<fieldset class="field">
|
||||||
<legend class="label">Site definitions</legend>
|
<legend class="label">Site definitions</legend>
|
||||||
<div class="content">
|
<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>
|
<p>Definitions are loaded from subscriptions weekly. The bundled collection remains available when a subscription is unavailable.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="definitions">Custom definitions (JSON)</label>
|
<label class="label" for="subscription-url">Add JSON subscription URL</label>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded"><input class="input" id="subscription-url" type="url" placeholder="https://example.com/definitions.json"></div>
|
||||||
|
<div class="control"><button class="button" type="button" id="add-subscription">Add</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="subscriptions" class="content"></div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button" type="button" id="refresh-subscriptions">Update subscriptions now</button>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="definitions">Advanced local overrides (JSON)</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea vibeguard-json" id="definitions" rows="18" spellcheck="false"></textarea>
|
<textarea class="textarea vibeguard-json" id="definitions" rows="18" spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { InferenceQueue } from "../inference/queue";
|
import { InferenceQueue } from "../inference/queue";
|
||||||
import { loadSettings, saveSettings, setDefinitionPaused } from "../shared/settings";
|
import { loadSettings, saveSettings, setDefinitionPaused } from "../shared/settings";
|
||||||
|
import { refreshSubscriptions, subscriptionsAreDue } from "../shared/subscriptions";
|
||||||
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
||||||
|
|
@ -9,6 +10,16 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis
|
||||||
return runBatch(requests);
|
return runBatch(requests);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshIfDue = async (): Promise<void> => {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (subscriptionsAreDue(settings)) {
|
||||||
|
const result = await refreshSubscriptions(settings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void chrome.alarms?.create?.("vibeguard-subscriptions-weekly", { periodInMinutes: 7 * 24 * 60 });
|
||||||
|
chrome.alarms?.onAlarm?.addListener((alarm) => { if (alarm.name === "vibeguard-subscriptions-weekly") void refreshIfDue(); });
|
||||||
|
chrome.runtime.onStartup?.addListener(() => { void refreshIfDue(); });
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => {
|
||||||
if (message.type === "INFER") {
|
if (message.type === "INFER") {
|
||||||
const request: InferenceRequest = { ...message.request, tabId: message.request.tabId ?? sender.tab?.id };
|
const request: InferenceRequest = { ...message.request, tabId: message.request.tabId ?? sender.tab?.id };
|
||||||
|
|
@ -24,6 +35,12 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis
|
||||||
.catch((error) => sendResponse({ error: String(error) }));
|
.catch((error) => sendResponse({ error: String(error) }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (message.type === "REFRESH_SUBSCRIPTIONS") {
|
||||||
|
loadSettings().then((settings) => refreshSubscriptions(settings)).then((result) => {
|
||||||
|
return result.settings;
|
||||||
|
}).then((settings) => sendResponse({ type: "SUBSCRIPTIONS_UPDATED", settings })).catch((error) => sendResponse({ error: String(error) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
|
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
import type { SiteDefinition } from "../shared/types";
|
import bundledCollection from "./definitions/default.json";
|
||||||
|
import type { DefinitionCollection, Settings, SiteDefinition } from "../shared/types";
|
||||||
|
|
||||||
export const BUILTIN_DEFINITIONS: SiteDefinition[] = [{
|
export const BUNDLED_COLLECTION = bundledCollection as DefinitionCollection;
|
||||||
id: "mastodon",
|
export const BUILTIN_DEFINITIONS: SiteDefinition[] = BUNDLED_COLLECTION.definitions;
|
||||||
name: "Mastodon (official web interface)",
|
|
||||||
urlPatterns: ["<all_urls>"],
|
export function effectiveDefinitions(settings: Pick<Settings, "customDefinitions" | "disabledDefinitionIds" | "subscriptions" | "subscriptionCollections"> | SiteDefinition[], legacyDisabledDefinitionIds?: string[]): SiteDefinition[] {
|
||||||
requiredSelectors: [".status__content", ".status, .detailed-status"],
|
if (Array.isArray(settings)) settings = { customDefinitions: settings, disabledDefinitionIds: legacyDisabledDefinitionIds ?? [], subscriptions: [], subscriptionCollections: [] };
|
||||||
post: {
|
const byId = new Map<string, SiteDefinition>();
|
||||||
rootSelectors: [".status", ".detailed-status"],
|
const orderedSubscriptions = [...settings.subscriptions]
|
||||||
textSelectors: [".status__content", ".e-content"],
|
.filter((subscription) => subscription.enabled)
|
||||||
excludedSelectors: [".status__content__spoiler-link", ".status__content__read-more-button"],
|
.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
|
||||||
idAttributes: ["data-id"],
|
const collections = new Map(settings.subscriptionCollections.map((collection) => [collection.collectionId, collection]));
|
||||||
permalinkSelectors: ["a.status__relative-time[href]", "a.detailed-status__datetime[href]", "a.u-url.u-uid[href]"]
|
for (const subscription of orderedSubscriptions) {
|
||||||
|
for (const definition of collections.get(subscription.id)?.definitions ?? []) {
|
||||||
|
if (!byId.has(definition.id)) byId.set(definition.id, definition);
|
||||||
}
|
}
|
||||||
}];
|
}
|
||||||
|
for (const definition of BUILTIN_DEFINITIONS) if (!byId.has(definition.id)) byId.set(definition.id, definition);
|
||||||
export function effectiveDefinitions(customDefinitions: SiteDefinition[], disabledDefinitionIds: string[]): SiteDefinition[] {
|
for (const definition of settings.customDefinitions) byId.set(definition.id, definition);
|
||||||
const customIds = new Set(customDefinitions.map((definition) => definition.id));
|
const disabled = new Set(settings.disabledDefinitionIds);
|
||||||
const disabled = new Set(disabledDefinitionIds);
|
return [...byId.values()].filter((definition) => !disabled.has(definition.id));
|
||||||
return [...customDefinitions, ...BUILTIN_DEFINITIONS.filter((definition) => !customIds.has(definition.id))]
|
|
||||||
.filter((definition) => !disabled.has(definition.id));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/content/definitions/default.json
Normal file
21
src/content/definitions/default.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"collectionId": "vibeguard-default",
|
||||||
|
"name": "VibeGuard default definitions",
|
||||||
|
"description": "Definitions maintained by the VibeGuard project.",
|
||||||
|
"definitions": [
|
||||||
|
{
|
||||||
|
"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]"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ async function start(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function runDefinition(settings: Settings, sequence: number): () => void {
|
function runDefinition(settings: Settings, sequence: number): () => void {
|
||||||
const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, settings.disabledDefinitionIds));
|
const definition = selectDefinition(effectiveDefinitions(settings));
|
||||||
if (!definition) return () => {};
|
if (!definition) return () => {};
|
||||||
console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href });
|
console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href });
|
||||||
const parser = new DefinitionEngine(definition);
|
const parser = new DefinitionEngine(definition);
|
||||||
|
|
@ -126,7 +126,7 @@ function runDefinition(settings: Settings, sequence: number): () => void {
|
||||||
let currentReloadSequence = 0;
|
let currentReloadSequence = 0;
|
||||||
|
|
||||||
async function getPageStatus(settings: Settings): Promise<PageStatus> {
|
async function getPageStatus(settings: Settings): Promise<PageStatus> {
|
||||||
const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, []));
|
const definition = selectDefinition(effectiveDefinitions({ ...settings, disabledDefinitionIds: [] }));
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
console.debug("[VibeGuard] No matching definition for page status", { url: location.href });
|
console.debug("[VibeGuard] No matching definition for page status", { url: location.href });
|
||||||
return { supported: false, paused: false };
|
return { supported: false, paused: false };
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,46 @@ const definitions = document.querySelector<HTMLTextAreaElement>("#definitions");
|
||||||
const importButton = document.querySelector<HTMLButtonElement>("#import-definitions");
|
const importButton = document.querySelector<HTMLButtonElement>("#import-definitions");
|
||||||
const exportButton = document.querySelector<HTMLButtonElement>("#export-definitions");
|
const exportButton = document.querySelector<HTMLButtonElement>("#export-definitions");
|
||||||
const definitionFile = document.querySelector<HTMLInputElement>("#definition-file");
|
const definitionFile = document.querySelector<HTMLInputElement>("#definition-file");
|
||||||
|
const subscriptionUrl = document.querySelector<HTMLInputElement>("#subscription-url");
|
||||||
|
const subscriptionList = document.querySelector<HTMLElement>("#subscriptions");
|
||||||
|
const addSubscriptionButton = document.querySelector<HTMLButtonElement>("#add-subscription");
|
||||||
|
const refreshSubscriptionsButton = document.querySelector<HTMLButtonElement>("#refresh-subscriptions");
|
||||||
|
let currentSettings: Settings;
|
||||||
|
|
||||||
void loadSettings().then((settings) => {
|
void loadSettings().then((settings) => {
|
||||||
|
currentSettings = settings;
|
||||||
threshold!.value = String(settings.threshold);
|
threshold!.value = String(settings.threshold);
|
||||||
mode!.value = settings.filterMode;
|
mode!.value = settings.filterMode;
|
||||||
showScore!.checked = settings.showScore;
|
showScore!.checked = settings.showScore;
|
||||||
definitions!.value = JSON.stringify(definitionConfiguration(settings), null, 2);
|
definitions!.value = JSON.stringify(definitionConfiguration(settings), null, 2);
|
||||||
|
renderSubscriptions(settings);
|
||||||
updateThresholdLabel();
|
updateThresholdLabel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
addSubscriptionButton?.addEventListener("click", async () => {
|
||||||
|
const url = subscriptionUrl?.value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!/^https?:$/.test(parsed.protocol)) throw new Error("Subscriptions must use HTTP or HTTPS.");
|
||||||
|
const id = `custom-${Date.now().toString(36)}`;
|
||||||
|
currentSettings = await saveSettings({ subscriptions: [...currentSettings.subscriptions, { id, name: url, url, enabled: true, priority: currentSettings.subscriptions.length }] });
|
||||||
|
subscriptionUrl!.value = "";
|
||||||
|
renderSubscriptions(currentSettings);
|
||||||
|
showStatus("Subscription added. Use Update subscriptions now to fetch it.");
|
||||||
|
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshSubscriptionsButton?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: "REFRESH_SUBSCRIPTIONS" });
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
currentSettings = response.settings;
|
||||||
|
renderSubscriptions(currentSettings);
|
||||||
|
showStatus("Subscriptions updated.");
|
||||||
|
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
||||||
|
});
|
||||||
|
|
||||||
threshold?.addEventListener("input", updateThresholdLabel);
|
threshold?.addEventListener("input", updateThresholdLabel);
|
||||||
form?.addEventListener("submit", async (event) => {
|
form?.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -63,11 +94,11 @@ function updateThresholdLabel(): void {
|
||||||
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
|
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function definitionConfiguration(settings?: Settings): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } {
|
function definitionConfiguration(settings?: Settings): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[]; subscriptions: Settings["subscriptions"]; subscriptionCollections: Settings["subscriptionCollections"] } {
|
||||||
return { customDefinitions: settings?.customDefinitions ?? [], disabledDefinitionIds: settings?.disabledDefinitionIds ?? [] };
|
return { customDefinitions: settings?.customDefinitions ?? [], disabledDefinitionIds: settings?.disabledDefinitionIds ?? [], subscriptions: settings?.subscriptions ?? [], subscriptionCollections: settings?.subscriptionCollections ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDefinitionConfiguration(): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } {
|
function parseDefinitionConfiguration(): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[]; subscriptions: Settings["subscriptions"]; subscriptionCollections: Settings["subscriptionCollections"] } {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try { parsed = JSON.parse(definitions?.value ?? "{}"); } catch { throw new Error("Definition JSON is invalid."); }
|
try { parsed = JSON.parse(definitions?.value ?? "{}"); } catch { throw new Error("Definition JSON is invalid."); }
|
||||||
const validation = validateDefinitionConfiguration(parsed);
|
const validation = validateDefinitionConfiguration(parsed);
|
||||||
|
|
@ -75,6 +106,61 @@ function parseDefinitionConfiguration(): { customDefinitions: Settings["customDe
|
||||||
return validation.value;
|
return validation.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSubscriptions(settings: Settings): void {
|
||||||
|
if (!subscriptionList) return;
|
||||||
|
subscriptionList.replaceChildren(...[...settings.subscriptions].sort((a, b) => a.priority - b.priority).map((subscription) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "box p-3";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = `${subscription.name} (${subscription.enabled ? "enabled" : "disabled"})`;
|
||||||
|
const detail = document.createElement("small");
|
||||||
|
detail.textContent = ` ${subscription.url} — ${subscription.lastError ?? (subscription.lastSuccessfulUpdate ? `updated ${new Date(subscription.lastSuccessfulUpdate).toLocaleString()}` : "not updated yet")}`;
|
||||||
|
const toggle = document.createElement("input");
|
||||||
|
toggle.type = "checkbox";
|
||||||
|
toggle.checked = subscription.enabled;
|
||||||
|
toggle.className = "mr-2";
|
||||||
|
toggle.addEventListener("change", async () => {
|
||||||
|
currentSettings = await saveSettings({ subscriptions: currentSettings.subscriptions.map((item) => item.id === subscription.id ? { ...item, enabled: toggle.checked } : item) });
|
||||||
|
renderSubscriptions(currentSettings);
|
||||||
|
});
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "button is-small is-light ml-2";
|
||||||
|
remove.textContent = "Remove";
|
||||||
|
remove.addEventListener("click", async () => {
|
||||||
|
currentSettings = await saveSettings({ subscriptions: currentSettings.subscriptions.filter((item) => item.id !== subscription.id), subscriptionCollections: currentSettings.subscriptionCollections.filter((item) => item.collectionId !== subscription.id) });
|
||||||
|
renderSubscriptions(currentSettings);
|
||||||
|
});
|
||||||
|
const raise = document.createElement("button");
|
||||||
|
raise.type = "button";
|
||||||
|
raise.className = "button is-small is-light ml-1";
|
||||||
|
raise.textContent = "↑";
|
||||||
|
raise.title = "Increase priority";
|
||||||
|
raise.addEventListener("click", () => void moveSubscription(subscription.id, -1));
|
||||||
|
const lower = document.createElement("button");
|
||||||
|
lower.type = "button";
|
||||||
|
lower.className = "button is-small is-light ml-1";
|
||||||
|
lower.textContent = "↓";
|
||||||
|
lower.title = "Decrease priority";
|
||||||
|
lower.addEventListener("click", () => void moveSubscription(subscription.id, 1));
|
||||||
|
row.prepend(toggle);
|
||||||
|
row.append(title, document.createElement("br"), detail, remove, raise, lower);
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSubscription(id: string, direction: -1 | 1): Promise<void> {
|
||||||
|
const ordered = [...currentSettings.subscriptions].sort((a, b) => a.priority - b.priority);
|
||||||
|
const index = ordered.findIndex((subscription) => subscription.id === id);
|
||||||
|
const swapIndex = index + direction;
|
||||||
|
if (index < 0 || swapIndex < 0 || swapIndex >= ordered.length) return;
|
||||||
|
const currentPriority = ordered[index]!.priority;
|
||||||
|
ordered[index]!.priority = ordered[swapIndex]!.priority;
|
||||||
|
ordered[swapIndex]!.priority = currentPriority;
|
||||||
|
currentSettings = await saveSettings({ subscriptions: ordered });
|
||||||
|
renderSubscriptions(currentSettings);
|
||||||
|
}
|
||||||
|
|
||||||
function showStatus(message: string, isError = false): void {
|
function showStatus(message: string, isError = false): void {
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
status.textContent = message;
|
status.textContent = message;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { DEFAULT_SETTINGS, type Settings, type SiteDefinition } from "./types";
|
import { DEFAULT_SETTINGS, type Settings, type SiteDefinition } from "./types";
|
||||||
import { validateDefinitionConfiguration } from "./site-definitions";
|
import { validateDefinitionConfiguration } from "./site-definitions";
|
||||||
|
import type { DefinitionSubscription } from "./types";
|
||||||
|
|
||||||
const KEY = "vibeguard.settings";
|
const KEY = "vibeguard.settings";
|
||||||
|
export const DEFAULT_SUBSCRIPTION: DefinitionSubscription = {
|
||||||
|
id: "vibeguard-default",
|
||||||
|
name: "VibeGuard default definitions",
|
||||||
|
url: "https://git.jordanwages.com/wagesj45/vibeguard/raw/branch/main/src/content/definitions/default.json",
|
||||||
|
enabled: true,
|
||||||
|
priority: 0
|
||||||
|
};
|
||||||
|
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
const stored = await getStorageItem(KEY);
|
const stored = await getStorageItem(KEY);
|
||||||
|
|
@ -35,20 +43,25 @@ function setStorageItem(items: Record<string, unknown>): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeSettings(input?: Partial<Settings>): Settings {
|
export function mergeSettings(input?: Partial<Settings>): Settings {
|
||||||
|
const subscriptions = input?.subscriptions?.length ? input.subscriptions : [DEFAULT_SUBSCRIPTION];
|
||||||
const definitionConfiguration = validateDefinitionConfiguration({
|
const definitionConfiguration = validateDefinitionConfiguration({
|
||||||
customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions,
|
customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions,
|
||||||
disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds
|
disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds,
|
||||||
|
subscriptions,
|
||||||
|
subscriptionCollections: input?.subscriptionCollections ?? DEFAULT_SETTINGS.subscriptionCollections
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...input,
|
...input,
|
||||||
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
||||||
customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions,
|
customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions,
|
||||||
disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds
|
disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds,
|
||||||
|
subscriptions: definitionConfiguration.valid ? definitionConfiguration.value.subscriptions : [DEFAULT_SUBSCRIPTION],
|
||||||
|
subscriptionCollections: definitionConfiguration.valid ? definitionConfiguration.value.subscriptionCollections : DEFAULT_SETTINGS.subscriptionCollections
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[] }): Promise<Settings> {
|
export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[]; subscriptions?: DefinitionSubscription[]; subscriptionCollections?: Settings["subscriptionCollections"] }): Promise<Settings> {
|
||||||
const validation = validateDefinitionConfiguration(configuration);
|
const validation = validateDefinitionConfiguration(configuration);
|
||||||
if (!validation.valid) return Promise.reject(new Error(validation.errors.join("\n")));
|
if (!validation.valid) return Promise.reject(new Error(validation.errors.join("\n")));
|
||||||
return saveSettings(validation.value);
|
return saveSettings(validation.value);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { SiteDefinition } from "./types";
|
import type { DefinitionCollection, DefinitionSubscription, SiteDefinition } from "./types";
|
||||||
|
|
||||||
export interface DefinitionConfiguration {
|
export interface DefinitionConfiguration {
|
||||||
customDefinitions: SiteDefinition[];
|
customDefinitions: SiteDefinition[];
|
||||||
disabledDefinitionIds: string[];
|
disabledDefinitionIds: string[];
|
||||||
|
subscriptions: DefinitionSubscription[];
|
||||||
|
subscriptionCollections: DefinitionCollection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DefinitionValidation =
|
export type DefinitionValidation =
|
||||||
|
|
@ -15,10 +17,13 @@ export function validateDefinitionConfiguration(value: unknown): DefinitionValid
|
||||||
if (!isRecord(value)) return invalid("Definition configuration must be an object.");
|
if (!isRecord(value)) return invalid("Definition configuration must be an object.");
|
||||||
const customDefinitions = value.customDefinitions;
|
const customDefinitions = value.customDefinitions;
|
||||||
const disabledDefinitionIds = value.disabledDefinitionIds;
|
const disabledDefinitionIds = value.disabledDefinitionIds;
|
||||||
|
const subscriptions = value.subscriptions ?? [];
|
||||||
|
const subscriptionCollections = value.subscriptionCollections ?? [];
|
||||||
if (!Array.isArray(customDefinitions)) return invalid("customDefinitions must be an array.");
|
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))) {
|
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.");
|
return invalid("disabledDefinitionIds must be an array of valid definition IDs.");
|
||||||
}
|
}
|
||||||
|
if (!Array.isArray(subscriptions) || !Array.isArray(subscriptionCollections)) return invalid("Subscription state must contain arrays.");
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const definitions: SiteDefinition[] = [];
|
const definitions: SiteDefinition[] = [];
|
||||||
|
|
@ -29,7 +34,17 @@ export function validateDefinitionConfiguration(value: unknown): DefinitionValid
|
||||||
else if (ids.has(validation.value.id)) errors.push(`Duplicate definition ID: ${validation.value.id}.`);
|
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); }
|
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)] } };
|
const validSubscriptions = subscriptions.map((subscription, index) => validateSubscription(subscription, `subscriptions[${index}]`, errors)).filter((subscription): subscription is DefinitionSubscription => Boolean(subscription));
|
||||||
|
const validCollections = subscriptionCollections.map((collection, index) => validateCollection(collection, `subscriptionCollections[${index}]`, errors)).filter((collection): collection is DefinitionCollection => Boolean(collection));
|
||||||
|
const subscriptionIds = new Set<string>();
|
||||||
|
for (const subscription of validSubscriptions) if (subscriptionIds.has(subscription.id)) errors.push(`Duplicate subscription ID: ${subscription.id}.`); else subscriptionIds.add(subscription.id);
|
||||||
|
return errors.length > 0 ? { valid: false, errors } : { valid: true, value: { customDefinitions: definitions, disabledDefinitionIds: [...new Set(disabledDefinitionIds)], subscriptions: validSubscriptions, subscriptionCollections: validCollections } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDefinitionCollection(value: unknown): { valid: true; value: DefinitionCollection } | { valid: false; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const collection = validateCollection(value, "collection", errors);
|
||||||
|
return collection && errors.length === 0 ? { valid: true, value: collection } : { valid: false, errors: errors.length ? errors : ["Invalid definition collection."] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSiteDefinition(value: unknown, path = "definition"): { valid: true; value: SiteDefinition } | { valid: false; errors: string[] } {
|
export function validateSiteDefinition(value: unknown, path = "definition"): { valid: true; value: SiteDefinition } | { valid: false; errors: string[] } {
|
||||||
|
|
@ -73,4 +88,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSubscription(value: unknown, path: string, errors: string[]): DefinitionSubscription | undefined {
|
||||||
|
if (!isRecord(value) || typeof value.id !== "string" || !ID_PATTERN.test(value.id) || typeof value.name !== "string" || !value.name.trim() || typeof value.url !== "string" || !/^https?:\/\//.test(value.url) || typeof value.enabled !== "boolean" || typeof value.priority !== "number" || !Number.isFinite(value.priority)) {
|
||||||
|
errors.push(`${path} is invalid.`); return undefined;
|
||||||
|
}
|
||||||
|
return { id: value.id, name: value.name.trim(), url: value.url, enabled: value.enabled, priority: value.priority, ...(typeof value.lastChecked === "string" ? { lastChecked: value.lastChecked } : {}), ...(typeof value.lastSuccessfulUpdate === "string" ? { lastSuccessfulUpdate: value.lastSuccessfulUpdate } : {}), ...(typeof value.lastKnownGoodHash === "string" ? { lastKnownGoodHash: value.lastKnownGoodHash } : {}), ...(typeof value.lastError === "string" ? { lastError: value.lastError } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCollection(value: unknown, path: string, errors: string[]): DefinitionCollection | undefined {
|
||||||
|
if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.collectionId !== "string" || !ID_PATTERN.test(value.collectionId) || typeof value.name !== "string" || !value.name.trim() || !Array.isArray(value.definitions) || value.definitions.length > 1000) {
|
||||||
|
errors.push(`${path} must be a version 1 collection with a valid ID, name, and definitions array.`); return undefined;
|
||||||
|
}
|
||||||
|
const definitions: SiteDefinition[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
value.definitions.forEach((definition, index) => {
|
||||||
|
const result = validateSiteDefinition(definition, `${path}.definitions[${index}]`);
|
||||||
|
if (!result.valid) errors.push(...result.errors);
|
||||||
|
else if (ids.has(result.value.id)) errors.push(`Duplicate definition ID: ${result.value.id}.`);
|
||||||
|
else { ids.add(result.value.id); definitions.push(result.value); }
|
||||||
|
});
|
||||||
|
return errors.length > 0 ? undefined : { schemaVersion: 1, collectionId: value.collectionId, name: value.name.trim(), ...(typeof value.description === "string" ? { description: value.description } : {}), ...(typeof value.updatedAt === "string" ? { updatedAt: value.updatedAt } : {}), definitions };
|
||||||
|
}
|
||||||
|
|
||||||
function invalid(error: string): { valid: false; errors: string[] } { return { valid: false, errors: [error] }; }
|
function invalid(error: string): { valid: false; errors: string[] } { return { valid: false, errors: [error] }; }
|
||||||
|
|
|
||||||
45
src/shared/subscriptions.ts
Normal file
45
src/shared/subscriptions.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { validateDefinitionCollection } from "./site-definitions";
|
||||||
|
import { saveSettings } from "./settings";
|
||||||
|
import type { DefinitionCollection, DefinitionSubscription, Settings } from "./types";
|
||||||
|
|
||||||
|
export const SUBSCRIPTION_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface SubscriptionRefreshResult { settings: Settings; changed: boolean; }
|
||||||
|
|
||||||
|
export async function refreshSubscriptions(settings: Settings, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<SubscriptionRefreshResult> {
|
||||||
|
let changed = false;
|
||||||
|
const collections = new Map(settings.subscriptionCollections.map((collection) => [collection.collectionId, collection]));
|
||||||
|
const subscriptions: DefinitionSubscription[] = [];
|
||||||
|
for (const subscription of settings.subscriptions) {
|
||||||
|
if (!subscription.enabled) { subscriptions.push(subscription); continue; }
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(subscription.url, { headers: { Accept: "application/json" } });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const validation = validateDefinitionCollection(await response.json() as unknown);
|
||||||
|
if (!validation.valid) throw new Error(validation.errors.join(" "));
|
||||||
|
const collection = validation.value;
|
||||||
|
const storedCollection: DefinitionCollection = { ...collection, collectionId: subscription.id };
|
||||||
|
const hash = await digest(JSON.stringify(storedCollection));
|
||||||
|
changed ||= subscription.lastKnownGoodHash !== hash || !collections.has(subscription.id);
|
||||||
|
collections.set(subscription.id, storedCollection);
|
||||||
|
subscriptions.push({ ...subscription, lastChecked: new Date(now).toISOString(), lastSuccessfulUpdate: new Date(now).toISOString(), lastKnownGoodHash: hash, lastError: undefined });
|
||||||
|
} catch (error) {
|
||||||
|
subscriptions.push({ ...subscription, lastChecked: new Date(now).toISOString(), lastError: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = await saveSettings({ subscriptions, subscriptionCollections: [...collections.values()] });
|
||||||
|
return { settings: next, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionIsDue(subscription: DefinitionSubscription, now = Date.now()): boolean {
|
||||||
|
return !subscription.lastSuccessfulUpdate || now - Date.parse(subscription.lastSuccessfulUpdate) >= SUBSCRIPTION_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionsAreDue(settings: Settings, now = Date.now()): boolean {
|
||||||
|
return settings.subscriptions.some((subscription) => subscription.enabled && subscriptionIsDue(subscription, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function digest(value: string): Promise<string> {
|
||||||
|
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||||
|
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,27 @@ export interface SiteDefinition {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DefinitionCollection {
|
||||||
|
schemaVersion: 1;
|
||||||
|
collectionId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
definitions: SiteDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefinitionSubscription {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
lastChecked?: string;
|
||||||
|
lastSuccessfulUpdate?: string;
|
||||||
|
lastKnownGoodHash?: string;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NormalizedPost {
|
export interface NormalizedPost {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -54,6 +75,8 @@ export interface Settings {
|
||||||
showScore: boolean;
|
showScore: boolean;
|
||||||
customDefinitions: SiteDefinition[];
|
customDefinitions: SiteDefinition[];
|
||||||
disabledDefinitionIds: string[];
|
disabledDefinitionIds: string[];
|
||||||
|
subscriptions: DefinitionSubscription[];
|
||||||
|
subscriptionCollections: DefinitionCollection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageStatus {
|
export interface PageStatus {
|
||||||
|
|
@ -67,7 +90,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||||
filterMode: "collapse",
|
filterMode: "collapse",
|
||||||
showScore: true,
|
showScore: true,
|
||||||
customDefinitions: [],
|
customDefinitions: [],
|
||||||
disabledDefinitionIds: []
|
disabledDefinitionIds: [],
|
||||||
|
subscriptions: [],
|
||||||
|
subscriptionCollections: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeMessage =
|
export type RuntimeMessage =
|
||||||
|
|
@ -79,5 +104,7 @@ export type RuntimeMessage =
|
||||||
| { type: "GET_PAGE_STATUS" }
|
| { type: "GET_PAGE_STATUS" }
|
||||||
| { type: "PAGE_STATUS"; status: PageStatus }
|
| { type: "PAGE_STATUS"; status: PageStatus }
|
||||||
| { type: "SET_DEFINITION_PAUSED"; definitionId: string; paused: boolean }
|
| { type: "SET_DEFINITION_PAUSED"; definitionId: string; paused: boolean }
|
||||||
|
| { type: "REFRESH_SUBSCRIPTIONS" }
|
||||||
|
| { type: "SUBSCRIPTIONS_UPDATED"; settings: Settings }
|
||||||
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
|
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
|
||||||
| { type: "PING" };
|
| { type: "PING" };
|
||||||
|
|
|
||||||
25
tests/subscriptions.test.ts
Normal file
25
tests/subscriptions.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { subscriptionIsDue } from "../src/shared/subscriptions";
|
||||||
|
import { validateDefinitionCollection } from "../src/shared/site-definitions";
|
||||||
|
|
||||||
|
const definition = {
|
||||||
|
id: "example",
|
||||||
|
name: "Example",
|
||||||
|
urlPatterns: ["https://example.test/*"],
|
||||||
|
requiredSelectors: ["main"],
|
||||||
|
post: { rootSelectors: ["article"], textSelectors: [".content"] }
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("definition subscriptions", () => {
|
||||||
|
it("validates versioned collections and rejects duplicate IDs", () => {
|
||||||
|
const result = validateDefinitionCollection({ schemaVersion: 1, collectionId: "example-source", name: "Example", definitions: [definition, definition] });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is due when never updated or at least seven days old", () => {
|
||||||
|
const now = Date.parse("2026-08-25T00:00:00Z");
|
||||||
|
expect(subscriptionIsDue({ id: "x", name: "X", url: "https://example.test/x.json", enabled: true, priority: 0 }, now)).toBe(true);
|
||||||
|
expect(subscriptionIsDue({ id: "x", name: "X", url: "https://example.test/x.json", enabled: true, priority: 0, lastSuccessfulUpdate: "2026-08-18T00:00:00Z" }, now)).toBe(true);
|
||||||
|
expect(subscriptionIsDue({ id: "x", name: "X", url: "https://example.test/x.json", enabled: true, priority: 0, lastSuccessfulUpdate: "2026-08-24T00:00:00Z" }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"lib": ["ES2022", "DOM", "WebWorker"],
|
"lib": ["ES2022", "DOM", "WebWorker"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue