Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce0619787 | ||
|
|
f38b39d60c | ||
|
|
2ee5cb30a9 | ||
|
|
6ebc4e7361 | ||
|
|
3c2aeb75cd | ||
|
|
5b14e6cf5d | ||
|
|
743bd9f1e8 | ||
|
|
11e152cc4b | ||
|
|
240937ff4d | ||
|
|
64d91a0d2e |
@@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 332 B |
Binary file not shown.
|
After Width: | Height: | Size: 643 B |
Binary file not shown.
|
After Width: | Height: | Size: 829 B |
+492
@@ -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 */ }
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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;
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user