Jump to content

MediaWiki:Common.js: Difference between revisions

From ZelocoreCMS Wiki
Light-theme JS rewrite
v2.0: Dark mode toggle, animated counters, helpful widget, copy buttons, scroll reveal, keyboard shortcuts
Line 1: Line 1:
/* ============================================================
 
   ZelocoreCMS Wiki — MediaWiki:Common.js
/* ════════════════════════════════════════════════════════════════════
   Light-theme compatible enhancements
   ZelocoreCMS Wiki — Enhanced Common.js (v2.0 world-class upgrade)
   ============================================================ */
   Features: dark mode, animated counters, helpful widget,
/* global mw, $ */
            keyboard shortcuts, reading progress, back-to-top
   ════════════════════════════════════════════════════════════════════ */
( function () {
( function () {
     'use strict';
     'use strict';


     var qs  = function ( s, c ) { return ( c || document ).querySelector( s ); };
     var qs  = function ( s, ctx ) { return ( ctx || document ).querySelector( s ); };
     var qsa = function ( s, c ) { return Array.prototype.slice.call( ( c || document ).querySelectorAll( s ) ); };
     var qsa = function ( s, ctx ) { return Array.from( ( ctx || document ).querySelectorAll( s ) ); };


     function ready( fn ) {
     function ready( fn ) {
         if ( document.readyState !== 'loading' ) { fn(); } else { document.addEventListener( 'DOMContentLoaded', fn ); }
         if ( document.readyState !== 'loading' ) { fn(); }
        else { document.addEventListener( 'DOMContentLoaded', fn ); }
     }
     }


     /* ── 1. Reading Progress Bar ─────────────────────────── */
     /* ── Reading progress bar ─────────────────────────── */
     function initProgressBar() {
     function initProgress() {
         var bar = document.createElement( 'div' );
         var bar = document.createElement( 'div' );
         bar.id = 'zc-reading-progress';
         bar.id = 'cms-progress';
        Object.assign( bar.style, {
         document.body.prepend( bar );
            position: 'fixed', top: '0', left: '0', height: '3px', width: '0%',
            background: '#3366cc', zIndex: '9999', transition: 'width 0.1s linear',
            pointerEvents: 'none'
        } );
         document.body.appendChild( bar );
         window.addEventListener( 'scroll', function () {
         window.addEventListener( 'scroll', function () {
             var doc = document.documentElement;
             var h = document.documentElement;
             var pct = ( window.scrollY / ( doc.scrollHeight - window.innerHeight ) ) * 100;
             var pct = ( h.scrollTop / ( h.scrollHeight - h.clientHeight ) ) * 100;
             bar.style.width = Math.min( pct, 100 ) + '%';
             bar.style.width = Math.min( pct, 100 ) + '%';
         }, { passive: true } );
         }, { passive: true } );
     }
     }


     /* ── 2. Back-to-Top Button ───────────────────────────── */
     /* ── Back to top ──────────────────────────────────── */
     function initBackToTop() {
     function initBackTop() {
         var btn = document.createElement( 'button' );
         var btn = document.createElement( 'button' );
         btn.id = 'zc-back-to-top';
         btn.id = 'cms-back-top';
        btn.textContent = '↑';
         btn.title = 'Back to top';
         btn.title = 'Back to top';
         Object.assign( btn.style, {
         btn.innerHTML = '';
            position: 'fixed', bottom: '24px', right: '24px',
            width: '40px', height: '40px', borderRadius: '50%',
            background: '#3366cc', border: 'none', color: '#fff',
            fontSize: '1.1rem', fontWeight: '700', cursor: 'pointer',
            opacity: '0', transition: 'opacity 0.25s ease, transform 0.25s ease',
            boxShadow: '0 2px 8px rgba(0,0,0,0.25)', zIndex: '998', transform: 'scale(0.8)'
        } );
         document.body.appendChild( btn );
         document.body.appendChild( btn );
         window.addEventListener( 'scroll', function () {
         window.addEventListener( 'scroll', function () {
            var show = window.scrollY > 400;
             btn.classList.toggle( 'visible', window.scrollY > 400 );
            btn.style.opacity  = show ? '1' : '0';
             btn.style.transform = show ? 'scale(1)' : 'scale(0.8)';
         }, { passive: true } );
         }, { passive: true } );
         btn.addEventListener( 'click', function () {
         btn.addEventListener( 'click', function () {
Line 56: Line 43:
     }
     }


     /* ── 3. Keyboard Shortcut: / or Ctrl+K focuses search ── */
     /* ── Dark mode toggle ─────────────────────────────── */
    function initDarkMode() {
        var btn = document.createElement( 'button' );
        btn.id = 'cms-dark-toggle';
        btn.title = 'Toggle dark mode';
        btn.innerHTML = '🌙';
        document.body.appendChild( btn );
 
        var isDark = localStorage.getItem( 'cms-dark' ) === '1';
        if ( isDark ) { document.body.classList.add( 'cms-dark-mode' ); btn.innerHTML = '☀️'; }
 
        btn.addEventListener( 'click', function () {
            isDark = !isDark;
            document.body.classList.toggle( 'cms-dark-mode', isDark );
            btn.innerHTML = isDark ? '☀️' : '🌙';
            localStorage.setItem( 'cms-dark', isDark ? '1' : '0' );
        } );
    }
 
    /* ── Animated number counters (main page stats) ───── */
    function animateCounter( el, target ) {
        var start = 0;
        var duration = 1400;
        var startTime = null;
        function step( ts ) {
            if ( !startTime ) { startTime = ts; }
            var progress = Math.min( ( ts - startTime ) / duration, 1 );
            var ease = 1 - Math.pow( 1 - progress, 3 );
            el.textContent = Math.floor( ease * target ).toLocaleString();
            if ( progress < 1 ) { requestAnimationFrame( step ); }
            else { el.textContent = target.toLocaleString(); }
        }
        requestAnimationFrame( step );
    }
 
    function initCounters() {
        qsa( '.mp-stat-num' ).forEach( function ( el ) {
            var raw = el.textContent.replace( /,/g, '' );
            var num = parseInt( raw, 10 );
            if ( !isNaN( num ) && num > 0 ) {
                var obs = new IntersectionObserver( function ( entries, o ) {
                    if ( entries[0].isIntersecting ) {
                        animateCounter( el, num );
                        o.disconnect();
                    }
                }, { threshold: 0.3 } );
                obs.observe( el );
            }
        } );
    }
 
    /* ── "Was this helpful?" widget ───────────────────── */
    function initHelpfulWidget() {
        var body = qs( '#mw-content-text .mw-parser-output' );
        if ( !body ) { return; }
        // Only on article pages, not main page
        if ( document.body.classList.contains( 'page-Main_Page' ) ) { return; }
        if ( !mw.config.get( 'wgIsArticle' ) ) { return; }
 
        var widget = document.createElement( 'div' );
        widget.id  = 'cms-helpful';
        widget.innerHTML = [
            '<span id="cms-helpful-label">Was this article helpful?</span>',
            '<button class="cms-helpful-btn yes" data-v="yes">👍 Yes</button>',
            '<button class="cms-helpful-btn no"  data-v="no">👎 No</button>',
            '<span id="cms-helpful-thanks">Thanks for your feedback! 🎉</span>'
        ].join( '' );
        body.appendChild( widget );
 
        qsa( '.cms-helpful-btn', widget ).forEach( function ( btn ) {
            btn.addEventListener( 'click', function () {
                qsa( '.cms-helpful-btn', widget ).forEach( function ( b ) { b.classList.remove( 'active' ); } );
                btn.classList.add( 'active' );
                qs( '#cms-helpful-thanks' ).style.display = 'inline';
            } );
        } );
    }
 
    /* ── Keyboard shortcut: Ctrl+K = focus search ─────── */
     function initSearchShortcut() {
     function initSearchShortcut() {
        var input = qs( '#searchInput, .cdx-text-input__input, input[name="search"]' );
        if ( !input ) { return; }
         document.addEventListener( 'keydown', function ( e ) {
         document.addEventListener( 'keydown', function ( e ) {
            var active = document.activeElement;
             if ( ( e.ctrlKey || e.metaKey ) && e.key === 'k' ) {
            var isEditing = active && ( active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable );
             if ( ( ( e.ctrlKey || e.metaKey ) && e.key === 'k' ) || ( e.key === '/' && !isEditing ) ) {
                 e.preventDefault();
                 e.preventDefault();
                 input.focus();
                 var input = qs( '#searchInput, .cdx-text-input__input' );
                input.select();
                if ( input ) { input.focus(); input.select(); }
             }
             }
         } );
         } );
     }
     }


     /* ── 4. Code Block Copy Button ───────────────────────── */
     /* ── Copy buttons on <pre> blocks ─────────────────── */
     function addCopyButtons() {
     function addCopyButtons() {
         qsa( 'pre, .mw-highlight' ).forEach( function ( block ) {
         qsa( 'pre' ).forEach( function ( pre ) {
             if ( qs( '.zc-copy-btn', block ) ) { return; }
             if ( qs( '.cms-copy-btn', pre ) ) { return; }
             var btn = document.createElement( 'button' );
             var btn = document.createElement( 'button' );
             btn.className = 'zc-copy-btn';
             btn.className = 'cms-copy-btn';
             btn.textContent = 'Copy';
             btn.textContent = 'Copy';
             Object.assign( btn.style, {
             btn.style.cssText = 'position:absolute;top:8px;right:10px;padding:3px 10px;' +
                position: 'absolute', top: '6px', right: '6px',
                 'background:rgba(255,255,255,.15);color:#e2e8f0;border:1px solid rgba(255,255,255,.25);' +
                 background: '#f8f9fa', border: '1px solid #a2a9b1',
                 'border-radius:4px;font-size:.78em;cursor:pointer;transition:background .2s;';
                 color: '#54595d', fontSize: '0.75rem', padding: '2px 10px',
            btn.addEventListener( 'mouseenter', function () { btn.style.background = 'rgba(255,255,255,.28)'; } );
                borderRadius: '2px', cursor: 'pointer', fontFamily: 'inherit'
            btn.addEventListener( 'mouseleave', function () { btn.style.background = 'rgba(255,255,255,.15)'; } );
            } );
             btn.addEventListener( 'click', function () {
             btn.addEventListener( 'click', function () {
                var text = block.textContent.replace( /^Copy$|^✓ Copied$/m, '' ).trim();
                 navigator.clipboard.writeText( pre.innerText ).then( function () {
                 navigator.clipboard.writeText( text ).then( function () {
                     btn.textContent = 'Copied!';
                     btn.textContent = 'Copied';
                     setTimeout( function () { btn.textContent = 'Copy'; }, 2000 );
                    btn.style.color = '#14866d';
                    btn.style.borderColor = '#14866d';
                     setTimeout( function () {
                        btn.textContent = 'Copy';
                        btn.style.color = '#54595d';
                        btn.style.borderColor = '#a2a9b1';
                    }, 2000 );
                 } );
                 } );
             } );
             } );
             block.style.position = 'relative';
             pre.style.position = 'relative';
             block.appendChild( btn );
             pre.appendChild( btn );
         } );
         } );
     }
     }


     /* ── 5. Scroll-reveal for elements ──────────────────── */
     /* ── Scroll reveal for portal cells ──────────────── */
     function initScrollReveal() {
     function initScrollReveal() {
         if ( !window.IntersectionObserver ) { return; }
         if ( !window.IntersectionObserver ) { return; }
Line 108: Line 161:
             entries.forEach( function ( entry ) {
             entries.forEach( function ( entry ) {
                 if ( entry.isIntersecting ) {
                 if ( entry.isIntersecting ) {
                     entry.target.style.opacity = '1';
                     entry.target.style.opacity = '1';
                     entry.target.style.transform = 'translateY(0)';
                     entry.target.style.transform = 'translateY(0)';
                     obs.unobserve( entry.target );
                     obs.unobserve( entry.target );
                 }
                 }
             } );
             } );
         }, { threshold: 0.08 } );
         }, { threshold: 0.06 } );
         qsa( '.mp-portal-cell' ).forEach( function ( el ) {
         qsa( '.mp-portal-cell, .mp-cat-card, .mp-card, .mp-stat' ).forEach( function ( el ) {
             el.style.opacity = '0';
             el.style.opacity   = '0';
             el.style.transform = 'translateY(12px)';
             el.style.transform = 'translateY(14px)';
             el.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
             el.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
             obs.observe( el );
             obs.observe( el );
Line 122: Line 175:
     }
     }


     /* ── 6. Smooth anchor scrolling ─────────────────────── */
     /* ── Table row highlight ──────────────────────────── */
    function initSmoothScroll() {
        qsa( 'a[href^="#"]' ).forEach( function ( a ) {
            a.addEventListener( 'click', function ( e ) {
                var id = decodeURIComponent( this.hash.slice( 1 ) );
                var target = document.getElementById( id );
                if ( !target ) { return; }
                e.preventDefault();
                window.scrollTo( {
                    top: target.getBoundingClientRect().top + window.scrollY - 70,
                    behavior: 'smooth'
                } );
            } );
        } );
    }
 
    /* ── 7. TOC scroll-spy (highlight current heading) ──── */
    function initTOCSpy() {
        var headings = qsa( '.mw-body-content h2, .mw-body-content h3' );
        var tocLinks = qsa( '#toc a, .toc a' );
        if ( !headings.length || !tocLinks.length ) { return; }
        var ticking = false;
        window.addEventListener( 'scroll', function () {
            if ( ticking ) { return; }
            ticking = true;
            requestAnimationFrame( function () {
                var scrollY = window.scrollY + 80;
                var current = '';
                headings.forEach( function ( h ) {
                    if ( h.getBoundingClientRect().top + window.scrollY <= scrollY ) {
                        current = '#' + h.id;
                    }
                } );
                tocLinks.forEach( function ( link ) {
                    var isCurrent = link.getAttribute( 'href' ) === current;
                    link.style.fontWeight = isCurrent ? 'bold' : '';
                    link.style.color = isCurrent ? '#202122' : '';
                } );
                ticking = false;
            } );
        }, { passive: true } );
    }
 
    /* ── 8. Table row highlight ─────────────────────────── */
     function initTableHighlight() {
     function initTableHighlight() {
         qsa( '.wikitable tbody tr' ).forEach( function ( row ) {
         qsa( '.wikitable tbody tr' ).forEach( function ( row ) {
             row.addEventListener( 'mouseenter', function () {
             row.style.transition = 'background .15s';
                qsa( 'td', this ).forEach( function ( td ) {
                    td.style.background = '#eaf3fb';
                } );
            } );
            row.addEventListener( 'mouseleave', function () {
                qsa( 'td', this ).forEach( function ( td ) {
                    td.style.background = '';
                } );
            } );
         } );
         } );
     }
     }


     /* ── 9. Auto external link icon ─────────────────────── */
     /* ── External link icon ───────────────────────────── */
     function initExternalLinks() {
     function initExternalLinks() {
         qsa( '.mw-body-content a.external' ).forEach( function ( a ) {
         qsa( '.mw-body-content a.external' ).forEach( function ( a ) {
             if ( !qs( '.ext-icon', a ) ) {
             if ( !qs( '.cms-ext', a ) ) {
                 var icon = document.createElement( 'span' );
                 var s = document.createElement( 'span' );
                 icon.className = 'ext-icon';
                 s.className = 'cms-ext';
                 icon.setAttribute( 'aria-hidden', 'true' );
                 s.setAttribute( 'aria-hidden', 'true' );
                 icon.textContent = ' ↗';
                 s.textContent = ' ↗';
                 icon.style.cssText = 'font-size:0.75em; color:#72777d; margin-left:1px;';
                 s.style.cssText = 'font-size:.72em;color:#7c3aed;margin-left:1px;';
                 a.appendChild( icon );
                 a.appendChild( s );
            }
        } );
    }
 
    /* ── 10. Sidebar active link highlight ──────────────── */
    function initSidebarHighlight() {
        var currentPath = window.location.pathname;
        qsa( '#mw-panel a, .vector-menu-content-list a' ).forEach( function ( a ) {
            if ( a.pathname && a.pathname !== '/' && currentPath === a.pathname ) {
                a.style.fontWeight = 'bold';
                a.style.color = '#202122';
             }
             }
         } );
         } );
     }
     }


     /* ── Bootstrap ──────────────────────────────────────── */
     /* ── Bootstrap ────────────────────────────────────── */
     ready( function () {
     ready( function () {
         initProgressBar();
         initProgress();
         initBackToTop();
         initBackTop();
        initDarkMode();
        initCounters();
         initSearchShortcut();
         initSearchShortcut();
        initSmoothScroll();
        initTOCSpy();
        initSidebarHighlight();
         initScrollReveal();
         initScrollReveal();
         initTableHighlight();
         initTableHighlight();
         initExternalLinks();
         initExternalLinks();
         setTimeout( addCopyButtons, 400 );
         setTimeout( addCopyButtons, 500 );
        setTimeout( initHelpfulWidget, 300 );


        // Re-run after VisualEditor deactivates
         if ( mw && mw.hook ) {
         if ( mw && mw.hook ) {
             mw.hook( 've.deactivate' ).add( function () {
             mw.hook( 've.deactivate' ).add( function () {

Revision as of 21:37, 29 July 2026

/* ════════════════════════════════════════════════════════════════════
   ZelocoreCMS Wiki — Enhanced Common.js  (v2.0 world-class upgrade)
   Features: dark mode, animated counters, helpful widget,
             keyboard shortcuts, reading progress, back-to-top
   ════════════════════════════════════════════════════════════════════ */
( function () {
    'use strict';

    var qs  = function ( s, ctx ) { return ( ctx || document ).querySelector( s ); };
    var qsa = function ( s, ctx ) { return Array.from( ( ctx || document ).querySelectorAll( s ) ); };

    function ready( fn ) {
        if ( document.readyState !== 'loading' ) { fn(); }
        else { document.addEventListener( 'DOMContentLoaded', fn ); }
    }

    /* ── Reading progress bar ─────────────────────────── */
    function initProgress() {
        var bar = document.createElement( 'div' );
        bar.id = 'cms-progress';
        document.body.prepend( bar );
        window.addEventListener( 'scroll', function () {
            var h = document.documentElement;
            var pct = ( h.scrollTop / ( h.scrollHeight - h.clientHeight ) ) * 100;
            bar.style.width = Math.min( pct, 100 ) + '%';
        }, { passive: true } );
    }

    /* ── Back to top ──────────────────────────────────── */
    function initBackTop() {
        var btn = document.createElement( 'button' );
        btn.id = 'cms-back-top';
        btn.title = 'Back to top';
        btn.innerHTML = '↑';
        document.body.appendChild( btn );
        window.addEventListener( 'scroll', function () {
            btn.classList.toggle( 'visible', window.scrollY > 400 );
        }, { passive: true } );
        btn.addEventListener( 'click', function () {
            window.scrollTo( { top: 0, behavior: 'smooth' } );
        } );
    }

    /* ── Dark mode toggle ─────────────────────────────── */
    function initDarkMode() {
        var btn = document.createElement( 'button' );
        btn.id = 'cms-dark-toggle';
        btn.title = 'Toggle dark mode';
        btn.innerHTML = '🌙';
        document.body.appendChild( btn );

        var isDark = localStorage.getItem( 'cms-dark' ) === '1';
        if ( isDark ) { document.body.classList.add( 'cms-dark-mode' ); btn.innerHTML = '☀️'; }

        btn.addEventListener( 'click', function () {
            isDark = !isDark;
            document.body.classList.toggle( 'cms-dark-mode', isDark );
            btn.innerHTML = isDark ? '☀️' : '🌙';
            localStorage.setItem( 'cms-dark', isDark ? '1' : '0' );
        } );
    }

    /* ── Animated number counters (main page stats) ───── */
    function animateCounter( el, target ) {
        var start = 0;
        var duration = 1400;
        var startTime = null;
        function step( ts ) {
            if ( !startTime ) { startTime = ts; }
            var progress = Math.min( ( ts - startTime ) / duration, 1 );
            var ease = 1 - Math.pow( 1 - progress, 3 );
            el.textContent = Math.floor( ease * target ).toLocaleString();
            if ( progress < 1 ) { requestAnimationFrame( step ); }
            else { el.textContent = target.toLocaleString(); }
        }
        requestAnimationFrame( step );
    }

    function initCounters() {
        qsa( '.mp-stat-num' ).forEach( function ( el ) {
            var raw = el.textContent.replace( /,/g, '' );
            var num = parseInt( raw, 10 );
            if ( !isNaN( num ) && num > 0 ) {
                var obs = new IntersectionObserver( function ( entries, o ) {
                    if ( entries[0].isIntersecting ) {
                        animateCounter( el, num );
                        o.disconnect();
                    }
                }, { threshold: 0.3 } );
                obs.observe( el );
            }
        } );
    }

    /* ── "Was this helpful?" widget ───────────────────── */
    function initHelpfulWidget() {
        var body = qs( '#mw-content-text .mw-parser-output' );
        if ( !body ) { return; }
        // Only on article pages, not main page
        if ( document.body.classList.contains( 'page-Main_Page' ) ) { return; }
        if ( !mw.config.get( 'wgIsArticle' ) ) { return; }

        var widget = document.createElement( 'div' );
        widget.id  = 'cms-helpful';
        widget.innerHTML = [
            '<span id="cms-helpful-label">Was this article helpful?</span>',
            '<button class="cms-helpful-btn yes" data-v="yes">👍 Yes</button>',
            '<button class="cms-helpful-btn no"  data-v="no">👎 No</button>',
            '<span id="cms-helpful-thanks">Thanks for your feedback! 🎉</span>'
        ].join( '' );
        body.appendChild( widget );

        qsa( '.cms-helpful-btn', widget ).forEach( function ( btn ) {
            btn.addEventListener( 'click', function () {
                qsa( '.cms-helpful-btn', widget ).forEach( function ( b ) { b.classList.remove( 'active' ); } );
                btn.classList.add( 'active' );
                qs( '#cms-helpful-thanks' ).style.display = 'inline';
            } );
        } );
    }

    /* ── Keyboard shortcut: Ctrl+K = focus search ─────── */
    function initSearchShortcut() {
        document.addEventListener( 'keydown', function ( e ) {
            if ( ( e.ctrlKey || e.metaKey ) && e.key === 'k' ) {
                e.preventDefault();
                var input = qs( '#searchInput, .cdx-text-input__input' );
                if ( input ) { input.focus(); input.select(); }
            }
        } );
    }

    /* ── Copy buttons on <pre> blocks ─────────────────── */
    function addCopyButtons() {
        qsa( 'pre' ).forEach( function ( pre ) {
            if ( qs( '.cms-copy-btn', pre ) ) { return; }
            var btn = document.createElement( 'button' );
            btn.className = 'cms-copy-btn';
            btn.textContent = 'Copy';
            btn.style.cssText = 'position:absolute;top:8px;right:10px;padding:3px 10px;' +
                'background:rgba(255,255,255,.15);color:#e2e8f0;border:1px solid rgba(255,255,255,.25);' +
                'border-radius:4px;font-size:.78em;cursor:pointer;transition:background .2s;';
            btn.addEventListener( 'mouseenter', function () { btn.style.background = 'rgba(255,255,255,.28)'; } );
            btn.addEventListener( 'mouseleave', function () { btn.style.background = 'rgba(255,255,255,.15)'; } );
            btn.addEventListener( 'click', function () {
                navigator.clipboard.writeText( pre.innerText ).then( function () {
                    btn.textContent = '✅ Copied!';
                    setTimeout( function () { btn.textContent = 'Copy'; }, 2000 );
                } );
            } );
            pre.style.position = 'relative';
            pre.appendChild( btn );
        } );
    }

    /* ── Scroll reveal for portal cells ──────────────── */
    function initScrollReveal() {
        if ( !window.IntersectionObserver ) { return; }
        var obs = new IntersectionObserver( function ( entries ) {
            entries.forEach( function ( entry ) {
                if ( entry.isIntersecting ) {
                    entry.target.style.opacity  = '1';
                    entry.target.style.transform = 'translateY(0)';
                    obs.unobserve( entry.target );
                }
            } );
        }, { threshold: 0.06 } );
        qsa( '.mp-portal-cell, .mp-cat-card, .mp-card, .mp-stat' ).forEach( function ( el ) {
            el.style.opacity   = '0';
            el.style.transform = 'translateY(14px)';
            el.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
            obs.observe( el );
        } );
    }

    /* ── Table row highlight ──────────────────────────── */
    function initTableHighlight() {
        qsa( '.wikitable tbody tr' ).forEach( function ( row ) {
            row.style.transition = 'background .15s';
        } );
    }

    /* ── External link icon ───────────────────────────── */
    function initExternalLinks() {
        qsa( '.mw-body-content a.external' ).forEach( function ( a ) {
            if ( !qs( '.cms-ext', a ) ) {
                var s = document.createElement( 'span' );
                s.className = 'cms-ext';
                s.setAttribute( 'aria-hidden', 'true' );
                s.textContent = ' ↗';
                s.style.cssText = 'font-size:.72em;color:#7c3aed;margin-left:1px;';
                a.appendChild( s );
            }
        } );
    }

    /* ── Bootstrap ────────────────────────────────────── */
    ready( function () {
        initProgress();
        initBackTop();
        initDarkMode();
        initCounters();
        initSearchShortcut();
        initScrollReveal();
        initTableHighlight();
        initExternalLinks();
        setTimeout( addCopyButtons, 500 );
        setTimeout( initHelpfulWidget, 300 );

        if ( mw && mw.hook ) {
            mw.hook( 've.deactivate' ).add( function () {
                setTimeout( addCopyButtons, 600 );
            } );
        }
    } );

}() );