Import source: src/content

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:07:29 +03:30
parent 1d2fbd36c6
commit 64d91a0d2e
+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 */ }
})();