Compare commits

..
10 Commits
Author SHA1 Message Date
arsam dce0619787 Import documentation: README.md
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:37 +03:30
arsam f38b39d60c Import documentation: docs/superpowers/specs/2026-07-13-persian-rtl-reliability-design.md
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:36 +03:30
arsam 2ee5cb30a9 Import documentation: docs/superpowers/plans/2026-07-13-persian-rtl-reliability.md
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:35 +03:30
arsam 6ebc4e7361 Import icons visual and font assets
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:34 +03:30
arsam 3c2aeb75cd Import fonts visual and font assets
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:33 +03:30
arsam 5b14e6cf5d Import tests: test/popup
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:33 +03:30
arsam 743bd9f1e8 Import tests: test/encoding
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:32 +03:30
arsam 11e152cc4b Import tests: test/edge-cases
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:31 +03:30
arsam 240937ff4d Import tests: test/content-engine
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:30 +03:30
arsam 64d91a0d2e Import source: src/content
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:07:29 +03:30
15 changed files with 1213 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# Persian RTL Fixer — Vazirmatn
A dependency-free Chrome extension (Manifest V3) that fixes Persian text direction on dynamic websites, especially streaming AI chats, without rewriting the page’s text.
## What it changes
- A Persian-leading text block gets `dir="rtl"` and right alignment.
- English-leading mixed content gets `dir="auto"` so both scripts remain readable.
- Arabic-only, Hebrew, and English-only text are left untouched.
- Latin digits such as `1234567890` are never converted or rewritten.
- Unstyled Persian text receives the bundled Vazirmatn font.
- Any font selected by the website is preserved, including inherited, generic, inline, and web fonts.
- Text inputs use automatic direction and receive Vazirmatn only after Persian text is present.
The content script batches DOM mutations, so token-by-token AI responses are corrected without rescanning the whole page for every character.
## Safety model
The extension never wraps, replaces, or restructures text nodes. It changes only reversible `dir` attributes and inline font styles on existing elements. Code blocks, SVG, MathML, and unsupported form controls are skipped. Disabling the extension restores every owned change without reloading the page.
The detector requires Persian-specific characters before acting. This intentionally favors precision over guessing: ambiguous text made entirely from letters shared with Arabic is not modified.
## Install as an unpacked extension
1. Open `chrome://extensions`.
2. Enable **Developer mode**.
3. Select **Load unpacked** and choose this directory.
4. Pin **Persian RTL Fixer** from the extensions menu.
To use the local `demo.html`, open the extension’s details and enable **Allow access to file URLs** first.
## Popup settings
| Setting | Default | Behavior |
|---|---:|---|
| Enable on this site | On | Pauses correction for the current hostname |
| Conservative RTL correction | On | Corrects clearly Persian blocks while preserving uncertain layouts |
| Vazirmatn for unstyled text | On | Applies Vazirmatn only when the site declares no font |
| Smart input direction | On | Uses automatic direction in text fields |
| Master switch | On | Enables or disables the extension everywhere |
Settings are stored locally through `chrome.storage.local`. The extension does not send page text anywhere.
## Development
```powershell
npm install
npm test
npm run check:encoding
```
The automated suites cover real Persian fixtures, Arabic isolation, mixed direction, Latin-digit integrity, inherited and stylesheet fonts, streaming mutations, reversible teardown, protected content, input behavior, popup states, and UTF-8 integrity.
## Project layout
```text
manifest.json Manifest V3 configuration
src/content.js Persian detector, DOM correction, and lifecycle
popup/ Accessible toolbar popup
fonts/ Bundled Vazirmatn WOFF2 files
icons/ Extension icons
demo.html Manual and streaming test page
test/ jsdom and integrity tests
```
Open shadow roots and related frames are supported where Chrome exposes them. Closed shadow roots are inaccessible to browser extensions.
Vazirmatn is distributed under the SIL Open Font License.
@@ -0,0 +1,115 @@
# Persian RTL Reliability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the extension conservatively correct Persian text direction, add Vazirmatn only to author-unstyled Persian text, remain responsive during streaming AI chats, and provide a clearer accessible popup.
**Architecture:** Keep the dependency-free Manifest V3 structure. Replace the corrupted broad Arabic-range detector with testable Persian-specific helpers inside the isolated content script, apply reversible DOM mutations, and coalesce mutation work by semantic block. Keep settings in `chrome.storage.local` and make the popup a small controller over the same schema.
**Tech Stack:** Chrome Extensions Manifest V3, plain JavaScript, HTML/CSS, Node.js, jsdom 29.
## Global Constraints
- Detect Persian text only; Arabic-only and Hebrew text do not trigger changes.
- Never rewrite text, punctuation, or Latin digits.
- Any author-specified font family counts as styled, including generic and inherited families.
- Work automatically on all supported websites and optimize for streaming AI chats without site-specific selectors.
- All mutations must be reversible without a reload.
- This folder is not a Git repository, so verification checkpoints replace commit steps.
---
### Task 1: Real UTF-8 Detection and Direction Contract
**Files:**
- Create: `test/content-engine.js`
- Modify: `src/content.js`
- Remove after replacement: `test/integration-v2.js`, `test/regex-test.js`
**Interfaces:**
- Consumes: the content script's automatic bootstrap with a mocked `chrome` API.
- Produces: Persian detection based on Persian-specific letters; conservative `rtl`/`auto` decisions and byte-identical text.
- [ ] **Step 1: Add failing fixtures** for `این متن فارسی است`, Arabic-only `هذا نص عربي`, mixed English/Persian text, and `نسخه 1234567890`.
- [ ] **Step 2: Run** `node test/content-engine.js`; expect failures for Persian detection and Arabic-only isolation against the corrupted current range.
- [ ] **Step 3: Replace the range** with explicit Unicode escapes for Persian-specific letters plus a broader Arabic-script letter count used only after a Persian trigger. Classify first-strong Persian blocks as `rtl`, English-leading mixed blocks as `auto`, and preserve the original text exactly.
- [ ] **Step 4: Run** `node test/content-engine.js`; expect all direction and integrity assertions to pass.
### Task 2: Conservative Targets and Author-Font Detection
**Files:**
- Modify: `test/content-engine.js`
- Modify: `src/content.js`
**Interfaces:**
- Consumes: `processTextNode(node, votedBlocks)` and reversible element markers.
- Produces: semantic block targeting, explicit-direction preservation, and `hasAuthorFont(element): boolean` behavior.
- [ ] **Step 1: Add failing cases** for existing `dir="rtl"`, sensible inherited direction, inline Arial, stylesheet `font-family: sans-serif`, inherited body font, and a document with no author font.
- [ ] **Step 2: Run** `node test/content-engine.js`; expect the current unconditional Vazirmatn behavior to fail the author-font cases.
- [ ] **Step 3: Implement author-font inspection** by walking the element/ancestor chain and checking matching author stylesheet declarations plus inline declarations. Apply Vazirmatn only when no declaration exists; isolate inaccessible stylesheet errors.
- [ ] **Step 4: Refine target selection** so paragraph/list/heading/message-sized containers win over broad sections, while code, SVG, MathML, and editable content remain protected.
- [ ] **Step 5: Run** `node test/content-engine.js`; expect all font and target cases to pass.
### Task 3: Streaming, Inputs, Frames, and Reversible Lifecycle
**Files:**
- Modify: `test/content-engine.js`
- Modify: `test/edge-cases.js`
- Modify: `src/content.js`
- Modify: `manifest.json`
**Interfaces:**
- Consumes: queued text nodes/roots and storage change callbacks.
- Produces: one scheduled processing pass per burst, reversible teardown/reapply, and related-frame coverage through Manifest V3 fields.
- [ ] **Step 1: Add asynchronous tests** that append a streaming Persian response in several character-data mutations, toggle settings off/on, and verify text identity plus restored `dir`/font state.
- [ ] **Step 2: Run** `node test/content-engine.js` and `node test/edge-cases.js`; expect streaming/lifecycle failures where stale direction or styling remains.
- [ ] **Step 3: Coalesce work by block** and re-evaluate a previously processed block when streamed text changes its classification. Clear queues and observers during teardown.
- [ ] **Step 4: Keep inputs at `dir="auto"`** without rewriting their values, and apply the unstyled-font policy consistently.
- [ ] **Step 5: Add `match_origin_as_fallback: true`** beside `all_frames` for related `about:`, `data:`, `blob:`, and `filesystem:` frames.
- [ ] **Step 6: Run both tests**; expect zero failures and no unhandled jsdom errors.
### Task 4: Accessible Popup and Settings Migration
**Files:**
- Create: `test/popup.js`
- Modify: `popup/popup.html`
- Modify: `popup/popup.css`
- Modify: `popup/popup.js`
**Interfaces:**
- Consumes: settings `{ enabled, fixDirection, useFont, fixInputs, disabledSites }` with legacy values safely defaulted.
- Produces: current-site status, master/site/direction/font/input controls, and safe unsupported-page/error states.
- [ ] **Step 1: Add popup controller tests** with mocked tabs/storage for a normal HTTPS tab, `chrome://extensions`, storage failure, dependency disabling, and setting persistence.
- [ ] **Step 2: Run** `node test/popup.js`; expect failure because current markup lacks status and `fixDirection`.
- [ ] **Step 3: Update markup and copy** to expose the approved five controls and a live status region. Use native checkbox semantics and explicit labels.
- [ ] **Step 4: Update styles** with visible keyboard focus, sufficient contrast, disabled states, compact spacing, and status icons/text that do not rely on color.
- [ ] **Step 5: Update controller** to use `chrome.runtime.lastError`, tolerate missing tab URLs, disable site controls for unsupported pages, and migrate the old settings shape without data loss.
- [ ] **Step 6: Run** `node test/popup.js`; expect all controller and accessibility assertions to pass.
### Task 5: Demo, Documentation, and Complete Verification
**Files:**
- Modify: `demo.html`
- Modify: `README.md`
- Modify: `package.json`
- Modify: `manifest.json`
**Interfaces:**
- Consumes: the final behavior and test commands.
- Produces: correctly encoded manual fixtures, accurate installation/behavior documentation, and a single test entry point.
- [ ] **Step 1: Replace mojibake** in user-visible metadata, popup copy, README, demo, test labels, and Persian fixtures with valid UTF-8.
- [ ] **Step 2: Add a streaming-chat demo** that appends Persian text incrementally without converting its Latin digits.
- [ ] **Step 3: Add package scripts**: `"test": "node test/content-engine.js && node test/edge-cases.js && node test/popup.js"` and `"check:encoding": "node test/encoding.js"`; add an encoding test that rejects known mojibake markers and requires real Persian fixtures.
- [ ] **Step 4: Run** `npm test`; expect all suites to pass.
- [ ] **Step 5: Run** `npm run check:encoding`; expect `Encoding check passed`.
- [ ] **Step 6: Parse `manifest.json`** with Node and inspect the popup/demo in a Chromium browser if available; record any browser-only validation still required.
## Self-Review
- Spec coverage: direction, Persian-only scope, digit preservation, font policy, streaming, popup, reversibility, errors, demo, and tests are each assigned above.
- Placeholder scan: no unresolved implementation placeholders remain.
- Interface consistency: the shared settings keys are `enabled`, `fixDirection`, `useFont`, `fixInputs`, and `disabledSites` in content, popup, and tests.
@@ -0,0 +1,93 @@
# Persian RTL Reliability Improvement Design
## Goal
Improve the Manifest V3 extension so it reliably fixes Persian text direction on dynamic AI chat sites and ordinary websites without rewriting text, changing digits, or disturbing intentionally styled content.
## Scope
- Detect Persian text only. Arabic-only and Hebrew text do not trigger changes.
- Never change text content, punctuation, or `1234567890` digits.
- Work automatically on all supported websites and frames.
- Optimize mutation handling for streaming AI responses.
- Apply Vazirmatn only when Persian text has no author-specified font.
- Simplify and improve the popup, tests, documentation, and encoding integrity.
## Direction Policy
The extension uses an adaptive-conservative policy:
1. Identify Persian-specific letters and marks.
2. Inspect meaningful letters and the first strong script in the nearest suitable text block.
3. Preserve a sensible explicit `dir` supplied by the site.
4. Apply `dir="rtl"` only to clearly Persian blocks.
5. Apply `dir="auto"` to uncertain mixed-language blocks.
6. Avoid changing large layout containers when a smaller semantic text block is available.
Every attribute change records its original state and is reversible without reloading the page.
## Font Policy
- Any author-specified `font-family` counts as styled, including Arial, generic families, system UI fonts, inherited fonts, inline styles, and web fonts.
- Vazirmatn is applied only when no author rule on the element or its ancestors supplies a font family and the element is therefore using the browser default.
- English-only elements are untouched.
- Existing font weight and style are preserved through bundled regular, medium, and bold faces.
- Disabling the feature restores the original inline state.
The popup exposes one clear font option: **Use Vazirmatn for unstyled Persian text**.
## Architecture
The content script is split into focused internal modules or sections:
- settings and lifecycle
- Persian detection and direction classification
- author-font detection
- DOM target selection and reversible mutations
- input handling
- mutation batching and shadow-root discovery
Streaming mutations are coalesced by affected text container. The extension processes changed regions rather than rescanning the complete document for every token.
## Popup
The popup shows:
- the current hostname and active/inactive status
- Enable on this site
- Conservative Persian RTL correction
- Use Vazirmatn for unstyled Persian text
- Smart input direction
- Master switch
Dependent controls have accessible disabled states. Switches support keyboard focus, visible focus rings, and status text that does not rely only on color.
## Error Handling
- Unsupported/internal browser pages display an unavailable state instead of behaving like an empty hostname.
- Storage and tab-query failures fall back safely and expose a concise popup status.
- DOM and stylesheet access failures are isolated per element so one unusual page cannot stop processing.
- The content script remains idempotent and does not observe its own attribute writes.
## Verification
Automated tests cover:
- real UTF-8 Persian fixtures and an encoding-integrity assertion
- Persian-only, Arabic-only, English-only, and mixed text
- Latin digits inside Persian text remaining byte-identical
- explicit RTL/LTR/auto direction preservation and correction
- author fonts from inline, stylesheet, inherited, generic, and custom declarations
- browser-default text receiving Vazirmatn
- streaming text mutations and batched processing
- inputs, code blocks, SVG/MathML, open shadow roots, and reversible teardown
- per-site and master settings
The demo page uses correctly encoded Persian fixtures and includes streaming-chat scenarios.
## Non-goals
- Transliteration, translation, numeral conversion, or text rewriting
- Hebrew or Arabic-only correction
- Fragile site-specific selectors for individual AI products
- Closed shadow-root support, which browser extensions cannot access
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

+492
View File
@@ -0,0 +1,492 @@
/* ============================================================================
* Persian RTL Fixer — content script (Manifest V3)
*
* Approach (non-destructive — NEVER restructures the page's DOM):
* - Find block elements proven to contain Persian text.
* - Set the block's base direction with the `dir` attribute:
* · Persian-leading block -> dir="rtl" (right-aligned)
* · Mixed / English-leading block -> dir="auto" (English stays LTR;
* Persian words still flow RTL inline via the native Unicode bidi algo)
* - Apply Vazirmatn only to author-unstyled Persian text using an @font-face
* `unicode-range` limited to the Arabic blocks. The browser then renders
* Persian in Vazirmatn and leaves English in the site's own font — per
* character, automatically, with NO span wrapping.
*
* Why no wrapping? Wrapping text in <span>s corrupts React/Vue/Angular pages:
* the framework owns the DOM and re-renders against its virtual DOM, so injected
* nodes cause duplicated/mangled text. Setting attributes/inline-style on
* existing elements is safe and reversible; if the framework strips them, the
* MutationObserver simply re-applies.
*
* Loop-safety: the observer watches childList + characterData only (never
* attributes), so our own dir/style writes can't trigger a re-process loop.
* ========================================================================== */
(() => {
"use strict";
if (window.__RTLX_LOADED__) return;
window.__RTLX_LOADED__ = true;
// --- Character classes ----------------------------------------------------
// Persian-only trigger characters. Shared Arabic letters are considered only
// after one of these characters proves the text is Persian.
const PERSIAN_RE = /[\u067E\u0686\u0698\u06A9\u06AF\u06CC\u06C0\u06C1\u06BE\u06D2\u06D3]/;
const ARABIC_SCRIPT_RE = /[\u0621-\u063A\u0641-\u064A\u066E-\u06D3]/;
const LTR_RE = /[A-Za-zÀ-ʯͰ-ϿЀ-ӿ]/;
const SKIP_TAGS = new Set([
"SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "CODE", "PRE", "KBD", "SAMP",
"XMP", "TT", "OPTION", "SELECT", "TITLE"
]);
const SKIP_SELECTOR =
"script,style,noscript,textarea,code,pre,kbd,samp,xmp,tt,option,select";
const INPUT_SELECTOR = "input,textarea,[contenteditable=''],[contenteditable='true']";
const SKIP_INPUT_TYPES = new Set([
"checkbox", "radio", "range", "color", "date", "datetime-local", "month",
"week", "time", "file", "number", "button", "submit", "reset", "image",
"hidden", "password"
]);
const XHTML = "http://www.w3.org/1999/xhtml";
// Block-level elements that control line flow / text-align.
// On SPAs text is often inside nested <span>s — we walk UP to the nearest
// block element so direction + text-align actually take effect.
const BLOCK_TAGS_SET = new Set([
"ADDRESS","ARTICLE","ASIDE","BLOCKQUOTE","CAPTION","DD","DETAILS","DIALOG",
"DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM",
"H1","H2","H3","H4","H5","H6","HEADER","HGROUP","LI","MAIN","NAV",
"OL","P","SECTION","SUMMARY","TABLE","TD","TH","TR","UL"
]);
const DEFAULTS = {
enabled: true, // master switch
fixDirection: true, // conservatively correct Persian direction
useFont: true, // use Vazirmatn for author-unstyled Persian text
fixInputs: true, // give inputs/textareas dir="auto" + Vazirmatn
disabledSites: [] // hostnames where the extension is off
};
let settings = Object.assign({}, DEFAULTS);
let active = false;
let booted = false;
let observer = null;
const observedShadowRoots = new Set();
const shadowObservers = [];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function inSkip(el) {
if (!el || !el.closest) return true;
if (el.namespaceURI && el.namespaceURI !== XHTML) return true; // skip SVG/MathML
if (el.isContentEditable) return true;
return !!el.closest(SKIP_SELECTOR);
}
// The first strong script decides mixed content. A Persian-specific character
// must occur somewhere, otherwise Arabic-only text is deliberately ignored.
function dirForText(text) {
if (!PERSIAN_RE.test(text)) return null;
for (const char of text) {
if (LTR_RE.test(char)) return "ltr";
if (ARABIC_SCRIPT_RE.test(char)) return "rtl";
}
return "rtl";
}
// Walk up to the nearest block-level ancestor (stops before <body>) so the
// direction/alignment lands on an element that actually controls line flow.
function nearestBlock(el) {
let node = el;
while (node && node !== document.body && node !== document.documentElement) {
if (node.nodeType === 1 && BLOCK_TAGS_SET.has(node.tagName)) return node;
node = node.parentElement;
}
return el;
}
function setDir(el, value) {
// Do not claim ownership of a direction the site already set sensibly.
const existing = el.getAttribute("dir");
if (!el.__rtlxSetDir && (existing === value || existing === "auto")) return;
if (!el.__rtlxSetDir) {
el.__rtlxSetDir = true;
el.setAttribute("data-rtlx-setdir", "1");
el.setAttribute("data-rtlx-dir", el.hasAttribute("dir") ? el.getAttribute("dir") : "");
}
if (el.getAttribute("dir") !== value) el.setAttribute("dir", value);
// Only forced-RTL blocks get the alignment hook; "auto" blocks must NOT be
// right-aligned (that's the over-aggressive behavior we're avoiding).
if (value === "rtl") el.setAttribute("data-rtlx-rtl", "1");
else el.removeAttribute("data-rtlx-rtl");
}
function hasAuthorFont(el) {
let node = el;
while (node && node.nodeType === 1) {
if (node.style && node.style.getPropertyValue("font-family").trim()) return true;
for (let i = 0; i < document.styleSheets.length; i++) {
let rules;
try { rules = document.styleSheets[i].cssRules; } catch (e) { continue; }
if (rulesSetFont(node, rules)) return true;
}
node = node.parentElement;
}
return false;
}
function rulesSetFont(el, rules) {
if (!rules) return false;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (rule.selectorText && rule.style && rule.style.getPropertyValue("font-family").trim()) {
try { if (el.matches(rule.selectorText)) return true; } catch (e) {}
}
let nested;
try { nested = rule.cssRules; } catch (e) { nested = null; }
if (nested && rulesSetFont(el, nested)) return true;
}
return false;
}
// Apply Vazirmatn only when neither the element nor an ancestor has an
// author-provided inline font. Stylesheet rules are handled below as part of
// the same author-font policy.
function applyFont(el) {
if (!settings.useFont || !el || el.nodeType !== 1 || el.__rtlxFont) return;
if (hasAuthorFont(el)) return;
el.__rtlxFont = true;
el.setAttribute("data-rtlx-font", "1");
el.setAttribute("data-rtlx-ff", el.style.fontFamily || "");
el.style.fontFamily = "'Vazirmatn'";
}
function restoreDirection(el) {
if (!el || !el.hasAttribute("data-rtlx-setdir")) return;
const original = el.getAttribute("data-rtlx-dir");
if (original) el.setAttribute("dir", original); else el.removeAttribute("dir");
el.removeAttribute("data-rtlx-setdir");
el.removeAttribute("data-rtlx-dir");
el.removeAttribute("data-rtlx-rtl");
el.__rtlxSetDir = false;
}
function restoreFont(el) {
if (!el || !el.hasAttribute("data-rtlx-font")) return;
const original = el.getAttribute("data-rtlx-ff");
if (original) el.style.fontFamily = original;
else el.style.removeProperty("font-family");
el.removeAttribute("data-rtlx-font");
el.removeAttribute("data-rtlx-ff");
el.__rtlxFont = false;
}
// ---------------------------------------------------------------------------
// Inputs / textareas / contenteditables
// ---------------------------------------------------------------------------
function applyInputs(root) {
if (!settings.fixInputs) return;
const handle = (el) => {
if (!el) return;
if (el.tagName === "INPUT") {
const t = (el.getAttribute("type") || "text").toLowerCase();
if (SKIP_INPUT_TYPES.has(t)) return;
}
if (!el.__rtlxInput) {
el.__rtlxInput = true;
el.setAttribute("data-rtlx-input", "1");
if (!el.hasAttribute("dir")) {
if (!el.__rtlxSetDir) {
el.__rtlxSetDir = true;
el.setAttribute("data-rtlx-setdir", "1");
el.setAttribute("data-rtlx-dir", "");
}
el.setAttribute("dir", "auto");
}
if (!el.__rtlxInputHandler) {
el.__rtlxInputHandler = () => updateInputFont(el);
el.addEventListener("input", el.__rtlxInputHandler);
}
}
updateInputFont(el);
};
if (root.matches && root.matches(INPUT_SELECTOR)) handle(root);
if (root.querySelectorAll) {
let list;
try { list = root.querySelectorAll(INPUT_SELECTOR); } catch (e) { return; }
for (let i = 0; i < list.length; i++) handle(list[i]);
}
}
function updateInputFont(el) {
if (!active || !settings.fixInputs) return;
const sample = (el.value || "") + " " + (el.getAttribute("placeholder") || "");
if (PERSIAN_RE.test(sample)) applyFont(el);
else restoreFont(el);
}
// ---------------------------------------------------------------------------
// Core: tag a Persian text node's block (direction) + element (font)
// `votedBlocks` dedupes the (costly) direction vote within a single pass.
// ---------------------------------------------------------------------------
function processTextNode(node, votedBlocks) {
const parent = node.parentElement;
if (!parent || SKIP_TAGS.has(parent.tagName) || inSkip(parent)) return;
if (!PERSIAN_RE.test(node.nodeValue || "")) {
const oldBlock = nearestBlock(parent);
if (!PERSIAN_RE.test(oldBlock.textContent || "")) restoreDirection(oldBlock);
if (!PERSIAN_RE.test(parent.textContent || "")) restoreFont(parent);
return;
}
// Font goes on the immediate parent so Persian inside styled spans is also
// covered; unicode-range keeps English in that element untouched.
applyFont(parent);
// Direction goes on the nearest block, voted by ALL of the block's text.
const block = nearestBlock(parent);
if (inSkip(block)) return;
if (votedBlocks) {
if (votedBlocks.has(block)) return;
votedBlocks.add(block);
}
let text = "";
try { text = block.textContent || ""; } catch (e) {}
if (settings.fixDirection) {
const d = dirForText(text);
if (d === "rtl") setDir(block, "rtl");
else if (d === "ltr") setDir(block, "auto");
}
}
// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------
function scan(root) {
if (!root) return;
let walker;
try {
walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
const v = n.nodeValue;
if (!v || !PERSIAN_RE.test(v)) return NodeFilter.FILTER_SKIP;
const p = n.parentElement;
if (!p || SKIP_TAGS.has(p.tagName) || inSkip(p)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
} catch (e) { return; }
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
const votedBlocks = new Set();
for (let i = 0; i < nodes.length; i++) {
try { processTextNode(nodes[i], votedBlocks); } catch (e) {}
}
applyInputs(root);
}
function scanShadow(root) {
let w;
try { w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null); }
catch (e) { return; }
let el;
while ((el = w.nextNode())) {
const sr = el.shadowRoot;
if (sr) { observeShadow(sr); scan(sr); scanShadow(sr); }
}
}
// ---------------------------------------------------------------------------
// Real-time updates
// ---------------------------------------------------------------------------
let pendingNodes = new Set();
let pendingRoots = new Set();
let scheduled = false;
function schedule() {
if (scheduled) return;
scheduled = true;
const run = () => { scheduled = false; flush(); };
if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 500 });
else if (window.requestAnimationFrame) requestAnimationFrame(run);
else setTimeout(run, 16);
}
function flush() {
if (!active) { pendingNodes.clear(); pendingRoots.clear(); return; }
const nodes = pendingNodes; pendingNodes = new Set();
const roots = pendingRoots; pendingRoots = new Set();
const votedBlocks = new Set();
nodes.forEach((node) => {
try { if (node.isConnected) processTextNode(node, votedBlocks); } catch (e) {}
});
roots.forEach((r) => {
try {
if (!r.isConnected) return;
scan(r);
if (r.shadowRoot) { observeShadow(r.shadowRoot); scan(r.shadowRoot); }
scanShadow(r);
} catch (e) {}
});
}
function onMutations(muts) {
if (!active) return;
for (let i = 0; i < muts.length; i++) {
const m = muts[i];
if (m.type === "characterData") {
const node = m.target;
if (node.parentElement) pendingNodes.add(node);
} else { // childList
const added = m.addedNodes;
for (let j = 0; j < added.length; j++) {
const node = added[j];
if (node.nodeType === 3) {
if (node.parentElement) pendingNodes.add(node);
} else if (node.nodeType === 1) {
pendingRoots.add(node);
}
}
}
}
if (pendingNodes.size || pendingRoots.size) schedule();
}
function observeShadow(sr) {
if (observedShadowRoots.has(sr)) return;
observedShadowRoots.add(sr);
try {
const mo = new MutationObserver(onMutations);
mo.observe(sr, { childList: true, subtree: true, characterData: true });
shadowObservers.push(mo);
} catch (e) {}
}
// ---------------------------------------------------------------------------
// Vazirmatn @font-face — unicode-range covers the glyphs Persian requires,
// so it never touches Latin text. Loaded from chrome-extension:// (bypasses
// page CSP font-src).
// ---------------------------------------------------------------------------
function injectFont() {
if (!settings.useFont) return;
if (document.getElementById("rtlx-font-face")) return;
const url = (p) => chrome.runtime.getURL(p);
// Arabic, Arabic Supplement, Arabic Extended-A, Presentation Forms A & B,
// plus ZWNJ/ZWJ used inside Persian words.
const range =
"U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF,U+200C-200D";
const face = (w, file) =>
"@font-face{font-family:'Vazirmatn';font-style:normal;font-weight:" + w +
";font-display:swap;unicode-range:" + range +
";src:url('" + url(file) + "') format('woff2');}";
const css =
face(400, "fonts/Vazirmatn-Regular.woff2") +
face(500, "fonts/Vazirmatn-Medium.woff2") +
face(700, "fonts/Vazirmatn-Bold.woff2") +
// Force right-alignment on forced-RTL blocks, beating site text-align:left.
// Only applies to data-rtlx-rtl (Persian-dominant), never to "auto" blocks.
"[data-rtlx-rtl='1']{direction:rtl!important;text-align:right!important;}";
const style = document.createElement("style");
style.id = "rtlx-font-face";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
function removeFont() {
const s = document.getElementById("rtlx-font-face");
if (s) s.remove();
}
// ---------------------------------------------------------------------------
// Undo (clean restore without reload)
// ---------------------------------------------------------------------------
function restoreEl(el) {
restoreDirection(el);
restoreFont(el);
el.removeAttribute("data-rtlx-input");
el.__rtlxInput = false;
}
function undoIn(root) {
try {
root.querySelectorAll("[data-rtlx-setdir],[data-rtlx-font],[data-rtlx-input]")
.forEach(restoreEl);
} catch (e) {}
}
function undoAll() {
undoIn(document);
observedShadowRoots.forEach((sr) => undoIn(sr));
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function fullScan() {
scan(document.body || document);
scanShadow(document.documentElement || document);
}
function boot() {
if (booted) return;
booted = true;
injectFont();
observer = new MutationObserver(onMutations);
observer.observe(document.documentElement || document, {
childList: true, subtree: true, characterData: true
});
if (document.body) fullScan();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => { if (active) fullScan(); }, { once: true });
}
window.addEventListener("load", () => { if (active) fullScan(); }, { once: true });
}
function teardown() {
booted = false;
if (observer) { observer.disconnect(); observer = null; }
while (shadowObservers.length) { try { shadowObservers.pop().disconnect(); } catch (e) {} }
pendingNodes.clear();
pendingRoots.clear();
undoAll();
observedShadowRoots.clear();
removeFont();
}
function computeActive() {
return !!settings.enabled && settings.disabledSites.indexOf(location.hostname) === -1;
}
function reapply() {
if (observer) { observer.disconnect(); observer = null; }
while (shadowObservers.length) { try { shadowObservers.pop().disconnect(); } catch (e) {} }
undoAll();
observedShadowRoots.clear();
removeFont();
booted = false;
boot();
}
try {
chrome.storage.local.get(DEFAULTS, (s) => {
if (chrome.runtime.lastError) s = {};
settings = Object.assign({}, DEFAULTS, s);
active = computeActive();
if (active) boot();
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
let touched = false;
for (const k in changes) {
if (k in DEFAULTS) { settings[k] = changes[k].newValue; touched = true; }
}
if (!touched) return;
const now = computeActive();
if (now && !active) { active = true; boot(); }
else if (!now && active) { active = false; teardown(); }
else if (now && active) { reapply(); }
});
} catch (e) { /* chrome.* unavailable */ }
})();
+192
View File
@@ -0,0 +1,192 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { JSDOM } = require("jsdom");
const source = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8");
function createExtensionDom(body, overrides = {}) {
const settings = {
enabled: true,
fixDirection: true,
useFont: true,
fixInputs: true,
disabledSites: [],
...overrides
};
let onChanged;
const dom = new JSDOM(`<!doctype html><html><head></head><body>${body}</body></html>`, {
url: "https://chat.example/",
runScripts: "dangerously",
pretendToBeVisual: true
});
dom.window.requestIdleCallback = (callback) => callback({ didTimeout: false, timeRemaining: () => 50 });
dom.window.chrome = {
runtime: {
getURL: (file) => `chrome-extension://test/${file}`,
lastError: null
},
storage: {
local: { get: (_defaults, callback) => callback(settings) },
onChanged: { addListener: (callback) => { onChanged = callback; } }
}
};
const script = dom.window.document.createElement("script");
script.textContent = source;
dom.window.document.head.appendChild(script);
return {
dom,
changeSettings(patch) {
for (const [key, value] of Object.entries(patch)) settings[key] = value;
const changes = Object.fromEntries(Object.entries(patch).map(([key, value]) => [key, { newValue: value }]));
onChanged(changes, "local");
}
};
}
function element(body, id = "target", overrides) {
const fixture = createExtensionDom(body, overrides);
return { fixture, target: fixture.dom.window.document.getElementById(id) };
}
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
function nextTurn() { return new Promise((resolve) => setTimeout(resolve, 0)); }
test("real Persian text is corrected to RTL", () => {
const { target } = element('<p id="target">این یک متن فارسی است</p>');
assert.equal(target.getAttribute("dir"), "rtl");
});
test("Arabic-only text is not treated as Persian", () => {
const { target } = element('<p id="target">هذا نص عربي للاختبار</p>');
assert.equal(target.hasAttribute("dir"), false);
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
test("English-leading Persian text uses auto direction", () => {
const { target } = element('<p id="target">The answer is متن فارسی برای آزمایش.</p>');
assert.equal(target.getAttribute("dir"), "auto");
assert.equal(target.hasAttribute("data-rtlx-rtl"), false);
});
test("Latin digits inside Persian text remain byte-identical", () => {
const text = "نسخه 1234567890 برای آزمایش";
const { target } = element(`<p id="target">${text}</p>`);
assert.equal(target.textContent, text);
});
test("a sensible explicit RTL direction is preserved without ownership markers", () => {
const { target } = element('<p id="target" dir="rtl">این متن از قبل راست‌چین است</p>');
assert.equal(target.getAttribute("dir"), "rtl");
assert.equal(target.hasAttribute("data-rtlx-setdir"), false);
});
test("a sensible explicit auto direction is preserved without ownership markers", () => {
const { target } = element('<p id="target" dir="auto">این متن جهت خودکار دارد</p>');
assert.equal(target.getAttribute("dir"), "auto");
assert.equal(target.hasAttribute("data-rtlx-setdir"), false);
});
test("a clearly wrong explicit LTR direction is corrected reversibly", () => {
const { target } = element('<p id="target" dir="ltr">این متن فارسی راست‌به‌چپ است</p>');
assert.equal(target.getAttribute("dir"), "rtl");
assert.equal(target.getAttribute("data-rtlx-dir"), "ltr");
});
test("an inline author font is never replaced", () => {
const { target } = element('<p id="target" style="font-family: Arial">این متن فونت دارد</p>');
assert.equal(target.style.fontFamily, "Arial");
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
test("an inherited author font is never replaced", () => {
const { target } = element('<div style="font-family: sans-serif"><p id="target">این متن فونت ارث‌بری دارد</p></div>');
assert.equal(target.style.fontFamily, "");
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
test("a stylesheet font on the Persian element is never replaced", () => {
const { target } = element('<style>.authored { font-family: system-ui; }</style><p id="target" class="authored">این متن فونت دارد</p>');
assert.equal(target.style.fontFamily, "");
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
test("an inherited stylesheet font is never replaced", () => {
const { target } = element('<style>.chat { font-family: Tahoma; }</style><article class="chat"><p id="target">این پاسخ هوش مصنوعی است</p></article>');
assert.equal(target.style.fontFamily, "");
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
test("unstyled Persian text receives Vazirmatn", () => {
const { target } = element('<p id="target">این متن بدون فونت است</p>');
assert.match(target.style.fontFamily, /Vazirmatn/i);
});
test("streamed Persian text is corrected and Latin digits are untouched", async () => {
const { fixture, target } = element('<p id="target">Waiting</p>');
target.textContent = "پاسخ زنده 1234567890";
await nextTurn();
assert.equal(target.getAttribute("dir"), "rtl");
assert.equal(target.textContent, "پاسخ زنده 1234567890");
fixture.dom.window.close();
});
test("a streamed block that becomes English is restored", async () => {
const { fixture, target } = element('<p id="target">پاسخ فارسی</p>');
assert.equal(target.getAttribute("dir"), "rtl");
target.textContent = "The final answer is English.";
await nextTurn();
assert.equal(target.hasAttribute("dir"), false);
assert.equal(target.hasAttribute("data-rtlx-rtl"), false);
assert.equal(target.style.fontFamily, "");
fixture.dom.window.close();
});
test("disabling the extension restores owned direction and font", () => {
const { fixture, target } = element('<p id="target">پاسخ فارسی</p>');
fixture.changeSettings({ enabled: false });
assert.equal(target.hasAttribute("dir"), false);
assert.equal(target.style.fontFamily, "");
});
test("unstyled inputs receive Vazirmatn only after Persian is present", () => {
const { fixture, target } = element('<input id="target" type="text" />');
assert.equal(target.style.fontFamily, "");
target.value = "متن فارسی";
target.dispatchEvent(new fixture.dom.window.Event("input", { bubbles: true }));
assert.match(target.style.fontFamily, /Vazirmatn/i);
});
test("author-styled Persian inputs preserve their font", () => {
const { target } = element('<input id="target" type="text" style="font-family: Arial" value="متن فارسی" />');
assert.equal(target.style.fontFamily, "Arial");
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
test("input listeners do not reapply styling while the extension is disabled", () => {
const { fixture, target } = element('<input id="target" type="text" />');
fixture.changeSettings({ enabled: false });
target.value = "متن فارسی";
target.dispatchEvent(new fixture.dom.window.Event("input", { bubbles: true }));
assert.equal(target.style.fontFamily, "");
assert.equal(target.hasAttribute("data-rtlx-font"), false);
});
(async () => {
let passed = 0;
for (const { name, fn } of tests) {
try {
await fn();
passed += 1;
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error.message);
}
}
console.log(`\n${passed}/${tests.length} passed`);
process.exitCode = passed === tests.length ? 0 : 1;
})();
+116
View File
@@ -0,0 +1,116 @@
// Edge cases: math, special chars, Markdown HTML — non-destructive model.
"use strict";
const { JSDOM } = require("jsdom");
const fs = require("fs");
const path = require("path");
const src = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8");
let passed = 0, failed = 0;
function assert(label, cond, got) {
if (cond) { console.log("PASS " + label); passed++; }
else { console.log("FAIL " + label + (got !== undefined ? " — got: " + JSON.stringify(String(got).slice(0, 80)) : "")); failed++; }
}
function makeDOM(bodyHTML, settings) {
settings = Object.assign({ enabled: true, fixDirection: true, useFont: true, fixInputs: true, disabledSites: [] }, settings || {});
const dom = new JSDOM("<!DOCTYPE html><html><body>" + bodyHTML + "</body></html>",
{ url: "https://example.com/", runScripts: "dangerously" });
const win = dom.window;
win.chrome = {
runtime: { getURL: (p) => "chrome-extension://test/" + p, lastError: null },
storage: { local: { get: (d, cb) => cb(settings), set: () => {} }, onChanged: { addListener: () => {} } }
};
const s = win.document.createElement("script");
s.textContent = src;
win.document.head.appendChild(s);
return dom;
}
console.log("=== Math Characters ===");
{
const dom = makeDOM("<p id='t'>The formula is ∑(n=0..∞) = π × r² ± ε</p>");
const p = dom.window.document.getElementById("t");
assert("math-only: no dir", !p.hasAttribute("dir"), p.getAttribute("dir"));
assert("math-only: no font change", !p.style.fontFamily);
assert("math-only: text unchanged", p.textContent === "The formula is ∑(n=0..∞) = π × r² ± ε");
}
{
const dom = makeDOM("<p id='t'>مساحت دایره برابر است با π × r²</p>");
const p = dom.window.document.getElementById("t");
assert("persian+math: dir=rtl", p.getAttribute("dir") === "rtl", p.getAttribute("dir"));
assert("persian+math: Vazirmatn applied", p.style.fontFamily.toLowerCase().includes("vazirmatn"));
assert("persian+math: π preserved in text", p.textContent.includes("π"));
}
console.log("\n=== Special Characters ===");
{
const dom = makeDOM("<p id='t'>Copyright © 2024 → All rights reserved ™</p>");
const p = dom.window.document.getElementById("t");
assert("special-only: no dir", !p.hasAttribute("dir"));
assert("special-only: no font change", !p.style.fontFamily);
}
{
const dom = makeDOM("<p id='t'>محصول جدید ما → قیمت: ۱۲۰ تومان ©</p>");
const p = dom.window.document.getElementById("t");
assert("persian+special: dir=rtl", p.getAttribute("dir") === "rtl", p.getAttribute("dir"));
assert("persian+special: © and → preserved", p.textContent.includes("©") && p.textContent.includes("→"));
}
console.log("\n=== ZWNJ (Persian word joiner) ===");
{
const ZWNJ = "‌";
const dom = makeDOM("<p id='t'>می" + ZWNJ + "خواهم برگردم</p>");
const p = dom.window.document.getElementById("t");
// No wrapping → ZWNJ can never split a word now. Text is byte-identical.
assert("ZWNJ: text byte-identical (no splitting possible)", p.textContent === "می" + ZWNJ + "خواهم برگردم");
assert("ZWNJ: dir=rtl", p.getAttribute("dir") === "rtl");
}
console.log("\n=== Markdown-rendered HTML ===");
{
const dom = makeDOM("<pre><code id='t'>const سلام = 'hello'; // فارسی</code></pre>");
const code = dom.window.document.getElementById("t");
assert("MD code block: no dir", !code.hasAttribute("dir"));
assert("MD code block: no font change", !code.style.fontFamily);
}
{
const dom = makeDOM("<p id='t'>Use <code id='c'>تابع()</code> to call it. این متن فارسی است.</p>");
const p = dom.window.document.getElementById("t");
const code = dom.window.document.getElementById("c");
assert("inline code: code element no font change", !code.style.fontFamily);
assert("inline code: English-leading mixed block dir=auto", p.getAttribute("dir") === "auto", p.getAttribute("dir"));
}
{
const dom = makeDOM("<p><strong id='t'>متن فارسی پررنگ</strong> and English text here</p>");
const strong = dom.window.document.getElementById("t");
const p = strong.parentElement;
assert("MD bold: font applied to <strong> (text's parent)", strong.style.fontFamily.toLowerCase().includes("vazirmatn"), strong.style.fontFamily);
assert("MD bold: dir on block <p>", p.hasAttribute("dir"), p.getAttribute("dir"));
}
{
const dom = makeDOM("<h1 id='t'>راهنمای استفاده</h1>");
const h1 = dom.window.document.getElementById("t");
assert("MD heading: dir=rtl", h1.getAttribute("dir") === "rtl", h1.getAttribute("dir"));
assert("MD heading: Vazirmatn applied", h1.style.fontFamily.toLowerCase().includes("vazirmatn"));
}
{
const dom = makeDOM("<blockquote><p id='t'>این یک نقل‌قول فارسی است</p></blockquote>");
const p = dom.window.document.getElementById("t");
assert("MD blockquote: dir=rtl", p.getAttribute("dir") === "rtl", p.getAttribute("dir"));
}
{
const dom = makeDOM('<svg xmlns="http://www.w3.org/2000/svg"><text id="t">متن فارسی</text></svg>');
const svgText = dom.window.document.getElementById("t");
if (svgText) assert("SVG text: no dir (skipped)", !svgText.hasAttribute("dir"));
else assert("SVG element found", false, "null");
}
{
const dom = makeDOM('<math xmlns="http://www.w3.org/1998/Math/MathML"><mi id="t">متغیر</mi></math>');
const mi = dom.window.document.getElementById("t");
if (mi) assert("MathML mi: no dir (skipped)", !mi.hasAttribute("dir"));
else assert("MathML element found (jsdom may lack support)", true);
}
console.log("\n" + passed + "/" + (passed + failed) + " passed" + (failed ? " — " + failed + " FAILED" : ""));
process.exit(failed ? 1 : 0);
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.join(__dirname, "..");
const files = [
"manifest.json",
"README.md",
"demo.html",
"src/content.js",
"popup/popup.html",
"popup/popup.js",
"test/content-engine.js",
"test/edge-cases.js"
];
const mojibakeMarkers = ["\u00D8\u00A7", "\u00D9\u0085", "\u00E2\u20AC", "\u00C3\u0097"];
for (const file of files) {
const text = fs.readFileSync(path.join(root, file), "utf8");
for (const marker of mojibakeMarkers) {
assert.equal(text.includes(marker), false, `${file} contains a mojibake marker`);
}
}
const fixtures = fs.readFileSync(path.join(root, "test/content-engine.js"), "utf8");
assert.match(fixtures, /[پچژگکی]/, "tests must contain real Persian-specific characters");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
assert.match(manifest.name, /Persian/);
assert.doesNotMatch(manifest.name, /Arabic/);
assert.equal(manifest.content_scripts[0].match_origin_as_fallback, true);
console.log("Encoding and manifest checks passed");
+102
View File
@@ -0,0 +1,102 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { JSDOM } = require("jsdom");
const html = fs.readFileSync(path.join(__dirname, "../popup/popup.html"), "utf8")
.replace('<script src="popup.js"></script>', "");
const source = fs.readFileSync(path.join(__dirname, "../popup/popup.js"), "utf8");
function tick() { return new Promise((resolve) => setTimeout(resolve, 0)); }
async function openPopup({ url = "https://chatgpt.com/c/123", settings = {}, storageError = null } = {}) {
const writes = [];
const dom = new JSDOM(html, { url: "chrome-extension://test/popup/popup.html", runScripts: "dangerously" });
const runtime = { lastError: null };
dom.window.chrome = {
runtime,
storage: {
local: {
get(defaults, callback) {
runtime.lastError = storageError ? { message: storageError } : null;
callback({ ...defaults, ...settings });
runtime.lastError = null;
},
set(patch, callback) {
writes.push(patch);
if (callback) callback();
}
},
onChanged: { addListener() {} }
},
tabs: {
query(_query, callback) { callback([{ url }]); }
}
};
const script = dom.window.document.createElement("script");
script.textContent = source;
dom.window.document.body.appendChild(script);
await tick();
return { dom, document: dom.window.document, writes };
}
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
test("shows the current host and an active text status", async () => {
const { document } = await openPopup();
assert.equal(document.getElementById("host").textContent, "chatgpt.com");
assert.match(document.getElementById("siteStatus").textContent, /active/i);
assert.equal(document.getElementById("siteStatus").getAttribute("role"), "status");
});
test("exposes the approved settings and persists direction changes", async () => {
const { dom, document, writes } = await openPopup();
for (const id of ["enabled", "thisSite", "fixDirection", "useFont", "fixInputs"]) {
assert.ok(document.getElementById(id), `missing #${id}`);
}
const direction = document.getElementById("fixDirection");
direction.checked = false;
direction.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
assert.equal(JSON.stringify(writes.at(-1)), JSON.stringify({ fixDirection: false }));
});
test("describes unsupported browser pages and disables the site switch", async () => {
const { document } = await openPopup({ url: "chrome://extensions" });
assert.equal(document.getElementById("host").textContent, "Unavailable");
assert.match(document.getElementById("siteStatus").textContent, /cannot be modified/i);
assert.equal(document.getElementById("thisSite").disabled, true);
});
test("a disabled master switch clearly disables dependent controls", async () => {
const { document } = await openPopup({ settings: { enabled: false } });
assert.equal(document.getElementById("thisSite").disabled, true);
assert.equal(document.getElementById("fixDirection").disabled, true);
assert.equal(document.getElementById("useFont").disabled, true);
assert.equal(document.getElementById("fixInputs").disabled, true);
assert.match(document.getElementById("siteStatus").textContent, /paused/i);
});
test("storage failures produce a safe error state", async () => {
const { document } = await openPopup({ storageError: "Storage unavailable" });
assert.match(document.getElementById("siteStatus").textContent, /settings unavailable/i);
assert.equal(document.getElementById("thisSite").disabled, true);
});
(async () => {
let passed = 0;
for (const { name, fn } of tests) {
try {
await fn();
passed += 1;
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error.message);
}
}
console.log(`\n${passed}/${tests.length} passed`);
process.exitCode = passed === tests.length ? 0 : 1;
})();