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:
+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 */ }
|
||||
})();
|
||||
Reference in New Issue
Block a user