MediaWiki:Common.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* MediaWiki:Common.js */
(function () {
var LS_SCRIPT_KEY = 'grantha_reader_script';
var currentScript = 'deva';
function devanagariToTamil(text) {
var CONSONANTS = {
'क': 'க', 'ख': 'க', 'ग': 'க', 'घ': 'க', 'ङ': 'ங',
'च': 'ச', 'छ': 'ச', 'ज': 'ஜ', 'झ': 'ஜ', 'ञ': 'ஞ',
'ट': 'ட', 'ठ': 'ட', 'ड': 'ட', 'ढ': 'ட', 'ण': 'ண',
'त': 'த', 'थ': 'த', 'द': 'த', 'ध': 'த', 'न': 'ந',
'प': 'ப', 'फ': 'ப', 'ब': 'ப', 'भ': 'ப', 'म': 'ம',
'य': 'ய', 'र': 'ர', 'ल': 'ல', 'ळ': 'ள', 'व': 'வ',
'श': 'ஶ', 'ष': 'ஷ', 'स': 'ஸ', 'ह': 'ஹ'
};
var DIACRITICS = {
'ा': 'ா', 'ि': 'ி', 'ी': 'ீ', 'ु': 'ு', 'ू': 'ூ',
'े': 'ே', 'ै': 'ை', 'ो': 'ோ', 'ौ': 'ௌ'
};
var VOWELS = {
'अ': 'அ', 'आ': 'ஆ', 'इ': 'இ', 'ई': 'ஈ', 'उ': 'உ', 'ऊ': 'ஊ',
'ए': 'ஏ', 'ऐ': 'ஐ', 'ओ': 'ஓ', 'औ': 'ஔ', 'ऽ': 'ऽ'
};
var MISC = {
'ं':'ம்', 'ः':':', 'ँ':'ம்', 'ॐ':'ௐ',
'०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
'५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
};
var HALANTA = '्';
var chars = Array.from(text), result = '', i = 0;
while (i < chars.length) {
var ch = chars[i], next = chars[i + 1];
if (CONSONANTS[ch]) {
var base = CONSONANTS[ch];
if (next === HALANTA) { result += base + '்'; i += 2; }
else if (next === 'ृ') { result += base + '்ரி'; i += 2; }
else if (next === 'ॄ') { result += base + '்ரீ'; i += 2; }
else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
else { result += base; i++; } // inherent 'a'
}
else if (ch === 'ऋ') { result += 'ரி'; i++; }
else if (ch === 'ॠ') { result += 'ரீ'; i++; }
else if (VOWELS[ch]) { result += VOWELS[ch]; i++; }
else if (MISC[ch]) { result += MISC[ch]; i++; }
else { result += ch; i++; }
}
return result;
}
function devanagariToIAST(text) {
var CONSONANTS = {
'क': 'k', 'ख': 'kh', 'ग': 'g', 'घ': 'gh', 'ङ': 'ṅ',
'च': 'c', 'छ': 'ch', 'ज': 'j', 'झ': 'jh', 'ञ': 'ñ',
'ट': 'ṭ', 'ठ': 'ṭh', 'ड': 'ḍ', 'ढ': 'ḍh', 'ण': 'ṇ',
'त': 't', 'थ': 'th', 'द': 'd', 'ध': 'dh', 'न': 'n',
'प': 'p', 'फ': 'ph', 'ब': 'b', 'भ': 'bh', 'म': 'm',
'य': 'y', 'र': 'r', 'ल': 'l', 'ळ': 'ḷ', 'व': 'v',
'श': 'ś', 'ष': 'ṣ', 'स': 's', 'ह': 'h'
};
var DIACRITICS = {
'ा': 'ā', 'ि': 'i', 'ी': 'ī', 'ु': 'u', 'ू': 'ū',
'ृ': 'ṛ', 'ॄ': 'ṝ', 'े': 'e', 'ै': 'ai', 'ो': 'o', 'ौ': 'au'
};
var VOWELS = {
'अ': 'a', 'आ': 'ā', 'इ': 'i', 'ई': 'ī', 'उ': 'u', 'ऊ': 'ū',
'ऋ': 'ṛ', 'ॠ': 'ṝ', 'ए': 'e', 'ऐ': 'ai', 'ओ': 'o', 'औ': 'au', 'ऽ': "'"
};
var MISC = {
'ं': 'ṃ', 'ः': 'ḥ', 'ँ': 'm̐', 'ॐ': 'oṃ',
'०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
'५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
};
var HALANTA = '्';
var chars = Array.from(text), result = '', i = 0;
while (i < chars.length) {
var ch = chars[i], next = chars[i + 1];
if (CONSONANTS[ch]) {
var base = CONSONANTS[ch];
if (next === HALANTA) { result += base; i += 2; }
else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
else if (next === 'ं' || next === 'ः') { result += base + 'a' + MISC[next]; i += 2; }
else { result += base + 'a'; i++; }
}
else if (VOWELS[ch]) { result += VOWELS[ch]; i++; }
else if (DIACRITICS[ch]) { result += DIACRITICS[ch]; i++; }
else if (MISC[ch]) { result += MISC[ch]; i++; }
else { result += ch; i++; }
}
return result;
}
var SCRIPT_MAP = {
kn: {
'अ': 'ಅ', 'आ': 'ಆ', 'इ': 'ಇ', 'ई': 'ಈ', 'उ': 'ಉ', 'ऊ': 'ಊ', 'ऋ': 'ಋ', 'ॠ': 'ೠ',
'ए': 'ಏ', 'ऐ': 'ಐ', 'ओ': 'ಓ', 'औ': 'ಔ', 'ऽ': 'ಽ',
'क': 'ಕ', 'ख': 'ಖ', 'ग': 'ಗ', 'घ': 'ಘ', 'ङ': 'ಙ',
'च': 'ಚ', 'छ': 'ಛ', 'ज': 'ಜ', 'झ': 'ಝ', 'ञ': 'ಞ',
'ट': 'ಟ', 'ठ': 'ಠ', 'ड': 'ಡ', 'ढ': 'ಢ', 'ण': 'ಣ',
'त': 'ತ', 'थ': 'ಥ', 'द': 'ದ', 'ध': 'ಧ', 'न': 'ನ',
'प': 'ಪ', 'फ': 'ಫ', 'ब': 'ಬ', 'भ': 'ಭ', 'म': 'ಮ',
'य': 'ಯ', 'र': 'ರ', 'ल': 'ಲ', 'ळ': 'ಳ', 'व': 'ವ',
'श': 'ಶ', 'ष': 'ಷ', 'स': 'ಸ', 'ह': 'ಹ',
'ा': 'ಾ', 'ि': 'ಿ', 'ी': 'ೀ', 'ु': 'ು', 'ू': 'ೂ',
'ृ': 'ೃ', 'ॄ': 'ೄ', 'े': 'ೇ', 'ै': 'ೈ', 'ो': 'ೋ', 'ौ': 'ೌ',
'ं': 'ಂ', 'ः': 'ಃ', '्': '್', 'ँ': 'ಁ',
'०': '೦', '१': '೧', '२': '೨', '३': '೩', '४': '೪',
'५': '೫', '६': '೬', '७': '೭', '८': '೮', '९': '೯'
},
te: {
'अ': 'అ', 'आ': 'ఆ', 'इ': 'ఇ', 'ई': 'ఈ', 'उ': 'ఉ', 'ऊ': 'ఊ', 'ऋ': 'ఋ', 'ॠ': 'ౠ',
'ए': 'ఏ', 'ऐ': 'ఐ', 'ओ': 'ఓ', 'औ': 'ఔ', 'ऽ': 'ఽ',
'क': 'క', 'ख': 'ఖ', 'ग': 'గ', 'घ': 'ఘ', 'ङ': 'ఙ',
'च': 'చ', 'छ': 'ఛ', 'ज': 'జ', 'झ': 'ఝ', 'ञ': 'ఞ',
'ट': 'ట', 'ठ': 'ఠ', 'ड': 'డ', 'ढ': 'ఢ', 'ण': 'ణ',
'त': 'త', 'थ': 'థ', 'द': 'ద', 'ध': 'ధ', 'न': 'న',
'प': 'ప', 'फ': 'ఫ', 'ब': 'బ', 'भ': 'భ', 'म': 'మ',
'य': 'య', 'र': 'ర', 'ल': 'ల', 'ळ': 'ళ', 'व': 'వ',
'श': 'శ', 'ष': 'ష', 'स': 'స', 'ह': 'హ',
'ा': 'ా', 'ि': 'ి', 'ी': 'ీ', 'ु': 'ు', 'ू': 'ూ',
'ृ': 'ృ', 'ॄ': 'ౄ', 'े': 'ే', 'ै': 'ై', 'ो': 'ో', 'ौ': 'ౌ',
'ं': 'ం', 'ः': 'ః', '्': '్', 'ँ': 'ఁ',
'०': '౦', '१': '౧', '२': '౨', '३': '౩', '४': '౪',
'५': '౫', '६': '౬', '७': '౭', '८': '౮', '९': '౯'
}
};
var PRE = [
[/ङ्क/g, 'ंक'], [/ङ्ख/g, 'ंख'], [/ङ्ग/g, 'ंग'], [/ङ्घ/g, 'ंघ'],
[/ञ्च/g, 'ंच'], [/ञ्ज/g, 'ंज'], [/ण्ट/g, 'ंट'], [/ण्ड/g, 'ंड'],
[/न्त/g, 'ंत'], [/न्द/g, 'ंद'], [/म्ब/g, 'ंब'], [/म्भ/g, 'ंभ']
];
function transliterateText(text, script) {
if (script === 'en') return devanagariToIAST(text);
if (script === 'ta') return devanagariToTamil(text);
var map = SCRIPT_MAP[script];
if (!map) return text;
var t = text;
PRE.forEach(function (p) { t = t.replace(p[0], p[1]); });
return Array.from(t).map(function (ch) {
return map[ch] !== undefined ? map[ch] : ch;
}).join('');
}
var translatableSpans = [];
function tagTextNodes() {
var content = document.querySelector('.mw-parser-output');
if (content) {
var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
var nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function (node) {
var p = node.parentNode;
if (!p) return;
if (p.hasAttribute && p.hasAttribute('data-deva')) return;
if (p.closest) {
if (p.closest('.gr-controls')) return;
if (p.closest('.mw-editsection')) return;
// Note markers are [1], [2] … — machinery, not text, and
// the numbers would be transliterated along with the page.
if (p.closest('.gr-note-marker')) return;
}
var orig = node.textContent;
if (!orig.trim()) return;
var span = document.createElement('span');
span.setAttribute('data-deva', orig);
span.textContent = orig;
p.replaceChild(span, node);
translatableSpans.push(span);
});
}
document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
}
// A contents entry is "1.2 शीर्षकम्": the number lives in its own
// .vector-toc-numb span, which Common.css hides. Tagging the whole
// .vector-toc-text meant transliteration replaced its contents with
// one flat string — number included — so switching to Tamil or any
// other script made the numbering appear. Only the label is tagged.
function tagTocEntry(textEl) {
if (!textEl || textEl.hasAttribute('data-deva')) return;
var label = textEl.querySelector('[data-deva]');
if (!label) {
label = document.createElement('span');
label.className = 'gr-toc-label';
Array.prototype.slice.call(textEl.childNodes).forEach(function (n) {
if (n.nodeType === 1 && n.classList && n.classList.contains('vector-toc-numb')) return;
label.appendChild(n); // moves it, order preserved
});
textEl.appendChild(label);
}
var orig = label.textContent;
if (!orig.trim()) return;
if (!label.hasAttribute('data-deva')) label.setAttribute('data-deva', orig);
if (translatableSpans.indexOf(label) === -1) translatableSpans.push(label);
}
// Which script is on screen, published to CSS. Common.css §2b uses it to
// swap the reading area onto --font-indic while a transliteration is
// showing: most rules here name Adishila directly, which has no Tamil,
// Kannada or Telugu letters, so without this the switched text fell back
// to whatever serif the visitor's machine happened to have — the reason
// it looked uneven from one line to the next.
function markScriptOnRoot(script) {
try {
var el = document.documentElement;
if (!el) return;
if (!script || script === 'deva') el.removeAttribute('data-gr-script');
else el.setAttribute('data-gr-script', script);
} catch (e) { }
}
function applyScript(script) {
currentScript = script;
markScriptOnRoot(script);
translatableSpans.forEach(function (span) {
if (!span.parentNode) return;
var orig = span.getAttribute('data-deva');
if (!orig) return;
span.textContent = (script === 'deva')
? orig
: transliterateText(orig, script);
});
}
function _isNoTocPage() {
var pn = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
return pn === 'Main_Page' || /^[A-Za-z0-9_]+:About$/.test(pn);
}
function renameTocTitle() {
if (_isNoTocPage()) return;
var toc = document.querySelector('.vector-toc');
if (!toc) return;
var titleEl = toc.querySelector('.vector-toc-title') || toc.querySelector('.vector-pinnable-header-label');
if (!titleEl) return;
var LABEL = 'विषयसूची';
var span = titleEl.querySelector('.gr-toc-title');
if (!span) {
titleEl.innerHTML = '';
span = document.createElement('span');
span.className = 'gr-toc-title';
span.setAttribute('data-deva', LABEL);
titleEl.appendChild(span);
translatableSpans.push(span);
}
span.textContent = currentScript === 'deva' ? LABEL : transliterateText(LABEL, currentScript);
}
function removeTocBeginning() {
if (_isNoTocPage()) return;
var toc = document.querySelector('.vector-toc');
if (!toc) return;
var el = toc.querySelector('#vector-toc-beginning');
if (!el) {
var items = toc.querySelectorAll('.vector-toc-list-item');
for (var i = 0; i < items.length; i++) {
var a = items[i].querySelector('a');
if (a) {
var href = a.getAttribute('href') || '';
if (href.indexOf('#') === -1) { el = items[i]; break; }
}
}
}
if (el && el.parentNode) el.parentNode.removeChild(el);
}
// The commentary list at the top of a moola page has its own heading
// (टीकाः). It isn't part of the text, so it doesn't belong in the
// contents. Recognised by what the section holds — the .gr-doc-teekas
// list, or a section whose only links are to /Vyakhya/ pages — with
// the heading's own wording as a last resort, since the list markup
// differs between pages and the commentary picker rewrites it.
var TEEKA_HEADINGS = /^(टीकाः?|व्याख्याः?|टीकाग्रन्थाः|Teekas?|Commentaries)$/i;
function isTeekaSection(headingEl) {
var wrap = (headingEl.closest && headingEl.closest('.mw-heading')) || headingEl;
var n = wrap.nextElementSibling, links = [], vyakhya = 0, steps = 0;
while (n && steps < 6) {
if (n.classList && n.classList.contains('gr-doc-teekas')) return true;
if (n.querySelector && n.querySelector('.gr-doc-teekas')) return true;
if ((n.tagName && /^H[1-6]$/.test(n.tagName)) || (n.classList && n.classList.contains('mw-heading'))) break;
if (n.querySelectorAll) {
Array.prototype.forEach.call(n.querySelectorAll('a[href]'), function (a) {
var href = a.getAttribute('href') || '';
if (/^#/.test(href)) return;
links.push(href);
if (/\/Vyakhya\//i.test(decodeURIComponent(href))) vyakhya++;
});
}
n = n.nextElementSibling; steps++;
}
if (links.length && vyakhya === links.length) return true;
return TEEKA_HEADINGS.test((headingEl.textContent || '').trim());
}
function removeTocTeekaEntry() {
if (_isNoTocPage()) return;
var toc = document.querySelector('.vector-toc');
if (!toc) return;
Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-link[href^="#"]'), function (a) {
var id = a.getAttribute('href').slice(1);
try { id = decodeURIComponent(id); } catch (e) { }
var h = id && document.getElementById(id);
if (!h || !isTeekaSection(h)) return;
var li = a.closest('.vector-toc-list-item');
if (li && li.parentNode) li.parentNode.removeChild(li);
});
}
// Section numbers ("2.1.1") are removed from the contents outright.
// Hiding them with CSS was not enough: the script switcher replaces an
// entry's contents with one flat string, which turned the hidden number
// into ordinary text — so it reappeared in Tamil, Kannada and the rest.
// They are also wrong here, since this contents list leaves entries out.
var NUMBERED = /^\s*\d+(?:\.\d+)*[.)]?\s+([\s\S]*\S)\s*$/;
function stripTocNumbers() {
var toc = document.querySelector('.vector-toc');
if (!toc) return;
Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-numb'), function (n) {
if (n.parentNode) n.parentNode.removeChild(n);
});
// Entries another script already flattened: take the number off the
// text, and off the Devanagari original the switcher re-reads.
Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-text, .vector-toc-text [data-deva]'), function (el) {
var d = el.getAttribute && el.getAttribute('data-deva');
if (d) { var md = NUMBERED.exec(d); if (md) el.setAttribute('data-deva', md[1]); }
if (!el.children.length) {
var mt = NUMBERED.exec(el.textContent);
if (mt) el.textContent = mt[1];
}
});
}
// The switcher can rewrite the contents at any time, so watch for it.
var _numbObserver = null;
function watchTocNumbers() {
var toc = document.querySelector('.vector-toc');
if (!toc || _numbObserver || !window.MutationObserver) return;
var pending = false;
_numbObserver = new MutationObserver(function () {
if (pending) return;
pending = true;
setTimeout(function () { pending = false; stripTocNumbers(); }, 50);
});
_numbObserver.observe(toc, { childList: true, subtree: true, characterData: true });
}
function expandTocSections() {
if (_isNoTocPage()) return;
var toc = document.querySelector('.vector-toc');
if (!toc) return;
toc.querySelectorAll('.vector-toc-list-item-collapsed').forEach(function (li) {
li.classList.remove('vector-toc-list-item-collapsed');
});
}
function injectTocDocNav() {
if (_isNoTocPage()) return;
var toc = document.querySelector('.vector-toc');
if (!toc) return;
if (document.getElementById('gr-toc-doc-nav')) return;
var artPath = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
var pageTitle = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
var teekaPage = document.querySelector('.gr-teeka-page');
var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || '') : '';
if (!primarySlug) { primarySlug = pageTitle.split('/')[0]; }
if (!primarySlug) return;
function wikiUrl(slug) {
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug);
return artPath.replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/'));
}
var docTitleEl = document.querySelector('.gr-doc-title');
var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1';
var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1';
var showMoolaPage = !teekaPage && hasMoolaPage;
var showMoolaBack = !!teekaPage;
var showUllekha = hasUllekhaPage || !!teekaPage;
if (!showMoolaPage && !showMoolaBack && !showUllekha) return;
var nav = document.createElement('div');
nav.id = 'gr-toc-doc-nav';
nav.setAttribute('class', 'toc-main-links');
function makeBtn(href, label) {
var a = document.createElement('a');
a.href = href;
a.setAttribute('class', 'toc-main-link-item');
var lspan = document.createElement('span');
lspan.setAttribute('data-deva', label);
lspan.textContent = (currentScript && currentScript !== 'deva') ? transliterateText(label, currentScript) : label;
translatableSpans.push(lspan);
a.appendChild(lspan);
return a;
}
if (showMoolaPage) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
if (showMoolaBack) nav.appendChild(makeBtn(wikiUrl(primarySlug), 'मूल'));
if (showUllekha) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
// Under the document's own title in the page, the same as on a
// phone — not tucked into the contents panel, where these were
// easy to miss. The contents panel is only the fallback.
var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
if (docTitle && docTitle.parentNode) { docTitle.parentNode.insertBefore(nav, docTitle.nextSibling); return; }
var h1 = document.getElementById('firstHeading') || document.querySelector('.mw-first-heading');
if (h1 && h1.parentNode) { h1.parentNode.insertBefore(nav, h1.nextSibling); return; }
var tocContents = toc.querySelector('.vector-toc-contents');
if (tocContents) toc.insertBefore(nav, tocContents);
else toc.appendChild(nav);
}
var _headingObserver = null;
var _tocActiveId = null;
// NEW: moved out of attachHeadingObserver's own local scope so they
// can be reused both by the scroll observer AND by a direct trigger
// for search-result clicks — was previously only reachable from
// inside attachHeadingObserver's own IntersectionObserver callback.
// _tocActiveId (was _activeId, local to attachHeadingObserver) is
// module-level now for the same reason — both callers need to share
// the same "what's currently active" state, not track it separately.
function getTocLink(id) {
var toc = document.querySelector('.vector-toc');
if (!toc) return null;
return toc.querySelector('a[href="#' + CSS.escape(id) + '"]');
}
function getTocLi(id) {
var a = getTocLink(id);
return a ? a.closest('.vector-toc-list-item') : null;
}
// Highlighting is class-only (styled in Common.css §6):
// .gr-toc-current — the one entry being read, on the <a> itself
// .gr-toc-in-path — its parent sections, marked on the <li>
// Previously this set inline colours on the active item AND on every
// element inside it, so a section with sub-sections lit up whole.
function clearActiveToc() {
var toc = document.querySelector('.vector-toc');
if (!toc) return;
toc.querySelectorAll('.gr-toc-current').forEach(function (el) { el.classList.remove('gr-toc-current'); });
toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
}
function ownLink(li) {
return li.querySelector(':scope > .vector-toc-link') || li.querySelector('.vector-toc-link');
}
function isShown(el) {
return !!(el && (el.offsetParent || (el.getClientRects && el.getClientRects().length)));
}
// Open a collapsed section so a given entry can be seen. Vector marks a
// top-level item open with vector-toc-list-item-expanded plus
// aria-expanded on its toggle, so this matches what its own button does.
function expandTocFor(li) {
var top = li.closest ? li.closest('.vector-toc-level-1') : null;
if (!top) return;
if (!top.classList.contains('vector-toc-list-item-expanded')) {
top.classList.add('vector-toc-list-item-expanded');
var tg = top.querySelector('.vector-toc-toggle');
if (tg) tg.setAttribute('aria-expanded', 'true');
}
var anc = li.parentNode;
while (anc && anc.classList) {
anc.classList.remove('vector-toc-list-item-collapsed');
if (anc.classList.contains('vector-toc')) break;
anc = anc.parentNode;
}
}
// expand=true when the reader picked the entry (TOC click, search
// result, #hash): open its section so the exact entry is shown.
// expand=false while scrolling: never open or close anything — if the
// entry is hidden inside a collapsed section, the section itself is
// highlighted instead, so the reader still sees where they are.
function setActiveToc(id, expand) {
var toc = document.querySelector('.vector-toc');
if (!toc) return;
var shownNow = toc.querySelector('.gr-toc-current');
if (_tocActiveId === id && !expand && shownNow && isShown(shownNow)) return;
_tocActiveId = id;
clearActiveToc();
if (!id) return;
var li = getTocLi(id);
if (!li) return;
if (expand) expandTocFor(li);
var lnk = ownLink(li);
var hostLi = li;
if (lnk && !isShown(lnk)) { // hidden in a collapsed section
var p = li.parentNode;
while (p && p !== toc) {
if (p.classList && p.classList.contains('vector-toc-list-item')) {
var pl = ownLink(p);
if (pl && isShown(pl)) { lnk = pl; hostLi = p; break; }
}
p = p.parentNode;
}
}
if (lnk) lnk.classList.add('gr-toc-current');
// Sections above it get the quieter "you are in here" treatment.
var anc = hostLi.parentNode;
while (anc && anc !== toc) {
if (anc.classList && anc.classList.contains('vector-toc-list-item')) anc.classList.add('gr-toc-in-path');
anc = anc.parentNode;
}
keepTocEntryVisible(lnk || hostLi);
}
// Scroll the TOC panel so the active entry is visible. This used to
// measure the whole <li>, which for a section with sub-sections is as
// tall as the entire branch — so "centre it" landed somewhere inside
// the NEXT section. It now measures the link itself, and finds the
// element that actually scrolls rather than assuming one.
function keepTocEntryVisible(el) {
if (!el) return;
var sc = el.parentNode;
while (sc && sc !== document.body && sc.nodeType === 1) {
var oy = window.getComputedStyle(sc).overflowY;
if ((oy === 'auto' || oy === 'scroll') && sc.scrollHeight > sc.clientHeight + 4) break;
sc = sc.parentNode;
}
if (!sc || sc === document.body || sc.nodeType !== 1) return; // nothing scrolls: leave the page alone
var er = el.getBoundingClientRect(), cr = sc.getBoundingClientRect();
if (er.top >= cr.top + 8 && er.bottom <= cr.bottom - 8) return; // already in view
sc.scrollTop += (er.top - cr.top) - Math.max((sc.clientHeight - er.height) / 2, 0);
}
// NEW: given any element in the article content, finds the nearest
// heading (with an id) that precedes it in document order, using
// compareDocumentPosition — robust regardless of nesting depth.
// Verified against a realistic multi-section fragment, including
// the "before any heading" and "right after a chapter heading, no
// sub-heading yet" edge cases, before wiring this in.
function findNearestHeadingId(target) {
var content = document.querySelector('.mw-parser-output');
if (!content) return null;
var headings = content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]');
var nearest = null;
for (var i = 0; i < headings.length; i++) {
var h = headings[i];
var pos = h.compareDocumentPosition(target);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
nearest = h.id;
} else {
break;
}
}
return nearest;
}
// NEW: exposed so the search-highlight IIFE (below) can directly
// trigger the TOC to expand and highlight the right section right
// after a search-result scroll lands — rather than relying solely on
// the IntersectionObserver to catch up on its own timing.
window.grHighlightTocForElement = function (el) {
if (!el) return;
var id = findNearestHeadingId(el);
if (id) pinActive(id);
};
// ── Which section am I reading? ────────────────────────────────
// Was an IntersectionObserver with a shrunken root band. Two faults
// came out of that, both reported as "the highlight is wrong":
// • right after jumping to a heading, that heading sits ABOVE the
// band, so nothing was "visible" and the fallback picked the
// PREVIOUS heading — clicking a sub-section highlighted its parent;
// • once every heading was outside the band, no intersection ever
// changed again, so the observer stopped firing and the highlight
// froze wherever it was.
// This measures instead: the active section is the last heading above
// a reading line just under the sticky toolbar. It updates on every
// scroll frame and can't go stale.
var _tocHeadings = [];
var _tocTops = [];
var _tocMeasuredHeight = 0;
// A clicked / jumped-to heading is "pinned": it stays the active entry
// until the reader actually scrolls away from it. Without this, the
// anchor jump leaves the heading below the reading line (the sticky
// bar and scroll-margin push it down the viewport), so the measurement
// picked the heading ABOVE it and the highlight jumped back one.
var _pin = null; // {id, y, until}
function measureHeadings() {
var y = window.pageYOffset || document.documentElement.scrollTop || 0;
_tocTops = _tocHeadings.map(function (h) { return h.getBoundingClientRect().top + y; });
_tocMeasuredHeight = document.documentElement.scrollHeight;
}
function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
// The line down the page that decides "which section am I reading":
// just below whatever is stuck to the top, plus a quarter of the
// screen, so a heading sitting near the top of the viewport counts as
// the one being read rather than the one before it.
function readingLine() {
var bar = document.getElementById('gr-static-bar');
var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0;
var vh = window.innerHeight || document.documentElement.clientHeight || 800;
return scrollY() + barBottom + Math.max(Math.round(vh * 0.25), 80);
}
// Scroll something into a comfortable reading position and KEEP it
// there. A plain scrollIntoView lands short or long whenever the page
// reflows just after the jump — a web font finishing, a commentary
// being interleaved, the match itself being wrapped in a highlight —
// which is why search results came to rest slightly above or below
// the text. This aims just under whatever is stuck to the top, then
// re-checks until the page has settled, and gives up the moment the
// reader scrolls, so it never fights them.
function grDesiredTop() {
var bar = document.getElementById('gr-static-bar');
var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0;
var vh = window.innerHeight || document.documentElement.clientHeight || 800;
return barBottom + Math.max(Math.round(vh * 0.22), 72);
}
window.grScrollToElement = function (el) {
if (!el || !el.getBoundingClientRect) return;
var cancelled = false, ticks = 0, lastY = null, iv = null;
function onUser() { cancelled = true; }
function cleanup() {
if (iv) clearInterval(iv);
window.removeEventListener('wheel', onUser);
window.removeEventListener('touchstart', onUser);
window.removeEventListener('keydown', onUser);
}
window.addEventListener('wheel', onUser, { passive: true });
window.addEventListener('touchstart', onUser, { passive: true });
window.addEventListener('keydown', onUser);
function delta() { return el.getBoundingClientRect().top - grDesiredTop(); }
try { window.scrollBy({ top: delta(), left: 0, behavior: 'smooth' }); }
catch (e) { window.scrollBy(0, delta()); }
iv = setInterval(function () {
ticks++;
if (cancelled || ticks > 12 || !document.body.contains(el)) { cleanup(); return; }
var y = scrollY();
if (lastY !== null && Math.abs(y - lastY) < 1) { // the scroll has come to rest
var d = delta();
if (Math.abs(d) > 6) window.scrollBy(0, d); // reflow moved it: correct at once
else { cleanup(); return; }
}
lastY = y;
}, 200);
};
// Called when the reader picks a heading (TOC click, search result,
// or a #hash in the URL) — that heading wins until they scroll on.
function pinActive(id) {
if (!id) return;
_pin = { id: id, y: scrollY(), until: Date.now() + 900 };
setActiveToc(id, true); // open its section if collapsed
scheduleRecheck(950);
}
var _recheck = null;
function scheduleRecheck(ms) {
if (_recheck) return;
_recheck = setTimeout(function () { _recheck = null; updateActiveFromScroll(); }, ms);
}
function updateActiveFromScroll() {
if (!_tocHeadings.length) return;
if (_pin) {
var y = scrollY();
if (Date.now() < _pin.until) { // still settling after the jump
_pin.y = y;
setActiveToc(_pin.id, true);
scheduleRecheck(_pin.until - Date.now() + 30);
return;
}
if (Math.abs(y - _pin.y) < 60) { // reader hasn't moved off it
setActiveToc(_pin.id);
return;
}
_pin = null; // they've scrolled on
}
// Interleaving a commentary or changing the font size moves things;
// re-measure when the page's height has changed.
if (document.documentElement.scrollHeight !== _tocMeasuredHeight) measureHeadings();
var line = readingLine(), lo = 0, hi = _tocTops.length - 1, idx = 0;
while (lo <= hi) { // last heading at or above the line
var mid = (lo + hi) >> 1;
if (_tocTops[mid] <= line + 1) { idx = mid; lo = mid + 1; } else { hi = mid - 1; }
}
setActiveToc(_tocHeadings[idx].id);
}
function attachHeadingObserver() {
if (_isNoTocPage()) return;
if (_headingObserver) return;
var content = document.querySelector('.mw-parser-output');
if (!content) return;
if (!document.querySelector('.vector-toc')) return;
// Only headings the contents lists — a heading with no entry (the
// commentary list, say) would otherwise leave nothing highlighted.
_tocHeadings = Array.prototype.slice.call(content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]'))
.filter(function (h) { return !!getTocLink(h.id); });
if (!_tocHeadings.length) return;
measureHeadings();
// Time-based throttle rather than a requestAnimationFrame latch: if
// one frame callback is ever skipped (background tab, a jump during
// load) a latch stays stuck and the highlight stops updating for
// good — which is one of the ways this went stale before.
var lastRun = 0, trailing = null;
function onScroll() {
var now = Date.now();
if (now - lastRun >= 80) { lastRun = now; updateActiveFromScroll(); }
else if (!trailing) {
trailing = setTimeout(function () { trailing = null; lastRun = Date.now(); updateActiveFromScroll(); }, 80);
}
}
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { measureHeadings(); onScroll(); }, { passive: true });
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
document.fonts.ready.then(function () { measureHeadings(); onScroll(); });
}
_headingObserver = { onScroll: onScroll }; // marks "already wired"
var hash = '';
try { hash = decodeURIComponent((location.hash || '').slice(1)); } catch (e) { hash = (location.hash || '').slice(1); }
if (hash && document.getElementById(hash)) pinActive(hash);
else updateActiveFromScroll();
}
// Clicking an entry highlights it straight away, rather than waiting
// for the scroll observer to catch up after the jump.
function attachTocClickHandler(toc) {
if (toc.getAttribute('data-gr-click-wired')) return;
toc.setAttribute('data-gr-click-wired', '1');
toc.addEventListener('click', function (e) {
var a = e.target.closest ? e.target.closest('.vector-toc-link') : null;
if (!a) return;
var href = a.getAttribute('href') || '';
if (href.charAt(0) !== '#') return;
var id;
try { id = decodeURIComponent(href.slice(1)); } catch (err) { id = href.slice(1); }
if (id) pinActive(id);
});
}
function setupToc() {
if (_isNoTocPage()) return;
var toc = document.querySelector('.vector-toc');
if (!toc) return;
removeTocBeginning();
removeTocTeekaEntry();
stripTocNumbers();
watchTocNumbers();
renameTocTitle();
expandTocSections();
injectTocDocNav();
attachTocClickHandler(toc);
attachHeadingObserver();
}
function init() {
var HIDE_IDS = ['vector-appearance', 'vector-appearance-pinned-container', 'vector-appearance-unpinned-container'];
function removeHiddenEls() {
HIDE_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); });
var pt = document.getElementById('vector-page-tools') || document.querySelector('.vector-page-tools-pinned-container');
if (pt) pt.querySelectorAll('[aria-controls="vector-appearance"]').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
}
removeHiddenEls();
(function detectTeekaMode() {
var tp = document.querySelector('.gr-teeka-page');
if (!tp) return;
var primary = tp.getAttribute('data-primary') || '';
var artPath = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1';
var mainUrl = artPath.replace('$1', primary);
var refParam = window.location.search.match(/[?&]ref=([01])/);
if (refParam) { document.body.classList.add(refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone'); return; }
var ref = document.referrer || '';
document.body.classList.add((ref && primary && ref.indexOf(mainUrl) !== -1) ? 'gr-ref-mode' : 'gr-standalone');
}());
if (window.MutationObserver) {
var hideObs = new MutationObserver(function (mutations) {
var dirty = false;
mutations.forEach(function (m) { if (m.addedNodes.length) dirty = true; });
if (dirty) removeHiddenEls();
});
hideObs.observe(document.body, { childList: true, subtree: false });
setTimeout(function () { hideObs.disconnect(); }, 6000);
}
var content = document.querySelector('.mw-parser-output');
var alreadyTagged = content && content.querySelector('[data-deva]');
if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
else {
document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
}
var saved = (function () { try { return localStorage.getItem(LS_SCRIPT_KEY); } catch (e) { return null; } }());
if (saved && saved !== 'deva') { applyScript(saved); } else { currentScript = 'deva'; }
watchNewContent();
setTimeout(setupToc, 200);
}
window.addEventListener('gr-script-change', function (e) {
var script = e && e.detail && e.detail.script;
if (script) applyScript(script);
});
var _translitBusy = false;
function tagNewContent(container) {
if (!container || !container.nodeType) return;
_translitBusy = true;
try {
var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
var nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function (node) {
var p = node.parentNode;
if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
if (p.closest && (p.closest('.gr-controls') || p.closest('.mw-editsection')
|| p.closest('.gr-note-marker'))) return;
var orig = node.textContent;
if (!orig.trim()) return;
var span = document.createElement('span');
span.setAttribute('data-deva', orig);
span.textContent = currentScript !== 'deva' ? transliterateText(orig, currentScript) : orig;
p.replaceChild(span, node);
translatableSpans.push(span);
});
} finally { _translitBusy = false; }
}
window.addEventListener('gr-new-content', function (e) {
tagNewContent(e && e.detail && e.detail.container);
});
// Safety net: anything else that adds text to the page later — an
// interleaved commentary, a sub-commentary, an edit preview — is picked
// up and put into the reader's chosen script, rather than being left in
// Devanagari because it wasn't there when the page was first tagged.
function watchNewContent() {
if (!window.MutationObserver) return;
var content = document.querySelector('.mw-parser-output');
if (!content || content.getAttribute('data-gr-translit-watch')) return;
content.setAttribute('data-gr-translit-watch', '1');
var queued = [], timer = null;
function flush() {
timer = null;
var items = queued; queued = [];
items.forEach(function (n) { if (document.body.contains(n)) tagNewContent(n); });
}
new MutationObserver(function (muts) {
if (_translitBusy) return;
muts.forEach(function (m) {
Array.prototype.forEach.call(m.addedNodes, function (n) {
var el = n.nodeType === 1 ? n : (n.nodeType === 3 ? n.parentNode : null);
if (!el || el.nodeType !== 1) return;
if (el.hasAttribute && el.hasAttribute('data-deva')) return;
queued.push(el);
});
});
if (queued.length && !timer) timer = setTimeout(flush, 60);
}).observe(content, { childList: true, subtree: true });
}
try {
var _grBC = new BroadcastChannel('gr-script');
_grBC.onmessage = function (e) {
var script = e && e.data && e.data.script;
if (script) { currentScript = script; var sel = document.querySelector('.gr-script-sel'); if (sel) sel.value = script; applyScript(script); }
};
} catch (e) { }
if (window.mw) {
mw.hook('wikipage.content').add(function () {
setTimeout(function () {
var content = document.querySelector('.mw-parser-output');
var alreadyTagged = content && content.querySelector('[data-deva]');
if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
else {
// tagTocEntry, not a re-tag from textContent: an entry
// keeps its Devanagari in a span *inside* .vector-toc-text,
// so .vector-toc-text itself never carries data-deva and a
// :not([data-deva]) sweep matched every entry on every
// fire. It then stored the text as it stood — Tamil, once
// the reader had switched — as that entry's "original", so
// the next switch had no Devanagari left to work from and
// the contents panel stayed in the previous script while
// the body moved on. tagTocEntry leaves a tagged entry be.
document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
}
if (currentScript !== 'deva') applyScript(currentScript);
setupToc();
}, 150);
});
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); }
else { init(); }
}()); /* ← end of main IIFE */
// ── Inject "Help" and "About" links into the header ─────────────────
(function () {
function wikiHref(title) {
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
return ap.replace('$1', title);
}
function makeHeaderLink(id, href, label) {
var a = document.createElement('a');
a.id = id; a.href = href; a.textContent = label;
a.className = 'gr-header-link'; // styled in Common.css §37
return a;
}
function injectHeaderLinks() {
if (document.getElementById('gr-about-link')) return;
var headerEnd = document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header');
if (!headerEnd) return;
var helpLink = makeHeaderLink('gr-help-link', wikiHref('My_wiki:Help'), 'Help');
var aboutLink = makeHeaderLink('gr-about-link', wikiHref('My_wiki:About'), 'About');
var userName = window.mw ? mw.config.get('wgUserName') : null;
var isAnon = !userName || (window.mw && mw.config.get('wgUserId') === null);
var authEl = null;
if (isAnon) {
if (!document.getElementById('gr-header-login')) {
authEl = makeHeaderLink('gr-header-login', wikiHref('Special:UserLogin'), 'Login');
}
} else if (!document.getElementById('gr-header-user')) {
authEl = buildHeaderUserDropdown(userName);
}
var ul = document.querySelector('.vector-user-links') || document.querySelector('#pt-userpage');
if (ul && ul.parentNode === headerEnd) {
if (authEl) headerEnd.insertBefore(authEl, ul);
headerEnd.insertBefore(aboutLink, authEl || ul);
headerEnd.insertBefore(helpLink, aboutLink);
} else {
headerEnd.appendChild(helpLink);
headerEnd.appendChild(aboutLink);
if (authEl) headerEnd.appendChild(authEl);
}
}
function buildHeaderUserDropdown(userName) {
var wrap = document.createElement('div');
wrap.id = 'gr-header-user';
wrap.style.cssText = 'position:relative;display:inline-block;margin-right:4px;';
var trigger = document.createElement('button');
trigger.id = 'gr-header-user-trigger';
trigger.type = 'button';
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');
trigger.style.cssText = [
'display:inline-flex', 'align-items:center', 'gap:5px',
'color:rgba(255,255,255,0.88)', 'font-size:0.88em',
'font-family:system-ui,sans-serif', 'font-weight:500',
'background:transparent', 'border:none', 'cursor:pointer',
'padding:4px 10px', 'border-radius:4px', 'white-space:nowrap',
'transition:color 0.15s,background 0.15s'
].join(';');
trigger.innerHTML =
'<span>' + userName.replace(/</g, '<') + '</span>' +
'<span id="gr-header-caret" style="font-size:0.8em;transition:transform 0.2s;">\u25be</span>';
trigger.addEventListener('mouseover', function () { this.style.color = '#fff'; this.style.background = 'rgba(255,255,255,0.12)'; });
trigger.addEventListener('mouseout', function () { if (menu.style.display === 'none') { this.style.color = 'rgba(255,255,255,0.88)'; this.style.background = 'transparent'; } });
var menu = document.createElement('div');
menu.id = 'gr-header-user-menu';
menu.setAttribute('role', 'menu');
menu.style.cssText = [
'display:none', 'position:absolute', 'top:100%', 'right:0', 'margin-top:4px',
'min-width:150px', 'background:#fff', 'border-radius:6px',
'box-shadow:0 4px 16px rgba(0,0,0,0.18)', 'overflow:hidden', 'z-index:1000'
].join(';');
var itemStyle = 'display:block;padding:10px 16px;font-size:0.9em;color:#2c1810;text-decoration:none;font-family:system-ui,sans-serif;background:#fff;';
function makeMenuItem(href, label, id) {
var a = document.createElement('a');
a.href = href; a.setAttribute('role', 'menuitem');
a.textContent = label; a.style.cssText = itemStyle;
if (id) a.id = id;
a.addEventListener('mouseover', function () { this.style.background = '#f5efe9'; });
a.addEventListener('mouseout', function () { this.style.background = '#fff'; });
return a;
}
menu.appendChild(makeMenuItem(wikiHref('Special:Profile'), 'Profile'));
menu.appendChild(makeMenuItem('#', 'Log out', 'gr-header-logout'));
function setOpen(open) {
menu.style.display = open ? 'block' : 'none';
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
var caret = document.getElementById('gr-header-caret');
if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
if (open) { trigger.style.color = '#fff'; trigger.style.background = 'rgba(255,255,255,0.12)'; }
else { trigger.style.color = 'rgba(255,255,255,0.88)'; trigger.style.background = 'transparent'; }
}
trigger.addEventListener('click', function (e) {
e.stopPropagation();
setOpen(menu.style.display === 'none');
});
document.addEventListener('click', function (e) {
if (!wrap.contains(e.target)) setOpen(false);
});
menu.querySelector('#gr-header-logout').addEventListener('click', function (e) {
e.preventDefault();
if (window.mw && mw.user && mw.user.tokens) {
var t = mw.user.tokens.get('csrfToken');
if (t) {
var script = (mw.util && mw.util.wikiScript) ? mw.util.wikiScript() : '/index.php';
var form = document.createElement('form');
form.method = 'post';
form.action = script + '?title=Special:UserLogout';
form.style.display = 'none';
form.innerHTML =
'<input type="hidden" name="wpEditToken" value="' + String(t).replace(/"/g, '"') + '">' +
'<input type="hidden" name="title" value="Special:UserLogout">' +
'<input type="hidden" name="returnto" value="Main Page">';
document.body.appendChild(form);
form.submit();
return;
}
}
if (window.mw && mw.Api) {
new mw.Api().postWithToken('csrf', { action: 'logout' })
.done(function () { location.href = '/Main_Page'; })
.fail(function () { location.href = '/index.php?title=Special:UserLogout'; });
} else {
location.href = '/index.php?title=Special:UserLogout';
}
});
wrap.appendChild(trigger);
wrap.appendChild(menu);
return wrap;
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectHeaderLinks);
else injectHeaderLinks();
}());
// ── Main page: by-Grantha / by-Author toggle ──────────────────────
(function () {
function grHomeView(v) {
var gView = document.getElementById('gr-view-grantha');
var aView = document.getElementById('gr-view-author');
var gBtn = document.getElementById('gr-toggle-grantha');
var aBtn = document.getElementById('gr-toggle-author');
if (!gView || !aView || !gBtn || !aBtn) return;
gView.style.display = (v === 'grantha') ? '' : 'none';
aView.style.display = (v === 'author') ? '' : 'none';
gBtn.className = 'gr-toggle-btn' + (v === 'grantha' ? ' gr-toggle-active' : '');
aBtn.className = 'gr-toggle-btn' + (v === 'author' ? ' gr-toggle-active' : '');
try { localStorage.setItem('gr_home_view', v); } catch (e) { }
}
}());
// ── Ullekha reference link handler ─────────────────────────────────
(function () {
// Uses the shared, reflow-proof scroller when it is available.
function grScrollTo(el) {
if (!el) return;
if (window.grScrollToElement) window.grScrollToElement(el);
else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function highlightOnArrival() {
var search = window.location.search;
if (!search) return;
var m = search.match(/[?&]hlUllekha=([^&]+)/);
if (!m) return;
var needle;
try { needle = decodeURIComponent(m[1]); } catch (e) { return; }
if (!needle || needle.length < 4) return;
function doHighlight() {
var content = document.querySelector('.mw-parser-output');
if (!content) return;
var snippet = needle.slice(0, 40);
var found = false;
var spans = content.querySelectorAll('[data-deva]');
for (var i = 0; i < spans.length && !found; i++) {
var spanEl = spans[i];
var orig = spanEl.getAttribute('data-deva') || '';
if (orig.indexOf(snippet) === -1) continue;
var idx = orig.indexOf(snippet);
var hlText = orig.slice(idx, Math.min(idx + needle.length, orig.length));
var mark = document.createElement('mark');
mark.className = 'gr-ullekha-highlight';
mark.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
mark.textContent = hlText;
var parent = spanEl.parentNode;
if (!parent) continue;
var before = document.createTextNode(orig.slice(0, idx));
var after = document.createTextNode(orig.slice(idx + hlText.length));
parent.insertBefore(before, spanEl); parent.insertBefore(mark, spanEl); parent.insertBefore(after, spanEl); parent.removeChild(spanEl);
setTimeout(function () { grScrollTo(mark); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark); }, 100);
found = true;
}
if (!found) {
var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
while (walker.nextNode() && !found) {
var node = walker.currentNode;
var txt = node.textContent || '';
if (txt.indexOf(snippet) === -1) continue;
var idx2 = txt.indexOf(snippet);
var mark2 = document.createElement('mark');
mark2.className = 'gr-ullekha-highlight';
mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
mark2.textContent = txt.slice(idx2, Math.min(idx2 + needle.length, txt.length));
var p = node.parentNode;
p.insertBefore(document.createTextNode(txt.slice(0, idx2)), node);
p.insertBefore(mark2, node);
p.insertBefore(document.createTextNode(txt.slice(idx2 + mark2.textContent.length)), node);
p.removeChild(node);
setTimeout(function () { grScrollTo(mark2); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark2); }, 100);
found = true;
}
}
}
doHighlight();
}
function wireUllekhaLinks() {
document.querySelectorAll('.gr-ullekha-ref-link').forEach(function (wrap) {
var anchor = wrap.getAttribute('data-anchor') || '';
var hl = wrap.getAttribute('data-hl') || '';
var a = wrap.querySelector('a');
if (!a) return;
var base = a.href.split('#')[0];
var encoded = encodeURIComponent(hl);
a.href = base + (hl ? '?hlUllekha=' + encoded : '') + (anchor ? '#' + anchor : '');
});
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { highlightOnArrival(); wireUllekhaLinks(); }); }
else { highlightOnArrival(); wireUllekhaLinks(); }
}());
/* ── Search result highlight ──────────────────────────────────── */
(function () {
function storeQueryForLink(url, query) {
try {
var a = document.createElement('a');
a.href = url;
sessionStorage.setItem('gr_search_hl', JSON.stringify({
query: query,
pathname: a.pathname
}));
} catch (e) { }
}
function applyHighlight() {
var stored;
try {
stored = JSON.parse(sessionStorage.getItem('gr_search_hl') || 'null');
} catch (e) { return; }
if (!stored || !stored.query) return;
var currentPath = window.location.pathname;
var storedPath = stored.pathname || '';
function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); }
if (storedPath && normPath(storedPath) !== normPath(currentPath)) {
try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
return;
}
var query = stored.query.trim();
if (!query) return;
try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
var delays = [0, 200, 600];
delays.forEach(function (ms) {
setTimeout(function () {
if (document.querySelector('.gr-search-hl')) return;
highlightText(query);
}, ms);
});
}
// Uses the shared, reflow-proof scroller when it is available.
function grScrollTo(el) {
if (!el) return;
if (window.grScrollToElement) window.grScrollToElement(el);
else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function highlightText(query) {
var content = document.querySelector('#mw-content-text .mw-parser-output');
if (!content) return;
var raw = query.replace(/^"|"$/g, '').trim();
if (!raw) return;
var patterns = [];
patterns.push(escapeRegex(raw));
raw.split(/\s+/).forEach(function (w) {
if (w.length >= 2) patterns.push(escapeRegex(w));
});
var matched = false;
for (var pi = 0; pi < patterns.length; pi++) {
var re;
try { re = new RegExp('(' + patterns[pi] + ')', 'gi'); }
catch (e) { continue; }
var count = wrapMatches(content, re);
if (count > 0) { matched = true; break; }
}
if (!matched) return;
var first = document.querySelector('.gr-search-hl');
if (first) {
grScrollTo(first);
first.classList.add('gr-search-hl-pulse');
setTimeout(function () { first.classList.remove('gr-search-hl-pulse'); }, 2000);
// NEW: directly trigger the TOC to expand and highlight the
// right section, rather than relying solely on the scroll
// observer to catch up on its own. This is the actual fix —
// confirmed the helper against a realistic multi-section
// fragment before wiring it in here.
if (window.grHighlightTocForElement) window.grHighlightTocForElement(first);
}
showDismissBar(query);
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function wrapMatches(root, re) {
var count = 0;
var walker = document.createTreeWalker(
root, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
var tag = p.tagName.toUpperCase();
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT;
if (p.classList.contains('gr-search-hl')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}, false
);
var nodes = [];
var node;
while ((node = walker.nextNode())) nodes.push(node);
nodes.forEach(function (textNode) {
var val = textNode.nodeValue;
if (!re.test(val)) return;
re.lastIndex = 0;
var frag = document.createDocumentFragment();
var last = 0;
var m;
while ((m = re.exec(val)) !== null) {
if (m.index > last) {
frag.appendChild(document.createTextNode(val.slice(last, m.index)));
}
var span = document.createElement('span');
span.className = 'gr-search-hl';
span.textContent = m[0];
frag.appendChild(span);
last = m.index + m[0].length;
count++;
}
if (last < val.length) {
frag.appendChild(document.createTextNode(val.slice(last)));
}
textNode.parentNode.replaceChild(frag, textNode);
});
return count;
}
function showDismissBar(query) {
var existing = document.getElementById('gr-hl-bar');
if (existing) existing.remove();
var count = document.querySelectorAll('.gr-search-hl').length;
if (!count) return;
var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
var bar = document.createElement('div');
bar.id = 'gr-hl-bar';
if (isMob) {
bar.className = 'gr-search-hl-mobile';
bar.innerHTML =
'<button id="gr-hl-results" class="gr-search-bar-btn">' +
'<span class="gra-icon gra-icon-search" aria-hidden="true"></span>' +
'<span>' + count + ' match' + (count === 1 ? '' : 'es') + '</span></button>' +
'<button id="gr-hl-prev" class="gr-search-bar-btn">' +
'<span class="gra-icon gra-icon-prev" aria-hidden="true"></span>' +
'<span>Prev</span></button>' +
'<button id="gr-hl-next" class="gr-search-bar-btn">' +
'<span class="gra-icon gra-icon-next" aria-hidden="true"></span>' +
'<span>Next</span></button>' +
'<button id="gr-hl-dismiss" class="gr-search-bar-btn">' +
'<span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>' +
'<span>Close</span></button>';
} else {
bar.style.cssText = [
'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200',
'background:#b5451b', 'color:#fff', 'padding:10px 16px',
'display:flex', 'align-items:center', 'justify-content:space-between',
'font-family:system-ui,sans-serif', 'font-size:14px',
'box-shadow:0 -2px 8px rgba(0,0,0,0.2)'
].join(';');
var nav = document.createElement('div');
nav.style.cssText = 'display:flex;align-items:center;gap:12px;';
nav.innerHTML =
'<span>🔍 <strong>' + escHtml(query) + '</strong> — ' + count + ' match' + (count === 1 ? '' : 'es') + '</span>' +
'<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↑ Prev</button>' +
'<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↓ Next</button>' +
'<button id="gr-hl-results" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">← Results</button>';
bar.appendChild(nav);
var db = document.createElement('button');
db.id = 'gr-hl-dismiss'; db.textContent = '✕ Close';
db.style.cssText = 'background:rgba(255,255,255,0.15);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;';
bar.appendChild(db);
}
document.body.appendChild(bar);
var hlEls = Array.from(document.querySelectorAll('.gr-search-hl'));
var current = 0;
function goTo(idx) {
hlEls.forEach(function (el) { el.classList.remove('gr-search-hl-current'); });
current = ((idx % hlEls.length) + hlEls.length) % hlEls.length;
hlEls[current].classList.add('gr-search-hl-current');
grScrollTo(hlEls[current]);
// NEW: same TOC sync on Prev/Next navigation within the
// results, not just the initial jump.
if (window.grHighlightTocForElement) window.grHighlightTocForElement(hlEls[current]);
}
function dismiss() { clearHighlights(); bar.remove(); }
var nb = document.getElementById('gr-hl-next');
var pb = document.getElementById('gr-hl-prev');
var rb = document.getElementById('gr-hl-results');
var db2 = document.getElementById('gr-hl-dismiss');
if (nb) nb.onclick = function () { goTo(current + 1); };
if (pb) pb.onclick = function () { goTo(current - 1); };
if (db2) db2.onclick = dismiss;
if (rb) rb.onclick = function () {
bar.remove(); clearHighlights();
if (window.showSearchDialog) window.showSearchDialog(query);
};
}
function clearHighlights() {
document.querySelectorAll('.gr-search-hl').forEach(function (span) {
var p = span.parentNode; if (!p) return;
while (span.firstChild) p.insertBefore(span.firstChild, span);
p.removeChild(span);
});
}
function escHtml(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function injectHighlightCSS() {
if (document.getElementById('gr-hl-css')) return;
var s = document.createElement('style');
s.id = 'gr-hl-css';
s.textContent = [
'.gr-search-hl{background:#fff176;color:#1a1a1a;border-radius:2px;padding:0 1px;box-shadow:0 0 0 1px rgba(181,69,27,0.25);}',
'.gr-search-hl-current{background:#ffb300!important;box-shadow:0 0 0 2px #b5451b!important;}',
'@keyframes gr-hl-pulse{0%{background:#ffb300;}50%{background:#fff176;}100%{background:#fff176;}}',
'.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}',
].join('');
document.head.appendChild(s);
}
injectHighlightCSS();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyHighlight);
} else {
applyHighlight();
}
if (window.mw) {
mw.hook('wikipage.content').add(function () {
setTimeout(applyHighlight, 100);
});
}
window.grStoreSearchHL = storeQueryForLink;
}());
/* ═══════════════════════════════════════════════════════════════
Mobile addon — only runs on Minerva (mobile) skin
═══════════════════════════════════════════════════════════════ */
(function () {
if (!document.body.classList.contains('skin-minerva')) return;
function injectMenuLinks() {
if (document.getElementById('gr-mob-menu-items')) return;
var navDrawer = document.querySelector('.navigation-drawer');
if (!navDrawer) return;
var wrap = document.createElement('div'); wrap.id = 'gr-mob-menu-items'; wrap.className = 'gr-mob-menu';
function makeItem(href, label) { var a = document.createElement('a'); a.href = href; a.className = 'gr-mob-item'; a.textContent = label; return a; }
wrap.appendChild(makeItem('/Main_Page', 'Home'));
wrap.appendChild(makeItem('/My_wiki:Help', 'Help'));
wrap.appendChild(makeItem('/My_wiki:About', 'About'));
var userName = window.mw ? mw.config.get('wgUserName') : null;
if (userName) {
wrap.appendChild(makeItem((window.mw && mw.util && mw.util.getUrl) ? mw.util.getUrl('Special:Profile') : '/Special:Profile', 'Profile'));
var la = document.querySelector('a[href*="action=logout"]');
wrap.appendChild(makeItem(la ? la.href : '/index.php?title=Special:UserLogout', 'Log out'));
}
else wrap.appendChild(makeItem('/index.php?title=Special:UserLogin', 'Log in'));
var pageLeft = document.getElementById('mw-mf-page-left');
if (pageLeft) {
// Hide Minerva's own menu lists rather than deleting them: Minerva's
// initMobile.js looks up .toggle-list__list inside this panel and
// throws ("Cannot read properties of null (reading 'parentNode')")
// when it is gone, which also stops the rest of Minerva's setup.
Array.prototype.forEach.call(pageLeft.children, function (c) { c.classList.add('gr-mf-native-menu'); });
pageLeft.style.removeProperty('display');
pageLeft.appendChild(wrap);
}
else navDrawer.appendChild(wrap);
}
function injectMoolaUllekhaLinks() {
if (document.getElementById('gr-mob-doc-nav')) return;
var pageName = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
if (pageName === 'Main_Page' || !pageName) return;
function wikiUrl(slug) { if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug); return ((window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1').replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/')); }
var teekaPage = document.querySelector('.gr-teeka-page');
var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || pageName.split('/')[0]) : pageName.split('/')[0];
var docTitleEl = document.querySelector('.gr-doc-title');
var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1';
var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1';
var showMoolam = !!teekaPage || hasMoolaPage, showUllekha = hasUllekhaPage || !!teekaPage;
if (!showMoolam && !showUllekha) return;
var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
nav.className = 'toc-main-links'; // styled in Common.css §24
function makeLink(href, label) { var a = document.createElement('a'); a.href = href; a.textContent = label; a.className = 'toc-main-link-item'; return a; }
if (teekaPage) nav.appendChild(makeLink(wikiUrl(primarySlug), 'मूल'));
else if (hasMoolaPage) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
if (showUllekha) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
// Right under the document's own title, which is what a reader on a
// phone actually sees as the title; the skin's h1 is the fallback.
var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
var h1 = document.getElementById('firstHeading') || document.querySelector('.page-heading, h1.firstHeading, .mw-first-heading');
if (docTitle && docTitle.parentNode) docTitle.parentNode.insertBefore(nav, docTitle.nextSibling);
else if (h1 && h1.parentNode) h1.parentNode.insertBefore(nav, h1.nextSibling);
else { var ct = document.getElementById('mw-content-text'); if (ct) ct.insertBefore(nav, ct.firstChild); }
}
function boot() { injectMenuLinks(); injectMoolaUllekhaLinks();}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
if (window.mw) mw.hook('wikipage.content');
}());
(function () {
'use strict';
if (mw.config.get('wgPageName') !== 'Main_Page') return;
mw.loader.using('mediawiki.util').done(function () {
$(function () { applyHomeToggleOffset(); window.addEventListener('resize', applyHomeToggleOffset, { passive: true }); setTimeout(applyHomeToggleOffset, 300); setTimeout(applyHomeToggleOffset, 800); });
});
function applyHomeToggleOffset() {
var bar = document.getElementById('gr-static-bar'); if (!bar) return;
var barBottom = Math.round(bar.getBoundingClientRect().bottom);
[document.getElementById('gr-home'), document.getElementById('gr-home-toggle'), document.querySelector('#mw-content-text .mw-parser-output > .gr-home, #mw-content-text .mw-parser-output > *:first-child')].forEach(function (el) { if (el) el.style.scrollMarginTop = (barBottom + 4) + 'px'; });
var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
if (isMob) { var toggleEl = document.getElementById('gr-home-toggle'); if (toggleEl) { var tr = toggleEl.getBoundingClientRect(); if (tr.top < barBottom) { var cp = parseInt(window.getComputedStyle(document.body).paddingTop, 10) || 0; document.body.style.paddingTop = (cp + (barBottom - tr.top) + 4) + 'px'; } } }
}
$(function () {
var $toggle = $('#gr-home-toggle'), $viewG = $('#gr-view-grantha'), $viewA = $('#gr-view-author'), $btnG = $('#gr-toggle-grantha'), $btnA = $('#gr-toggle-author');
if (!$toggle.length || !$viewG.length || !$viewA.length) return; if ($toggle.data('gr-wired')) return; $toggle.data('gr-wired', true);
function showView(which) { if (which === 'grantha') { $viewG.show(); $viewA.hide(); $btnG.addClass('gr-toggle-active'); $btnA.removeClass('gr-toggle-active'); } else { $viewA.show(); $viewG.hide(); $btnA.addClass('gr-toggle-active'); $btnG.removeClass('gr-toggle-active'); } try { localStorage.setItem('grantha_home_tab', which); } catch (e) { } }
$btnG.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('grantha'); });
$btnA.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('author'); });
try { var saved = localStorage.getItem('grantha_home_tab'); if (saved === 'author') showView('author'); else showView('grantha'); } catch (e) { showView('grantha'); }
});
}());
// ── Replace MediaWiki user links with a single username dropdown (Profile + Logout) ──
(function () {
function wikiHref(title) {
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
return ap.replace('$1', title);
}
}());
/* ═══════════════════════════════════════════════════════════════
Commentary interleaving — discovers a document's /Vyakhya or
/Bhashya commentary subpages, lets a reader select which ones to
show interleaved with the moola text, and augments any existing
link to a commentary (e.g. a "वादावली" list) with its own inline
checkbox. Exposes window.grTeekaInterleave so readerToolbar.js's
"Commentaries" dropdown can stay in sync without duplicating this
logic — same pattern as window.grHighlightTocForElement above.
═══════════════════════════════════════════════════════════════ */
(function () {
var LS_TEEKA_KEY = 'grantha_reader_teeka_in_moola';
// Distinct light background per commentary, for when multiple
// different teekas are interleaved together and need to stay
// visually distinguishable. CHANGED: was a 30-color palette
// transcribed from a screenshot - confirmed directly that at least
// one entry (#F6C500, a saturated gold) wasn't actually light at
// all, which is very likely what read as "really dark" in practice.
// Replaced with 10 colors in the site's own warm palette (matching
// the maroon/cream tones already used throughout Common.css -
// #b5451b, #fdf0eb, #f0cfc4), all kept in the ~94-97% lightness
// range so they stay easy to read against dark text regardless of
// which one a given teeka lands on. First entry is #fcf5f3 - the
// exact background .teeka-block itself already uses on a native
// teeka page (verified at 97.1% lightness), per direction, so the
// most common single-commentary case matches the site's own
// existing look exactly rather than introducing a new shade for it.
var TEEKA_COLOR_PALETTE = [
'#fcf5f3', '#F5EFE6', '#F0F5EC', '#EEF3F6', '#F6EFF3',
'#F3F0F8', '#F8F3EA', '#EEF6F2', '#F6EEEE', '#F2F0EC'
];
// Stable djb2-style hash - same teeka slug always maps to the SAME
// color everywhere on the site (not re-randomized per page load or
// fetch order) - verified directly: a reader learning "this shade =
// भावप्रकाशः" needs that to hold site-wide.
function teekaColorForSlug(slug) {
var h = 5381;
for (var i = 0; i < slug.length; i++) h = ((h * 33) ^ slug.charCodeAt(i)) >>> 0;
return TEEKA_COLOR_PALETTE[h % TEEKA_COLOR_PALETTE.length];
}
function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function fetchAllSubpages(rootTitle) {
var deferred = $.Deferred(), subpages = [], nsNum = mw.config.get('wgNamespaceNumber'), localName = mw.config.get('wgTitle');
function ask(apfrom) {
var params = { action: 'query', list: 'allpages', apprefix: localName + '/', apnamespace: nsNum, aplimit: 'max', format: 'json' };
if (apfrom) params.apcontinue = apfrom;
$.ajax({ url: mw.util.wikiScript('api'), data: params, dataType: 'json' }).then(function (data) {
var pages = (data.query && data.query.allpages) || [];
pages.forEach(function (p) { subpages.push(p.title); });
if (data.continue && data.continue.apcontinue) ask(data.continue.apcontinue);
else deferred.resolve(subpages);
}).fail(function () { deferred.resolve(subpages); });
}
ask(null);
return deferred.promise();
}
function fetchTeekaJson(title) {
return $.ajax({
url: mw.util.wikiScript('api'),
data: { action: 'query', titles: 'JSON:' + title, prop: 'revisions', rvslots: 'main', rvprop: 'content', format: 'json', formatversion: 2 },
dataType: 'json'
}).then(function (data) {
var pages = (data.query && data.query.pages) || [];
var page = pages[0];
if (!page || page.missing) return null;
var content = page.revisions && page.revisions[0] && page.revisions[0].slots.main.content;
if (!content) return null;
try { var parsed = JSON.parse(content); parsed._title = title; return parsed; }
catch (e) { return null; }
}).catch(function () { return null; });
}
// Finds this document's commentary subpages - filters to the site's
// own established commentary-path convention (Vyakhya/Bhashya are
// shown; Moola/Ullekha are never commentary content).
function findCommentarySubpages(rootTitle) {
return $.Deferred(function (d) {
fetchAllSubpages(rootTitle).then(function (all) {
d.resolve(all.filter(function (t) {
return t !== rootTitle && (/\/vyakhya(\/|$)/i.test(t) || /\/bhashya(\/|$)/i.test(t));
}));
});
}).promise();
}
// Builds moola-verse-id -> [{teekaTitle, teekaSlug, text, blockId}].
// Handles TWO possible parent-linking shapes so this works across
// every commentary document on the site, not just ones from any one
// generation pipeline: (1) DIRECT - a type:"teeka" block's own
// "parent" already IS the moola verse id; (2) TWO-HOP - it points to
// a type:"verse" block WITHIN THE SAME teeka doc (a quoted-verse
// reference), and THAT block's own parent is the moola verse id.
// Deliberately skips anything whose parent is "UNVERIFIED" or
// missing, rather than guessing.
// CHANGED: also builds a slug -> color-palette-index map here, based
// on teekaDocs' OWN ORDER (the order commentaries were actually
// discovered/fetched for this page) rather than hashing each slug
// independently. Confirmed directly this was the actual issue: hash-
// based assignment gives every teeka a color, but nothing guarantees
// the FIRST one shown on a page lands on palette slot 0 (#fcf5f3,
// the site's own real .teeka-block default) - it gets whatever slot
// its hash happens to produce. Positional assignment makes "first
// shown = the exact default color" a guarantee, not a coincidence,
// at the cost of a specific teeka's color now depending on where it
// appears in a given page's own list rather than being fixed
// site-wide - a reasonable trade given the direction received.
var teekaColorIndexBySlug = {};
function buildTeekaInMoolaIndex(teekaDocs) {
var index = {};
teekaColorIndexBySlug = {};
var colorIdx = 0;
teekaDocs.forEach(function (td) {
if (!td || !td.blocks || !td.doc) return;
var slug = td.doc.slug || td._title;
if (!(slug in teekaColorIndexBySlug)) {
teekaColorIndexBySlug[slug] = colorIdx % TEEKA_COLOR_PALETTE.length;
colorIdx++;
}
var blockById = {};
td.blocks.forEach(function (b) { blockById[b.id] = b; });
td.blocks.forEach(function (b) {
if (b.type !== 'teeka') return;
var moolaId = null;
if (b.parent && b.parent !== 'UNVERIFIED' && !blockById[b.parent]) {
moolaId = b.parent;
} else {
var verseRef = blockById[b.parent];
if (verseRef && (verseRef.type === 'verse' || verseRef.type === 'verse-ref') && verseRef.parent && verseRef.parent !== 'UNVERIFIED') {
moolaId = verseRef.parent;
}
}
if (!moolaId) return;
if (!index[moolaId]) index[moolaId] = [];
index[moolaId].push({
teekaTitle: td.doc.title || td._title,
teekaSlug: slug,
text: (b.lines || []).join(' '),
blockId: b.id
});
});
});
return index;
}
function injectTeekaInMoola(index) {
var injectedAny = false;
Object.keys(index).forEach(function (verseId) {
var $verseEl = $(document.getElementById(verseId));
if (!$verseEl.length) return;
var entries = index[verseId];
var $wrap = $('<div>').addClass('gr-injected-teeka-group');
entries.forEach(function (entry) {
// CHANGED: was teekaColorForSlug(slug) (hash-based) -
// now looks up the position assigned in
// buildTeekaInMoolaIndex, falling back to the hash only
// if this slug is somehow missing from that map (should
// not normally happen, but safer than an undefined color).
var colorIdx = teekaColorIndexBySlug[entry.teekaSlug];
var color = (colorIdx !== undefined) ? TEEKA_COLOR_PALETTE[colorIdx] : teekaColorForSlug(entry.teekaSlug);
var $entry = $('<div>').addClass('teeka-block')
.attr('data-teeka-slug', entry.teekaSlug)
.attr('data-teeka-block-id', entry.blockId)
.css('background', color);
$('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry);
$('<div>').addClass('teeka-body').html(entry.text).appendTo($entry);
$wrap.append($entry);
});
$verseEl.after($wrap);
// Tell the script switcher about the new text, so an interleaved
// commentary is transliterated like the rest of the page instead
// of staying in Devanagari.
try {
window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: $wrap[0] } }));
} catch (e) { }
injectedAny = true;
});
return injectedAny;
}
function removeTeekaInMoola() { $('.gr-injected-teeka-group').remove(); }
// CRITICAL FIX: confirmed directly this feature was corrupting saved
// page content. QuickEdit is a contenteditable-based editor operating
// on the page's LIVE DOM - since this feature injects real elements
// (interleaved teeka blocks, inline checkboxes wrapping links) into
// that same DOM, QuickEdit's save/serialization pipeline had no way
// to distinguish them from genuine content, and was saving them back
// into the page's actual wikitext as literal escaped HTML text. This
// teardown strips every trace of this feature's DOM changes -
// removing injected teeka blocks entirely, and for inline checkboxes,
// specifically UNWRAPPING each link back to its own original
// position (not just deleting the wrapper, which would also delete
// the link itself) - restoring the DOM to exactly what the page's
// real, saved content actually is, before any editor can see it.
function teardownTeekaInterleave() {
removeTeekaInMoola();
$('.gr-teeka-cb-wrap').each(function () {
var $wrap = $(this);
var $link = $wrap.find('a');
if ($link.length) $wrap.before($link);
$wrap.remove();
});
// CHANGED: also strips the gr-teeka-list-item class added to any
// <li> for bullet-removal - confirmed via direct test this was
// missed initially, which would have left a class attribute
// change in the saved content even after the wrapper/checkbox
// itself was correctly removed. Also removes the class
// ATTRIBUTE entirely (not just the class name) once empty -
// classList.remove() alone leaves class="" behind, which a
// byte-for-byte test caught as still not matching the true
// original markup (which never had a class attribute at all).
$('.gr-teeka-list-item').each(function () {
this.classList.remove('gr-teeka-list-item');
if (!this.className) this.removeAttribute('class');
});
}
// Watches for the exact DOM signal that editing has actually begun -
// Common.css targets .mw-body-content[contenteditable="true"]
// directly, confirming this is the real, load-order-independent
// trigger QuickEdit itself relies on, rather than trying to
// intercept every possible UI path that could invoke it (edit
// button, keyboard shortcut, or anything else) individually and
// risk missing one.
function watchForEditMode() {
var target = document.querySelector('.mw-body-content') || document.body;
if (!target || !window.MutationObserver) return;
var wasEditing = false;
var obs = new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
if (m.attributeName !== 'contenteditable') return;
var isEditingNow = m.target.getAttribute('contenteditable') === 'true';
if (isEditingNow && !wasEditing) {
teardownTeekaInterleave();
wasEditing = true;
} else if (!isEditingNow && wasEditing) {
// Editing ended without a page reload (e.g. cancelled) -
// restore using the already-fetched data cached in
// memory, no need to re-fetch anything from the server.
if (teekaIndex) renderInterleave();
if (lastAugmentArgs) {
var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
}
wasEditing = false;
}
});
});
obs.observe(target, { attributes: true, attributeFilter: ['contenteditable'], subtree: true });
}
function normalizeWikiPath(p) {
try { p = decodeURIComponent(p); } catch (e) { }
return p.replace(/_/g, ' ').replace(/\/+$/, '');
}
// ── State ──────────────────────────────────────────────────────
var selectedTeekaSlugs = {};
try { JSON.parse(readLS(LS_TEEKA_KEY) || '[]').forEach(function (s) { selectedTeekaSlugs[s] = true; }); } catch (e) { }
var teekaIndex = null;
var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
var lastAugmentArgs = null; // {titles, docs} - cached to restore inline checkboxes after a cancelled edit without re-fetching
var checkboxesBySlug = {};
var readyCallbacks = [];
var changeCallbacks = [];
var visibilityCallbacks = [];
var isReady = false;
var inlineListVisible = null; // null = no inline checkboxes exist at all on this page
function registerCheckbox(slug, $cb) { (checkboxesBySlug[slug] = checkboxesBySlug[slug] || []).push($cb); }
function renderInterleave() {
removeTeekaInMoola();
if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return;
var filtered = {};
Object.keys(teekaIndex).forEach(function (verseId) {
var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; });
if (entries.length) filtered[verseId] = entries;
});
var injectedAny = injectTeekaInMoola(filtered);
if (!injectedAny) console.warn('[grTeekaInterleave] Selected commentaries loaded, but none of their verse ids matched any verse-block on THIS page.');
}
function toggleSlug(slug, checked) {
if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug];
writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs)));
(checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); });
renderInterleave();
changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); });
}
// Augments existing <a> links pointing to a known commentary subpage
// with an inline checkbox, wherever the link already appears -
// matched by real href rather than any assumed list markup, since
// that markup isn't something this script controls.
// CHANGED: wraps the checkbox AND the link together in one flex
// container, rather than just wrapping the checkbox alone next to
// an untouched sibling link - confirmed directly (screenshot, twice
// now) that aligning a wrapped checkbox against ambient text-
// baseline metrics alone still leaves a small vertical offset,
// especially with the site's custom Devanagari font's own metrics.
// Flexbox align-items:center between the checkbox and the link's
// own line box is a more reliable way to center them against EACH
// OTHER directly, rather than each independently against the
// surrounding line. The link is moved (not cloned) into the
// wrapper at its original DOM position, so its href/click behavior
// and the surrounding "\u2014 author" text's sibling order are both
// preserved exactly as before.
function augmentExistingTeekaLinks(subpageTitles, docs) {
var slugByPath = {};
subpageTitles.forEach(function (title, i) {
var doc = docs[i];
if (!doc) return;
slugByPath[normalizeWikiPath(mw.util.getUrl(title))] = doc.doc.slug || doc._title;
});
var $contentLinks = $('#mw-content-text a[href], .mw-parser-output a[href]');
var augmented = [];
$contentLinks.each(function () {
var href = this.getAttribute('href') || '';
var path;
try { path = normalizeWikiPath(new URL(href, window.location.href).pathname); }
catch (e) { return; }
var slug = slugByPath[path];
if (!slug || this.closest('.gr-injected-teeka-group')) return;
var li = this.closest('li');
if (li) li.classList.add('gr-teeka-list-item');
var $anchor = $(this);
var $wrap = $('<span>').addClass('gr-teeka-cb-wrap');
var $cb = $('<input>').attr({ type: 'checkbox' }).addClass('gr-teeka-inline-checkbox').prop('checked', !!selectedTeekaSlugs[slug]);
$anchor.before($wrap);
$wrap.append($cb);
$wrap.append($anchor);
registerCheckbox(slug, $cb);
$cb.on('change', function () { toggleSlug(slug, $cb.is(':checked')); });
augmented.push({ slug: slug, el: $cb[0] });
});
var missedTitles = subpageTitles.filter(function (t, i) {
var d = docs[i]; if (!d) return false;
var s = d.doc.slug || d._title;
return !augmented.some(function (a) { return a.slug === s; });
});
if (missedTitles.length) console.warn('[grTeekaInterleave] No matching link found on this page for: ' + missedTitles.join(', ') + ' - the toolbar dropdown still covers them.');
return augmented;
}
// Tracks whether ANY inline checkbox is currently on-screen, and
// notifies subscribers only when that overall state actually flips -
// verified in isolation before wiring in here. Drives whether
// readerToolbar.js's "Commentaries" control needs to be shown as a
// fallback (inline list scrolled out of view) or can stay hidden
// (reader can already see and use the inline checkboxes).
function observeInlineVisibility(checkboxEls) {
if (!checkboxEls.length || !window.IntersectionObserver) return;
var visibleSet = new Set();
var obs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var id = entry.target.__grTeekaId;
if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id);
});
var anyVisible = visibleSet.size > 0;
if (anyVisible !== inlineListVisible) {
inlineListVisible = anyVisible;
visibilityCallbacks.forEach(function (cb) { cb(anyVisible); });
}
}, { rootMargin: '-60px 0px 0px 0px', threshold: 0 });
checkboxEls.forEach(function (el, i) { el.__grTeekaId = 'cb' + i; obs.observe(el); });
}
function init() {
// Runs unconditionally, before any of the early-return checks
// below - harmless as a no-op teardown on pages where nothing
// ever gets injected, but critical to have active on every page
// this feature COULD have injected into, regardless of which
// exact code path led here.
watchForEditMode();
var ns = mw.config.get('wgNamespaceNumber');
var pageName = mw.config.get('wgPageName') || '';
var isMainPg = (pageName === 'Main_Page' || pageName === 'Main Page');
var isTeekaPage = !!document.querySelector('.gr-teeka-page[data-interleaved="1"], .gr-parent-ref-block');
if (ns !== 0 || isMainPg || isTeekaPage) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
var rootTitle = mw.config.get('wgTitle');
findCommentarySubpages(rootTitle).then(function (subpages) {
if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
$.when.apply($, subpages.map(fetchTeekaJson)).then(function () {
var fetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
// Pair title+doc together FIRST, then filter as one unit -
// filtering `docs` separately from `subpages` and passing
// both (now misaligned) arrays elsewhere was a confirmed
// real bug in an earlier version of this feature.
var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; })
.filter(function (p) {
if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title);
return !!p.doc;
});
// Reading order = the order the book prints the
// commentaries in, which a document states as doc.order.
// findCommentarySubpages gets its list from
// action=query&list=allpages, and that is ALPHABETICAL BY
// TITLE - so Vadiraja came before Vyasatirtha, and the
// checkbox list, the colour slots and the interleaved
// blocks all inherited that same wrong order, since every
// one of them is derived from this array. Sorting here
// fixes all three at once. A document with no doc.order
// (anything published before this field existed) keeps its
// existing relative position rather than jumping to the
// front, so mixed pages stay stable.
pairs = pairs.map(function (p, i) { return { p: p, i: i }; })
.sort(function (a, b) {
var ao = a.p.doc && a.p.doc.doc && a.p.doc.doc.order;
var bo = b.p.doc && b.p.doc.doc && b.p.doc.doc.order;
if (typeof ao === 'number' && typeof bo === 'number' && ao !== bo) return ao - bo;
if (typeof ao === 'number' && typeof bo !== 'number') return -1;
if (typeof bo === 'number' && typeof ao !== 'number') return 1;
return a.i - b.i;
})
.map(function (x) { return x.p; });
var topDocs = pairs.map(function (p) { return p.doc; });
// NEW: sub-commentaries (tippanis) - a teeka doc declares
// its own children via doc.related (an array of slugs,
// matching the same field this session's own generator
// already writes into new JSON docs). Only the ASK here
// is to show these as sub-categories in the options list
// and gate their visibility on the parent being checked
// - so this only needs one more level of fetching, not
// unbounded recursion.
var relatedSlugs = [];
topDocs.forEach(function (d) { (d.doc.related || []).forEach(function (s) { relatedSlugs.push(s); }); });
var uniqueRelated = relatedSlugs.filter(function (s, i) { return relatedSlugs.indexOf(s) === i; });
function finishInit(subDocsBySlug) {
var allDocs = topDocs.slice();
Object.keys(subDocsBySlug).forEach(function (slug) { if (subDocsBySlug[slug]) allDocs.push(subDocsBySlug[slug]); });
teekaIndex = buildTeekaInMoolaIndex(allDocs);
availableList = topDocs.map(function (d) {
var subItems = (d.doc.related || [])
.map(function (s) { return subDocsBySlug[s]; })
.filter(Boolean)
.map(function (sd) { return { slug: sd.doc.slug || sd._title, title: sd.doc.title || sd._title }; });
return { slug: d.doc.slug || d._title, title: d.doc.title || d._title, subItems: subItems };
});
// Cached at module level so watchForEditMode can
// correctly re-run link augmentation after a
// cancelled edit, without needing to re-fetch
// anything from the server.
lastAugmentArgs = { titles: pairs.map(function (p) { return p.title; }), docs: topDocs };
var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
else inlineListVisible = false; // no inline checkboxes exist - toolbar control should always show as the only access point
if (Object.keys(selectedTeekaSlugs).length) renderInterleave();
isReady = true;
readyCallbacks.forEach(function (cb) { cb(availableList); });
if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); });
}
if (!uniqueRelated.length) { finishInit({}); return; }
$.when.apply($, uniqueRelated.map(fetchTeekaJson)).then(function () {
var subFetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
var subDocsBySlug = {};
uniqueRelated.forEach(function (slug, i) {
if (!subFetched[i]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
subDocsBySlug[slug] = subFetched[i];
});
finishInit(subDocsBySlug);
});
});
});
}
window.grTeekaInterleave = {
teardown: teardownTeekaInterleave,
onReady: function (cb) { if (isReady) cb(availableList); else readyCallbacks.push(cb); },
onChange: function (cb) { changeCallbacks.push(cb); },
onVisibilityChange: function (cb) { visibilityCallbacks.push(cb); if (inlineListVisible !== null) cb(inlineListVisible); },
getSelected: function () { return Object.keys(selectedTeekaSlugs); },
toggle: function (slug, checked) { toggleSlug(slug, checked); },
registerCheckbox: registerCheckbox
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
}());
/* ═══════════════════════════════════════════════════════════════
Devanagari page titles, site-wide (window.grTitles)
───────────────────────────────────────────────────────────────
One source of truth for "what is this page called": the Main Page
catalogue, where every published work and commentary is listed as
[[Slug|देवनागरी नाम]]. update_main_page.py keeps that list current,
so a newly pushed commentary gets its Devanagari name everywhere
without anyone editing a hard-coded list.
Used by:
• readerToolbar.js search results (grDocDisplayTitle & co. ask
grTitles first, then fall back to their own GR_DOC_TITLES map)
• the Updates page
• this block itself, which relabels any link on the page whose
text is still the English slug (e.g. the "Vadavali · Bhavavivaranam"
page-nav line), the mobile page heading, and the browser tab title.
The catalogue is cached in localStorage for CACHE_HOURS and refreshed
in the background, so lookups are synchronous on almost every view.
═══════════════════════════════════════════════════════════════ */
(function () {
'use strict';
var CATALOGUE_PAGE = 'Main_Page';
var CACHE_KEY = 'gr_titles_v1';
var CACHE_HOURS = 1;
var DEVA = /[ऀ-ॿ]/;
var full = {}; // "Katha/Vyakhya/VyasatirthaVivarana" → "श्रीव्यासतीर्थविरचितविवरणम्"
var leaf = {}; // "VyasatirthaVivarana" → same; null when two pages share a leaf
var waiting = [];
var loaded = false;
function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function script(name) {
if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
return (cfg('wgScriptPath') || '') + '/' + name + '.php';
}
function add(title, label, override) {
title = norm(title); label = String(label || '').replace(/<[^>]*>/g, '').trim();
if (!title || !label || !DEVA.test(label)) return;
if (override || !full[title]) full[title] = label;
var l = title.split('/').pop();
if (!(l in leaf) || override) leaf[l] = label;
else if (leaf[l] !== label) leaf[l] = null; // ambiguous leaf: don't guess
}
function parseCatalogue(txt) {
var out = {}, re = /\[\[([^\]|#]+)\|([^\]]+)\]\]/g, m;
while ((m = re.exec(txt || ''))) {
var k = norm(m[1]);
if (!out[k]) out[k] = m[2].replace(/<[^>]*>/g, '').trim();
}
return out;
}
function useCatalogue(map) {
full = {}; leaf = {};
Object.keys(map).forEach(function (k) { add(k, map[k]); });
addCurrentPage();
}
// The page being viewed names itself in .gr-doc-title — covers a page
// that isn't on the Main Page yet.
function addCurrentPage() {
var el = document.querySelector('#mw-content-text .gr-doc-title');
var me = norm(cfg('wgPageName'));
if (el && me && !full[me]) add(me, el.textContent);
}
// ── public API ────────────────────────────────────────────────
function get(title) { return full[norm(title)] || null; }
function getLeaf(seg) { var v = leaf[norm(seg)]; return v || null; }
function whenReady(cb) { if (loaded) cb(); else waiting.push(cb); }
function all() { var o = {}; for (var k in full) o[k] = full[k]; return o; }
window.grTitles = { get: get, leaf: getLeaf, all: all, whenReady: whenReady, parse: parseCatalogue };
function finish() {
loaded = true;
var cbs = waiting; waiting = [];
cbs.forEach(function (cb) { try { cb(); } catch (e) { } });
relabel();
if (window.mw && mw.hook) mw.hook('gr.titles').fire(window.grTitles);
}
// ── relabel what's on screen ──────────────────────────────────
function articleTitleFromHref(href) {
var u;
try { u = new URL(href, location.href); } catch (e) { return null; }
if (u.origin !== location.origin) return null;
var t = u.searchParams.get('title');
if (!t) {
var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0];
if (u.pathname.indexOf(ap) !== 0) return null;
t = u.pathname.slice(ap.length);
}
try { t = decodeURIComponent(t); } catch (e) { }
return norm(t);
}
function relabel(root) {
root = root || document;
var links = root.querySelectorAll('#mw-content-text a[href], .gr-sticky-title a[href], #mw-panel a[href], .vector-toc a[href]');
Array.prototype.forEach.call(links, function (a) {
if (a.children.length) return; // leave rich links alone
var text = norm(a.textContent);
if (!text || DEVA.test(text)) return; // already Devanagari
var target = articleTitleFromHref(a.getAttribute('href')) || norm(a.getAttribute('title'));
if (!target) return;
var tail = target.split('/').pop();
if (text !== target && text !== tail) return; // only replace a bare slug
var label = get(target) || (text === tail ? getLeaf(tail) : null);
if (label) a.textContent = label;
});
// Page heading (shown on mobile) and the browser tab
var me = norm(cfg('wgPageName'));
var myLabel = get(me);
if (!myLabel) return;
var h = document.querySelector('#firstHeading .mw-page-title-main') || document.getElementById('firstHeading');
if (h && !h.children.length && norm(h.textContent) === me) h.textContent = myLabel;
var wgTitle = norm(cfg('wgTitle'));
if (wgTitle && document.title.indexOf(wgTitle) === 0) document.title = myLabel + document.title.slice(wgTitle.length);
else if (document.title.indexOf(me) === 0) document.title = myLabel + document.title.slice(me.length);
}
// ── load ─────────────────────────────────────────────────────
function fetchFresh() {
return fetch(script('index') + '?title=' + CATALOGUE_PAGE + '&action=raw', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
.then(function (txt) {
var map = parseCatalogue(txt);
lsSet(CACHE_KEY, JSON.stringify({ at: Date.now(), map: map }));
return map;
});
}
function boot() {
var cached = null;
try { cached = JSON.parse(lsGet(CACHE_KEY) || 'null'); } catch (e) { }
if (cached && cached.map) { useCatalogue(cached.map); finish(); }
var stale = !cached || (Date.now() - cached.at > CACHE_HOURS * 3600000);
if (!stale) return;
fetchFresh().then(function (map) {
useCatalogue(map);
if (!loaded) finish(); else relabel();
}).catch(function () { if (!loaded) { addCurrentPage(); finish(); } });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
// Content that arrives later (previews, lazy sections, search panes)
if (window.mw && mw.hook) mw.hook('wikipage.content').add(function ($c) {
if (loaded) { addCurrentPage(); relabel($c && $c[0] ? $c[0] : document); }
});
}());
/* ═══════════════════════════════════════════════════════════════
Header links "Library" and "Updates", and the Updates page
───────────────────────────────────────────────────────────────
Desktop (Vector): "Library" and "Updates" added before "Help".
Mobile (Minerva): the same two items in the menu panel, after "Home".
"Updates" opens the page My_wiki:Updates, which has two parts:
1. What's new — filled in automatically from the wiki's edit history
into <div id="gr-updates-feed">. Nothing to maintain by hand:
• New a page created in the last WINDOW_DAYS days
• Updated an existing page whose edits in that window add up to
at least UPDATE_MIN_BYTES (so typo fixes don't show)
Titles are the Devanagari names from window.grTitles (Main Page catalogue).
Test pages, helper subpages (/Ullekha, /Moola, /C1 …) and deleted
pages are left out.
2. Announcements & events — a card editors fill by hand with
{{Bulletin|type=…|date=…|title=…|text=…}} lines on that page.
Styling: MediaWiki:Common.css §37 (no inline styles here).
═══════════════════════════════════════════════════════════════ */
(function () {
'use strict';
var LIBRARY_URL = 'https://dvaita-grantha-hierarchy-library.ai.studio/';
var LIBRARY_LABEL = 'Library';
var UPDATES_LABEL = 'Updates';
var UPDATES_PAGE = 'My_wiki:Updates'; // the page the "Updates" link opens
var WINDOW_DAYS = 90; // how far back the list looks
var UPDATE_MIN_BYTES = 1500; // edits smaller than this in total aren't "updates"
var MAX_ITEMS = 100; // rows shown on the page
var CACHE_MINUTES = 10; // per-tab cache so every page view isn't an API call
var CATALOGUE_PAGE = 'Main_Page';
var SKIP_TITLE = /^(Main Page|Test|Sandbox)|(^|\/)(Ullekha|Moola|C\d+)$|(^|\/)(Test|Teeka|Tippani)\d*$/i;
var HIDE = []; // exact titles never to list, e.g. ['Sumadhwavijaya/Vyakhya/पदार्थदीपिका']
var SEEN_KEY = 'gr_updates_seen';
var CACHE_KEY = 'gr_updates_cache_v3';
var isMobile = document.body.classList.contains('skin-minerva');
// ── helpers ────────────────────────────────────────────────
function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
function wikiHref(title) {
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
var ap = cfg('wgArticlePath') || '/wiki/$1';
return ap.replace('$1', encodeURIComponent(title.replace(/ /g, '_')).replace(/%2F/g, '/').replace(/%3A/g, ':'));
}
function script(name) {
if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
return (cfg('wgScriptPath') || '') + '/' + name + '.php';
}
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function absDate(d) { return MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); }
// Today · 1 day ago · 5 days ago (under a week) · Sep 16, 2026
function relDate(d, now) {
now = now || new Date();
var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
var b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
var days = Math.round((b - a) / 86400000);
if (days <= 0) return 'Today';
if (days === 1) return '1 day ago';
if (days < 7) return days + ' days ago';
return absDate(d);
}
// ── data ───────────────────────────────────────────────────
function getJSON(url) {
return fetch(url, { credentials: 'same-origin' }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function api(params) {
var q = ['format=json', 'formatversion=2'];
for (var k in params) q.push(k + '=' + encodeURIComponent(params[k]));
return getJSON(script('api') + '?' + q.join('&'));
}
// Devanagari names come from window.grTitles (the Main Page catalogue,
// cached) — the same names search and links use.
function fetchCatalogue() {
return new Promise(function (resolve) {
if (window.grTitles) window.grTitles.whenReady(function () { resolve(window.grTitles.all()); });
else resolve({});
});
}
// Every main-namespace change in the window, oldest→newest pages grouped.
function fetchChanges() {
var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z');
var all = [];
function page(cont) {
var p = {
action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0,
rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: 500, rcend: since
};
if (cont) p.rccontinue = cont;
return api(p).then(function (j) {
all = all.concat((j.query && j.query.recentchanges) || []);
// Two pages (1,000 changes) is plenty for a notifications list.
if (j['continue'] && j['continue'].rccontinue && all.length < 1000) return page(j['continue'].rccontinue);
return all;
});
}
return page(null);
}
function summarise(changes, catalogue) {
var byTitle = {};
changes.forEach(function (c) {
var t = norm(c.title);
if (SKIP_TITLE.test(t) || HIDE.indexOf(t) !== -1) return;
var e = byTitle[t] || (byTitle[t] = { title: t, created: null, last: null, bytes: 0 });
var ts = new Date(c.timestamp);
if (c.type === 'new') e.created = ts;
else e.bytes += Math.abs((c.newlen || 0) - (c.oldlen || 0));
if (!e.last || ts > e.last) e.last = ts;
});
var out = [];
Object.keys(byTitle).forEach(function (t) {
var e = byTitle[t];
var listed = !!catalogue[t];
// Pages the catalogue doesn't know about still count if they look like
// real content: a top-level work or a commentary under /Vyakhya/.
if (!listed && t.indexOf('/') !== -1 && !/\/Vyakhya\/[^/]+$/.test(t)) return;
if (e.created) out.push({ title: t, kind: 'new', when: e.created });
else if (e.bytes >= UPDATE_MIN_BYTES) out.push({ title: t, kind: 'update', when: e.last });
});
out.forEach(function (x) { x.listed = !!catalogue[x.title]; x.label = labelFor(x.title, catalogue); x.sub = subFor(x.title, catalogue); });
return out.sort(function (a, b) { return b.when - a.when; });
}
function labelFor(t, cat) {
if (cat[t]) return cat[t];
var parts = t.split('/');
return parts[parts.length - 1];
}
// Second line: which work a commentary belongs to.
function subFor(t, cat) {
var m = /^([^/]+)\/Vyakhya\//.exec(t);
if (!m) return '';
return cat[m[1]] ? cat[m[1]] + ' — टीका' : 'टीका';
}
// Drop pages created in the window and deleted since.
function dropMissing(items) {
if (!items.length) return Promise.resolve(items);
var batches = [];
for (var i = 0; i < items.length; i += 50) batches.push(items.slice(i, i + 50));
return Promise.all(batches.map(function (b) {
return api({ action: 'query', prop: 'info', inprop: 'displaytitle', titles: b.map(function (x) { return x.title; }).join('|') })
.then(function (j) {
var gone = {}, shown = {};
((j.query && j.query.pages) || []).forEach(function (p) {
if (p.missing) gone[norm(p.title)] = true;
var dt = (p.displaytitle || '').replace(/<[^>]*>/g, '').trim();
if (dt && norm(dt) !== norm(p.title)) shown[norm(p.title)] = dt;
});
return b.filter(function (x) {
// A DISPLAYTITLE only helps pages the catalogue doesn't name.
if (shown[x.title] && !x.listed) x.label = shown[x.title];
return !gone[x.title];
});
}).catch(function () { return b; });
})).then(function (r) { return [].concat.apply([], r); });
}
function loadUpdates() {
var cached = ssGet(CACHE_KEY);
if (cached) {
try {
var c = JSON.parse(cached);
if (Date.now() - c.at < CACHE_MINUTES * 60000) {
return Promise.resolve(c.items.map(function (x) { x.when = new Date(x.when); return x; }));
}
} catch (e) { }
}
return Promise.all([fetchChanges(), fetchCatalogue()]).then(function (r) {
return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10));
}).then(function (items) {
items = items.slice(0, MAX_ITEMS);
ssSet(CACHE_KEY, JSON.stringify({ at: Date.now(), items: items }));
return items;
});
}
function lastSeen() { return +lsGet(SEEN_KEY) || 0; }
function markSeen() { lsSet(SEEN_KEY, String(Date.now())); }
// ── Updates page: the automatic list ─────────────────────────
// Filled into <div id="gr-updates-feed"> on UPDATES_PAGE.
// All styling lives in Common.css §37.
var KIND = { 'new': ['New', 'gr-upd-new'], 'update': ['Updated', 'gr-upd-update'] };
// Today · This week · This month · then one group per month ("August 2026")
var MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function groupName(d, now) {
var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
var b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
var days = Math.round((b - a) / 86400000);
if (days <= 0) return 'Today';
if (days < 7) return 'This week';
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) return 'Earlier this month';
return MONTHS_LONG[d.getMonth()] + ' ' + d.getFullYear();
}
function renderFeed(box, items, seenBefore) {
while (box.firstChild) box.removeChild(box.firstChild);
if (items === null) { box.appendChild(el('p', 'gr-upd-empty', 'Loading…')); return; }
if (!items.length) { box.appendChild(el('p', 'gr-upd-empty', 'No new or updated pages in the last ' + WINDOW_DAYS + ' days.')); return; }
var now = new Date(), group = null, list = null;
items.forEach(function (x) {
var g = groupName(x.when, now);
if (g !== group) {
group = g;
// A div, not an h3: the site's heading rules would restyle an h3.
var h = el('div', 'gr-upd-group', g);
h.setAttribute('role', 'heading'); h.setAttribute('aria-level', '3');
box.appendChild(h);
list = el('div', 'gr-upd-list');
list.setAttribute('role', 'list');
box.appendChild(list);
}
var li = el('div', 'gr-upd-item' + (seenBefore && x.when.getTime() > seenBefore ? ' gr-upd-unseen' : ''));
li.setAttribute('role', 'listitem');
var main = el('div', 'gr-upd-main');
var a = el('a', 'gr-upd-title', x.label);
a.href = wikiHref(x.title);
main.appendChild(a);
var sub = el('div', 'gr-upd-sub');
sub.appendChild(el('span', 'gr-upd-tag ' + KIND[x.kind][1], KIND[x.kind][0]));
if (x.sub) sub.appendChild(el('span', 'gr-upd-work', x.sub));
main.appendChild(sub);
li.appendChild(main);
var d = el('time', 'gr-upd-date', relDate(x.when, now));
d.setAttribute('datetime', x.when.toISOString());
d.title = absDate(x.when);
li.appendChild(d);
list.appendChild(li);
});
}
// ── Updates page: the bulletin card ─────────────────────────
// Items are written on the page with {{Bulletin|…}}; this only
// formats their dates, marks past events, and shows an empty note.
function decorateBulletin() {
var card = document.querySelector('.gr-bulletin');
if (!card) return;
var now = new Date(), today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
var items = card.querySelectorAll('.gr-bulletin-item');
Array.prototype.forEach.call(items, function (it) {
var raw = it.getAttribute('data-date') || '';
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
var d = m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
var slot = it.querySelector('.gr-bulletin-date');
if (d && slot) { slot.textContent = absDate(d); slot.setAttribute('title', raw); }
// Past events stay listed, dimmed; hide them with until= on the item.
if (d && it.getAttribute('data-type') === 'event' && d < today) it.classList.add('gr-bulletin-past');
var until = /^(\d{4})-(\d{2})-(\d{2})$/.exec((it.getAttribute('data-until') || '').trim());
if (until && new Date(+until[1], +until[2] - 1, +until[3]) < today) it.classList.add('gr-bulletin-expired');
});
var visible = card.querySelectorAll('.gr-bulletin-item:not(.gr-bulletin-expired)').length;
card.classList.toggle('gr-bulletin-is-empty', visible === 0);
var layout = card.closest('.gr-updates-layout');
if (layout) layout.classList.toggle('gr-no-bulletin', visible === 0);
}
function initUpdatesPage() {
var box = document.getElementById('gr-updates-feed');
decorateBulletin();
if (!box || box.getAttribute('data-ready')) return;
box.setAttribute('data-ready', '1');
var seenBefore = lastSeen();
renderFeed(box, null);
loadUpdates().then(function (items) {
renderFeed(box, items, seenBefore);
markSeen();
}).catch(function () { renderFeed(box, []); });
}
// ── header links ─────────────────────────────────────────────
function updatesHref() { return wikiHref(UPDATES_PAGE); }
// Which wiki page a link points at ("My wiki:Help"), or null.
function linkTarget(a) {
var href = a.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return null; // e.g. the Log out button
var u;
try { u = new URL(href, location.href); } catch (e) { return null; }
if (u.origin !== location.origin) return null;
var t = u.searchParams.get('title');
if (!t) {
var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0];
if (u.pathname.indexOf(ap) !== 0) return null;
t = u.pathname.slice(ap.length);
}
try { t = decodeURIComponent(t); } catch (e) { }
return norm(t);
}
// Highlight whichever header / menu link is the page being viewed —
// Help, About, Updates, Profile … not only the ones this block adds.
function markCurrent() {
var me = norm(cfg('wgPageName'));
if (!me) return;
var links = document.querySelectorAll('.gr-header-link, #gr-header-user-menu a, .gr-mob-item');
Array.prototype.forEach.call(links, function (a) {
var on = a.tagName === 'A' && linkTarget(a) === me;
a.classList.toggle('is-current', on);
if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
});
}
function injectDesktop() {
if (document.getElementById('gr-library-link')) return true;
var help = document.getElementById('gr-help-link');
var host = help ? help.parentNode :
(document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header'));
if (!host) return false;
var lib = el('a', 'gr-header-link', LIBRARY_LABEL);
lib.id = 'gr-library-link';
lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
lib.title = 'Grantha catalogue (opens in a new tab)';
var upd = el('a', 'gr-header-link', UPDATES_LABEL);
upd.id = 'gr-updates-link';
upd.href = updatesHref();
if (help) { host.insertBefore(lib, help); host.insertBefore(upd, help); }
else { host.appendChild(lib); host.appendChild(upd); }
markCurrent();
return true;
}
function injectMobile() {
if (document.getElementById('gr-mob-library')) return true;
var menu = document.getElementById('gr-mob-menu-items');
if (!menu) return false;
var lib = el('a', 'gr-mob-item', LIBRARY_LABEL);
lib.id = 'gr-mob-library';
lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
var upd = el('a', 'gr-mob-item', UPDATES_LABEL);
upd.id = 'gr-mob-updates';
upd.href = updatesHref();
var home = null;
for (var i = 0; i < menu.children.length; i++) {
if (/^\s*Home\s*$/.test(menu.children[i].textContent)) { home = menu.children[i]; break; }
}
var ref = home ? home.nextSibling : menu.firstChild;
menu.insertBefore(lib, ref);
menu.insertBefore(upd, ref);
markCurrent();
return true;
}
// ── boot: the Help/About and mobile-menu code creates the anchors on
// DOMContentLoaded, so wait briefly for them rather than assume order.
function boot() {
initUpdatesPage();
var inject = isMobile ? injectMobile : injectDesktop;
if (inject()) return;
var tries = 0;
var obs = window.MutationObserver ? new MutationObserver(function () { if (inject()) obs.disconnect(); }) : null;
if (obs) obs.observe(document.body, { childList: true, subtree: true });
(function poll() {
if (inject()) { if (obs) obs.disconnect(); return; }
if (++tries < 40) setTimeout(poll, 250); else if (obs) obs.disconnect();
}());
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
// Page previews and live-preview re-render content; re-run the page part then.
if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { initUpdatesPage(); });
window.grUpdates = { relDate: relDate, summarise: summarise, groupName: groupName };
}());
/* ═══════════════════════════════════════════════════════════════
Main Page cards: item count beside each title, and a
.gr-home-card-wide class on long lists. Styling: Common.css §25.
═══════════════════════════════════════════════════════════════ */
(function () {
'use strict';
var WIDE_AT = 14; // cards with this many items or more span two columns
function decorate() {
var cards = document.querySelectorAll('.gr-home .gr-home-card');
Array.prototype.forEach.call(cards, function (card) {
var title = card.querySelector('.gr-home-card-title');
var n = card.querySelectorAll('li').length;
if (!n) return;
if (n >= WIDE_AT) card.classList.add('gr-home-card-wide');
if (!title || title.querySelector('.gr-home-card-count')) return;
var label = document.createElement('span');
while (title.firstChild) label.appendChild(title.firstChild);
title.appendChild(label);
var c = document.createElement('span');
c.className = 'gr-home-card-count';
c.textContent = String(n);
title.appendChild(c);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
else decorate();
}());
// ── Footnote markers and their cards ────────────────────────────────
/* QuickEdit already stores footnotes: it wraps the annotated words in
<span class="gra-qe-footnote-highlight" data-gra-id="…"> and appends a
<div class="gra-qe-footnotes"><ol> at the foot of the document, one
<li data-gra-id="…"> per note carrying the quoted words and the note.
That is the whole apparatus, but reading it meant scrolling to the end of
a long document and scrolling back. This turns the same data into what
Setutila shows: a small superscript after the annotated words, and a card
at the point of reading. Nothing is stored differently and QuickEdit is
untouched — the list is still written, still saved, and still printed; it
is only taken off the screen, where the markers replace it.
The note text is transliterated with the rest of the page: the card's body
is tagged data-deva and announced through gr-new-content, which the script
switcher already listens for. */
(function () {
'use strict';
var HL = '.gra-qe-footnote-highlight';
var LIST = '.gra-qe-footnotes';
// data-gra-type on the <li>. The names the converter writes, and the
// ones a QuickEdit button could write later, land in the same map.
var TYPES = {
pathantara: 'पाठान्तरम्', // a variant reading
locus: 'प्रमाणम्', // where a quotation is from — ऋ.सं.७.९९
source: 'प्रमाणम्',
note: 'टिप्पणी' // an editorial remark
};
var card = null, openFor = null;
function notesFromList() {
var out = {};
document.querySelectorAll(LIST + ' .gra-qe-footnote-item').forEach(function (li) {
var id = li.getAttribute('data-gra-id');
if (!id) return;
var textEl = li.querySelector('.gra-qe-footnote-text');
out[id] = {
text: (textEl ? textEl.textContent : li.textContent).trim(),
quote: (li.querySelector('.gra-qe-footnote-quote') || {}).textContent || '',
type: li.getAttribute('data-gra-type') || 'note'
};
});
return out;
}
function buildCard() {
if (card) return card;
card = document.createElement('div');
card.className = 'gr-note-card';
card.setAttribute('role', 'dialog');
card.hidden = true;
card.innerHTML =
'<div class="gr-note-card-head">' +
'<span class="gr-note-card-kind"></span>' +
'<button type="button" class="gr-note-card-close" aria-label="Close">×</button>' +
'</div><div class="gr-note-card-body"></div>';
card.querySelector('.gr-note-card-close').addEventListener('click', close);
document.body.appendChild(card);
return card;
}
function close() {
if (!card || card.hidden) return;
card.hidden = true;
if (openFor) {
openFor.setAttribute('aria-expanded', 'false');
openFor.classList.remove('gr-note-marker-open');
}
openFor = null;
}
// Anchored under the marker, nudged back inside the viewport rather than
// flipped: these sit in running text, and a card that jumps above the line
// is harder to connect to the word it belongs to than one that shifts left.
function place(marker) {
var r = marker.getBoundingClientRect();
var w = card.offsetWidth, h = card.offsetHeight;
var pad = 10;
var left = r.left + r.width / 2 - w / 2;
left = Math.max(pad, Math.min(left, window.innerWidth - w - pad));
var top = r.bottom + 8;
if (top + h > window.innerHeight - pad && r.top - h - 8 > pad) top = r.top - h - 8;
card.style.left = Math.round(left) + 'px';
card.style.top = Math.round(top) + 'px';
}
function open(marker, note, num) {
buildCard();
if (openFor === marker && !card.hidden) { close(); return; }
close();
card.querySelector('.gr-note-card-kind').textContent =
(TYPES[note.type] || TYPES.note) + ' [' + num + ']';
var body = card.querySelector('.gr-note-card-body');
// A bare text node, deliberately. gr-new-content's handler wraps it,
// records it as translatable and applies whatever script is showing —
// tagging it here instead would make the handler skip it as already
// done, and the note would sit in Devanagari on a Telugu page.
body.textContent = note.text;
card.hidden = false;
place(marker);
try {
window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: body } }));
} catch (e) { }
marker.setAttribute('aria-expanded', 'true');
marker.classList.add('gr-note-marker-open');
openFor = marker;
}
function decorate() {
var notes = notesFromList();
var n = 0;
document.querySelectorAll('#mw-content-text ' + HL).forEach(function (hl) {
var id = hl.getAttribute('data-gra-id');
var note = id && notes[id];
if (!note || !note.text) return; // orphan highlight: leave it alone
n++;
if (hl.nextElementSibling &&
hl.nextElementSibling.classList.contains('gr-note-marker')) {
hl.nextElementSibling.textContent = '[' + n + ']';
return;
}
var m = document.createElement('sup');
m.className = 'gr-note-marker';
m.textContent = '[' + n + ']';
m.setAttribute('role', 'button');
m.setAttribute('tabindex', '0');
m.setAttribute('aria-expanded', 'false');
var num = n;
function show(e) { e.preventDefault(); e.stopPropagation(); open(m, note, num); }
m.addEventListener('click', show);
m.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(m, note, num); }
});
// The annotated words open it as well. On a phone the marker is a
// small target, and the words are what the reader is looking at.
hl.addEventListener('click', show);
hl.parentNode.insertBefore(m, hl.nextSibling);
});
var list = document.querySelector(LIST);
if (list) list.classList.toggle('gr-notes-listed', n > 0);
}
document.addEventListener('click', function (e) {
if (!card || card.hidden) return;
if (!card.contains(e.target)) close();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
window.addEventListener('resize', function () { if (openFor) place(openFor); });
window.addEventListener('scroll', function () { if (openFor) place(openFor); }, true);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
else decorate();
if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { setTimeout(decorate, 150); });
}());