Jump to content

User:Gibcus/common.js: Difference between revisions

From BioMicro Center
No edit summary
No edit summary
 
(12 intermediate revisions by the same user not shown)
Line 1: Line 1:
#FORM 541715
/**
#NEW PROJECT SETUP
* BioMicro Center Wiki Wrapper — Full DOM Replacement
*
* Strategy:
* 1. Extract wiki article content from MediaWiki's DOM
* 2. Inject the complete shell structure
* 3. Place article content inside the shell
* MediaWiki still runs underneath for editing, search, login, etc.
*/


FORMNAME:
( function () {
    AA (QC)
  'use strict';


#FORM SPECIFIC
  /* ── Extract nav from MediaWiki's sidebar DOM ─────────────── */
NEW PROJECT:
  function extractNavFromDom() {
    #<==test==> only one of [ILAB::QC_std_types] should be filled. else [WARN:"Mutliple services selected in Illumina QC"]
    var SKIP_IDS = {
    EXCEPTION_UNIQUE:
      'p-tb': 1, 'p-personal': 1, 'p-search': 1,
        ILAB::QC_std_types:
      'p-logo': 1, 'p-cactions': 1, 'p-views': 1,
            True
      'p-lang': 1, 'p-namespaces': 1
        #ILAB::RNA_std_types:
     };
        #    True
        ILAB::DNA_std_types:
            True
        ALL:
            True
    ATTRIBUTES:
        DNA_Project::Date_submission:
            ILAB::TODAY_DATE
        DNA_Project::userName:
            ILAB::Kerberose
        DNA_Project::userName_CC:
            ILAB::ccEmail
        DNA_Project::iLAB_UID:
            ILAB::ILAB_UID
        DNA_Project::Service:
            BioA_AA
        DNA_Project::ReferTo:
            ILAB::referTo
        DNA_Project::EnteredBy:
            ILAB
        #DNA_Project::LastModified:
        #    ILAB::TODAY_DATE
        DNA_Project::SampleType:
            ILAB::Sample_Type:
                DNA:
                    ILAB::DNA_Assay:
                        IlluminaQC:
                            ShortReadLibrary
                        genomic (>8kb):
                            gDNA
                        DEFAULT:
                            DNA
                RNA:
                    RNA
        DNA_Project::QC_Assay:
            ILAB::Sample_Type:
                DNA:
                    ILAB::DNA_Assay
                RNA:
                    ILAB::RNA_Assay
        DNA_Project::iLABemail:
            ILAB::Kerberose
        DNA_Project::Subservice_BioA_AA:
            OPERATOR:
                ADDITION_STRING
            VALUE:
                ILAB::Sample_Type:
                    DNA:
                        ILAB::DNA_Assay:
                            IlluminaQC:
                                ILAB::QC_std_types:
                                    261739:
                                        FA+qPCR
                                    288673:
                                        SYBR+qPCR
                                    261738:
                                        qPCR only
                                    455504:
                                        qPCR preload plate
                            DEFAULT:
                                ILAB::DNA_std_types:
                                    261734:
                                        FA
                                    261735:
                                        FA preload
                                    261736:
                                        BioA_hsDNA
                                    334243:
                                        FP
                                    348097:
                                        FP preload
                    RNA:
                        ILAB::RNA_std_types:
                            261734:
                                FA nano
                            261735:
                                FA nano preload
                            261736:
                                BioA nano
                            334243:
                                FP RNA
                            348097:
                                FP RNA preload
                            551777:
                                FF Scroll for QC
                            551778:
                                FFPE Scroll for QC
                       
        DNA_Project::NoSamples:
            #<==test==> only one of [ILAB::QC_std_types] should be filled. else [WARN:"Mutliple services selected in Illumina QC"]
            #EXCEPTION_UNIQUE:
            #    ILAB::QC_std_types:
            #        True
            #    ILAB::DNA_std_types:
            #        True
            OPERATOR:
                ADDITION_INT
            VALUE:
                ILAB::Sample_Type:
                    DNA:
                        ILAB::QC_std_types:
                            261739:
                                261739
                            288673:
                                288673
                            261738:
                                261738
                            455504:
                                455504
                        ILAB::DNA_std_types:
                            261734:
                                261734
                            261735:
                                261735
                            261736:
                                261736
                            334243:
                                334243
                            348097:
                                348097
                    RNA:
                        ILAB::RNA_std_types:
                            261734:
                                261734
                            261735:
                                261735
                            261736:
                                261736
                            334243:
                                334243
                            348097:
                                348097
                            551777:
                                551777
                            551778:
                                551778
NEW REQUIREMENT:
    EXCEPTION_NOTNULL:
        ILAB::Project_Notes
     ATTRIBUTES:
        Requirements::Project:
            TBD
        Requirements::Text:
            ILAB::Project_Notes
        Requirements::Category:
            "ilabNote"
        Requirements::Date:
            ILAB::TODAY_DATE
        Requirements::Author:
            ILAB
               


#<==test==> if numROWS in [ILAB::Sample_table] != DNA_Project::NoSample
    /* Scope to the sidebar container to exclude Vector 2022 header user-menu portlets
#    [DIE:"Mismatch in number of samples and number of services"]
      (p-vector-user-menu-userpage, p-user-menu-logout, p-personal-sticky-header, etc.) */
#foreach ROW in [ILAB::Sample_table]
    var navContainer = document.getElementById( 'vector-main-menu' ) ||
NEW DNA_db:
                      document.getElementById( 'mw-panel' )        ||
    TABLE:
                      document.getElementById( 'mw-navigation' )    ||
         ILAB::Sample_table
                      document.body;
    EXCEPTION_MATCH:
 
         #DNA_Project::NoSample
    var portlets = Array.prototype.slice.call(
         # Only if [FORM:Sample_Type] == DNA
      navContainer.querySelectorAll( '[id^="p-"]' )
        ILAB::Sample_Type:
    );
            DNA
 
     ATTRIBUTES:
    var nav = [];
         #DNA_db::DNAserial:
    var currentUrl = window.location.href.split( '?' )[0];
         #    <autogenerated>
 
         DNA_db::projectID:
    portlets.forEach( function ( portlet ) {
            <project ID>
      if ( SKIP_IDS[ portlet.id ] ) return;
         DNA_db::sampleName:
 
            ROW::Name
      var links = Array.prototype.slice.call(
         DNA_db::sampleVol:
        portlet.querySelectorAll( 'li a' )
            ROW::Volume(ul)
      ).map( function ( a ) {
         DNA_db::concUser:
        return { label: a.textContent.trim(), href: a.href };
            ROW::Conc (ng/ul)
      } ).filter( function ( item ) { return item.label && item.href; } );
         DNA_db::sampleNotes:
 
            ROW::SampleNotes
      if ( !links.length ) return;
         DNA_db::Genome:
 
             ILAB::Species
      var headingEl = portlet.querySelector( '[id$="-label"], h3, h2' );
        DNA_db::sampleDate:
      var sectionLabel = headingEl ? headingEl.textContent.trim() : '';
             ILAB::TODAY_DATE
 
          
      var isActive = links.some( function ( link ) {
# Only if [FORM:Sample_Type] == RNA
        return link.href.split( '?' )[0] === currentUrl;
NEW RNA_samples:
      } );
     TABLE:
 
        ILAB::Sample_table
      if ( links.length === 1 ) {
     EXCEPTION_MATCH:
         nav.push( {
         #DNA_Project::NoSample
          label:   links[0].label,
         # Only if [FORM:Sample_Type] == RNA
          href:     links[0].href,
         ILAB::Sample_Type:
          isActive: links[0].href.split( '?' )[0] === currentUrl
             RNA
         } );
    ATTRIBUTES:
      } else {
         #RNA_samples::RNAserial:
         nav.push( {
         #    <autogenerated>
          label:    sectionLabel || links[0].label,
        RNA_samples::ProjectID:
          href:     links[0].href,
            <project ID>
          items:   links,
         RNA_samples::SampleName:
          isActive: isActive
             ROW::Name
        } );
         RNA_samples::Volume:
      }
             ROW::Volume(ul)
    } );
         RNA_samples::concUser:
 
            ROW::Conc (ng/ul)
    return nav;
        RNA_samples::SampleNotes:
  }
             ROW::SampleNotes
 
         RNA_samples::Genome:
  /* ── Build topbar HTML ─────────────────────────────────────── */
             ILAB::Species
  function buildTopbar() {
         RNA_samples::sampleDate:
    var d = document.createElement( 'div' );
            ILAB::TODAY_DATE
    d.className = 'bmc-topbar';
     d.innerHTML =
      '<div class="inner">' +
        '<a href="mailto:biomicro@mit.edu">biomicro@mit.edu</a>' +
         '<span>|</span>' +
        '617-715-4533' +
         '<span>|</span>' +
         'Building 68-322' +
      '</div>';
    return d;
  }
 
  /* ── Build nav dropdown item ───────────────────────────────── */
  function buildDropdownMenu( items ) {
    var menu = document.createElement( 'div' );
    menu.className = 'bmc-dropdown-menu';
    items.forEach( function ( item ) {
      if ( item.divider ) {
         var div = document.createElement( 'div' );
        div.className = 'bmc-divider';
        menu.appendChild( div );
      } else if ( item.groupLabel ) {
        var gl = document.createElement( 'div' );
        gl.className = 'bmc-group-label';
        gl.textContent = item.groupLabel;
        menu.appendChild( gl );
      } else {
        var a = document.createElement( 'a' );
        a.href = item.page ? mw.util.getUrl( item.page ) : item.href;
        a.textContent = item.label;
        if ( item.ext ) a.target = '_blank';
        menu.appendChild( a );
      }
    } );
    return menu;
  }
 
  /* ── Build full header ─────────────────────────────────────── */
  function buildHeader( currentPage, nav ) {
    /* Logo */
    var logo = document.createElement( 'a' );
    logo.href = mw.util.getUrl( 'BioMicroCenter' );
    logo.className = 'bmc-logo';
    logo.innerHTML =
      '<img class="bmc-logo-img" src="https://bmcwiki.mit.edu/images/c/c9/Logo.png" alt="MIT BioMicro Center" />';
 
    /* Nav */
    var ul = document.createElement( 'ul' );
    nav.forEach( function ( item ) {
      var li = document.createElement( 'li' );
      if ( item.isActive ) li.classList.add( 'bmc-active' );
      if ( item.items ) li.classList.add( 'bmc-dropdown' );
 
      var a = document.createElement( 'a' );
      a.href = item.page ? mw.util.getUrl( item.page ) : ( item.href || '#' );
      a.textContent = item.label;
      li.appendChild( a );
 
      if ( item.items ) {
         li.appendChild( buildDropdownMenu( item.items ) );
      }
      ul.appendChild( li );
    } );
 
    var navEl = document.createElement( 'nav' );
    navEl.className = 'bmc-nav';
    navEl.appendChild( ul );
 
    /* Search */
    var searchForm = document.createElement( 'form' );
    searchForm.className = 'bmc-search-form';
    searchForm.method = 'get';
    searchForm.action = '/index.php';
    searchForm.innerHTML =
      '<input type="hidden" name="title" value="Special:Search">' +
      '<input type="search" name="search" placeholder="Search wiki…" aria-label="Search">' +
      '<button type="submit" aria-label="Search"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></button>';
 
    /* User info — shown only when logged in */
    var userName = mw.config.get( 'wgUserName' );
    var userEl = null;
    if ( userName ) {
      userEl = document.createElement( 'span' );
      userEl.className = 'bmc-user-info';
      userEl.textContent = userName;
    }
 
    var right = document.createElement( 'div' );
    right.className = 'bmc-header-right';
    right.appendChild( navEl );
    right.appendChild( searchForm );
    var editBar = buildEditBar( currentPage );
    if ( editBar ) { right.appendChild( editBar ); }
    if ( userEl ) { right.appendChild( userEl ); }
 
    /* Inner wrapper */
    var inner = document.createElement( 'div' );
    inner.className = 'bmc-header-inner';
    inner.appendChild( logo );
    inner.appendChild( right );
 
    var header = document.createElement( 'header' );
    header.className = 'bmc-header';
    header.appendChild( inner );
    return header;
  }
 
  /* ── Build page hero ───────────────────────────────────────── */
  function buildHero( titleText ) {
    var crumb = document.createElement( 'div' );
    crumb.className = 'bmc-breadcrumb';
    crumb.innerHTML =
      '<a href="' + mw.util.getUrl( 'BioMicroCenter' ) + '">Home</a>' +
      '<span class="sep">›</span>' +
      document.createTextNode( titleText ).textContent; // plain text, no XSS
 
    var h1 = document.createElement( 'h1' );
    h1.textContent = titleText;
 
    var inner = document.createElement( 'div' );
    inner.className = 'inner';
    inner.appendChild( crumb );
    inner.appendChild( h1 );
 
    var hero = document.createElement( 'div' );
    hero.className = 'bmc-page-hero';
    hero.appendChild( inner );
    return hero;
  }
 
  /* ── Build sidebar TOC from MediaWiki #toc or heading scan ─── */
  function buildSidebarToc( mwToc, contentEl ) {
    var toc = document.createElement( 'div' );
    toc.className = 'bmc-toc';
 
    var h3 = document.createElement( 'h3' );
    h3.textContent = 'On this page';
    toc.appendChild( h3 );
 
    var list;
 
    if ( mwToc ) {
      /* Clone only the list from the MediaWiki TOC */
      var mwList = mwToc.querySelector( 'ul' );
      if ( mwList ) {
        list = mwList.cloneNode( true );
        /* Mark sub-items */
        list.querySelectorAll( 'li li' ).forEach( function ( li ) {
          li.classList.add( 'bmc-sub' );
         } );
      }
    } else if ( contentEl ) {
      /* Fall back to scanning h2/h3 headings directly */
      var headings = Array.prototype.slice.call(
        contentEl.querySelectorAll( 'h2, h3' )
      );
      if ( headings.length > 0 ) {
        list = document.createElement( 'ul' );
        headings.forEach( function ( heading ) {
          var anchor = heading.querySelector( '.mw-headline' );
          if ( !anchor || !anchor.id ) return;
          var li = document.createElement( 'li' );
          if ( heading.tagName === 'H3' ) li.classList.add( 'bmc-sub' );
          var a = document.createElement( 'a' );
          a.href = '#' + anchor.id;
          a.textContent = anchor.textContent.trim();
          li.appendChild( a );
          list.appendChild( li );
        } );
      }
    }
 
    if ( !list || list.childNodes.length === 0 ) return null;
 
    toc.appendChild( list );
 
    var aside = document.createElement( 'aside' );
    aside.className = 'bmc-sidebar';
    aside.appendChild( toc );
    return aside;
  }
 
 
  /* ── Smooth scroll for anchor links ───────────────────────── */
  function initSmoothScroll( container ) {
    container.addEventListener( 'click', function ( e ) {
      var a = e.target.closest( 'a[href^="#"]' );
      if ( !a ) return;
      var id = decodeURIComponent( a.getAttribute( 'href' ).slice( 1 ) );
      var target = document.getElementById( id );
      if ( target ) {
         e.preventDefault();
        target.scrollIntoView( { behavior: 'smooth', block: 'start' } );
        history.pushState( null, '', '#' + id );
      }
    } );
  }
 
  /* ── Add a separator line at the END of each rowspan group, on every
      cell whose own span also ends at that row. This marks the boundary
      between e.g. "5M" and "25M" without ever drawing a line through
      the inside of a single rowspan cell.                              */
  function fixRowspanSeparators( container ) {
    container.querySelectorAll( '.wikitable' ).forEach( function ( table ) {
      var tbody = table.querySelector( 'tbody' ) || table;
      var rows = Array.from( tbody.querySelectorAll( 'tr' ) );
      if ( !rows.length ) return;
 
      /* Build a grid: for each row index, find which row index each
        rowspan group ENDS at, per starting cell. Then for every row
        that is the last row of at least one rowspan group, draw a
        border-bottom on every td/th that is present in that row
        (cells whose own rowspan continues past this row are skipped,
        since the line would cut through them — instead they get their
        own end-of-group border drawn at their own last row). */
      rows.forEach( function ( row, rowIdx ) {
         row.querySelectorAll( 'td[rowspan], th[rowspan]' ).forEach( function ( cell ) {
          var span = parseInt( cell.getAttribute( 'rowspan' ), 10 ) || 1;
          var endRowIdx = rowIdx + span - 1;
          var endRow = rows[ endRowIdx ];
          if ( !endRow ) return;
 
          /* Draw the boundary on every cell present in the END row,
            except cells whose OWN rowspan extends beyond this row
            (those get their boundary drawn at their own end row). */
          endRow.querySelectorAll( 'td, th' ).forEach( function ( endCell ) {
             var ownSpan = parseInt( endCell.getAttribute( 'rowspan' ), 10 ) || 1;
            if ( ownSpan > 1 ) return; /* this cell's own group hasn't ended yet */
             endCell.classList.add( 'bmc-rowspan-boundary' );
          } );
         } );
      } );
    } );
  }
 
 
  /* ── Split <br>-separated items inside a <ul> into separate <li>s ── */
  function splitBrBullets( ul ) {
    Array.prototype.slice.call( ul.querySelectorAll( 'li' ) ).forEach( function ( li ) {
      if ( !li.querySelector( 'br' ) ) return;
      var nodes = Array.prototype.slice.call( li.childNodes );
      var segments = [[]];
      nodes.forEach( function ( node ) {
        if ( node.nodeName === 'BR' ) {
          segments.push( [] );
        } else {
          segments[ segments.length - 1 ].push( node );
        }
      } );
      /* Drop empty segments (only whitespace text nodes) */
      segments = segments.filter( function ( seg ) {
        return seg.some( function ( n ) {
          return n.nodeType === 1 ||
            ( n.nodeType === 3 && n.textContent.trim() !== '' );
        } );
      } );
      if ( segments.length <= 1 ) return;
      var parent = li.parentNode;
      var ref    = li.nextSibling;
      segments.forEach( function ( seg ) {
        var newLi = document.createElement( 'li' );
        seg.forEach( function ( n ) { newLi.appendChild( n ); } );
        parent.insertBefore( newLi, ref );
      } );
      parent.removeChild( li );
    } );
  }
 
  /* ── Edit bar for logged-in users ─────────────────────────── */
  function buildEditBar( currentPage ) {
    if ( !mw.config.get( 'wgUserId' ) ) return null;
    var bar = document.createElement( 'div' );
    bar.className = 'bmc-edit-bar';
    var editHref = mw.util.getUrl( currentPage, { action: 'edit' } );
     var histHref = mw.util.getUrl( currentPage, { action: 'history' } );
    var talkHref = mw.util.getUrl( 'Talk:' + currentPage );
    bar.innerHTML =
      '<a href="' + editHref + '">Edit</a>' +
      '<a href="' + histHref + '">History</a>' +
      '<a href="' + talkHref + '">Talk</a>';
    return bar;
  }
 
  /* ── Main: build and inject the shell ─────────────────────── */
  function buildShell( nav ) {
    var currentPage = mw.config.get( 'wgPageName' ) || '';
 
    /* 1. Extract wiki content BEFORE any DOM manipulation */
    var mwContentText = document.getElementById( 'mw-content-text' );
    if ( !mwContentText ) return; /* not a content page, bail */
 
    var contentClone = mwContentText.cloneNode( true );
 
    /* 2. Extract page title */
     var titleEl = document.getElementById( 'firstHeading' ) ||
      document.querySelector( '.mw-first-heading' );
    var titleText = titleEl
      ? titleEl.textContent.trim()
      : ( mw.config.get( 'wgTitle' ) || 'BioMicro Center' );
 
    /* 3. Extract MediaWiki TOC from the cloned content */
    var mwToc = contentClone.querySelector( '#toc, .toc' );
    if ( mwToc ) mwToc.parentNode.removeChild( mwToc ); /* remove from content body */
 
    /* 3b. Hide the BMC logo/header sticker by exact image filename.
          The sticker is BMC_Header_2020_3.png (from the {{BioMicroCenter}} template)
          and bmc_logo_square.png. Target the nearest meaningful container so the
          surrounding content (nav links, table structure) is preserved. */
    contentClone.querySelectorAll( 'img' ).forEach( function ( img ) {
      var src = img.getAttribute( 'src' ) || '';
      if ( /BMC_Header|bmc_logo_square|Bmc_logo|\/Logo\.png/i.test( src ) ) {
         var container = img.closest( '.thumb' ) || img.closest( 'figure' ) || img.closest( 'td' );
        if ( container ) {
          container.style.display = 'none';
        } else {
          img.style.display = 'none';
        }
      }
    } );
 
    /* 4. Build shell elements */
    var topbar = buildTopbar();
    var header = buildHeader( currentPage, nav );
    var hero  = buildHero( titleText );
 
    /* Page layout grid */
    var layout = document.createElement( 'div' );
    layout.className = 'bmc-page-layout';
 
    /* Sidebar — skip on the home page */
    var isHome = ( currentPage === 'BioMicroCenter' ||
                  currentPage === 'Main_Page' );
    var sidebar = isHome ? null : buildSidebarToc( mwToc, contentClone );
    if ( sidebar ) {
      layout.appendChild( sidebar );
    } else {
      /* No TOC — collapse to single-column via inline style */
      layout.style.gridTemplateColumns = '1fr';
    }
 
    /* Main content area */
    var main = document.createElement( 'main' );
    main.className = 'bmc-content';
    main.appendChild( contentClone );
    layout.appendChild( main );
 
    /* 5. Build wrapper */
    var wrapper = document.createElement( 'div' );
    wrapper.id = 'bmc-wrapper';
    wrapper.appendChild( topbar );
    wrapper.appendChild( header );
    wrapper.appendChild( hero );
    wrapper.appendChild( layout );
 
    var templateFooter = contentClone.querySelector( '#footer' );
    if ( templateFooter ) {
      templateFooter.remove();
      wrapper.appendChild( templateFooter );
    }
 
    /* 6. Inject into body (prepend so it appears first) */
    document.body.insertBefore( wrapper, document.body.firstChild );
 
    /* 7. Add class immediately so the CSS rule kicks in and hides
          all original MW elements (works for any skin version) */
    document.body.classList.add( 'bmc-active' );
    if ( isHome ) {
      document.body.classList.add( 'bmc-home' );
 
      /* Remove page title from hero — logo already identifies the site */
      var heroH1 = hero.querySelector( 'h1' );
      if ( heroH1 ) { heroH1.remove(); }
      var heroCrumb = hero.querySelector( '.bmc-breadcrumb' );
      if ( heroCrumb ) { heroCrumb.remove(); }
 
      var parserOutput = contentClone.querySelector( '.mw-parser-output' ) || contentClone;
 
      /* 7a. Remove the "Welcome to the MIT BioMicro Center!" tagline */
      var welcomeEl = null;
      Array.prototype.forEach.call( parserOutput.children, function ( el ) {
        if ( !welcomeEl && /Welcome to the MIT BioMicro Center/i.test( el.textContent ) ) {
          welcomeEl = el;
        }
      } );
      if ( welcomeEl ) { welcomeEl.parentNode.removeChild( welcomeEl ); }
 
      /* 7b. The first table on the page is the quick-links list (News,
            Pricing, Sample Submission, etc.) — pull its links up into
            the hero bar as pills so they don't eat vertical space, and
            remove the now-empty table. */
      var navLinksTable = parserOutput.querySelector( 'table' );
      if ( navLinksTable ) {
        var heroLinksBar = document.createElement( 'div' );
        heroLinksBar.className = 'bmc-hero-links';
        var seenHref = {};
         navLinksTable.querySelectorAll( 'a' ).forEach( function ( a ) {
          var label = a.textContent.trim();
          var href  = a.getAttribute( 'href' );
          if ( !label || !href || seenHref[ href ] ) return;
          seenHref[ href ] = true;
          var pill = document.createElement( 'a' );
          pill.className = 'bmc-hero-link';
          pill.setAttribute( 'href', href );
          pill.textContent = label;
          heroLinksBar.appendChild( pill );
        } );
        if ( heroLinksBar.children.length ) {
          hero.querySelector( '.inner' ).appendChild( heroLinksBar );
        }
        navLinksTable.parentNode.removeChild( navLinksTable );
      }
 
      /* 7c. The remaining table is one single "services" mega-table (5 rows,
            3 columns) where every cell — including the "Assisted Services" /
            "Walkup Services" / "Informatics Services" headers — wraps its
            content in its own nested one-cell table, purely for old-school
            formatting. Left as-is, our .wikitable styling was being applied
            to those nested tables too, double-boxing every cell. Unwrap them
            first, then style the table as a whole. */
      var servicesTable = parserOutput.querySelector( 'table' );
      if ( servicesTable ) {
         Array.prototype.slice.call( servicesTable.querySelectorAll( 'table' ) ).forEach(
          function ( inner ) {
            var frag = document.createDocumentFragment();
            Array.prototype.forEach.call( inner.querySelectorAll( 'td' ), function ( td ) {
              while ( td.firstChild ) { frag.appendChild( td.firstChild ); }
            } );
             inner.parentNode.replaceChild( frag, inner );
          }
        );
 
         servicesTable.classList.add( 'wikitable', 'bmc-home-table' );
 
         /* 7d. A row whose cells are ALL just a heading with no <ul> of
              items is a group-header row ("Assisted Services" / "Walkup
              Services", "Informatics/Bioinformatics Services") — style it
              as a colored bar. Real content rows (heading + <ul>) get
              normal styling. Because this uses the table's own
              <tr>/<td>/colspan, the bar aligns exactly with the columns
              below it — no guesswork.
 
              Color is chosen by matching the cell's own label text rather
              than its position, so "Walkup" / "Assisted" / "Bioinformatics"
              each get their own distinct color regardless of column order.
              Any header text that doesn't match one of these falls back to
              the neutral wide style, so new/renamed categories don't break. */
         function headerColorClass( label ) {
          var t = label.toLowerCase();
          if ( t.indexOf( 'walkup' ) !== -1 || t.indexOf( 'walk-up' ) !== -1 ) {
            return 'bmc-home-header-walkup';
          }
          if ( t.indexOf( 'bioinformatics' ) !== -1 || t.indexOf( 'informatics' ) !== -1 ) {
            return 'bmc-home-header-bioinformatics';
          }
          if ( t.indexOf( 'assisted' ) !== -1 ) {
             return 'bmc-home-header-assisted';
          }
          return 'bmc-home-header-wide';
        }
 
         Array.prototype.forEach.call( servicesTable.querySelectorAll( 'tr' ), function ( tr ) {
          var cells = Array.prototype.slice.call( tr.children ).filter( function ( c ) {
             return c.tagName === 'TD' || c.tagName === 'TH';
          } );
          if ( !cells.length ) return;
          var isHeaderRow = cells.every( function ( td ) { return !td.querySelector( 'ul' ); } );
          if ( isHeaderRow ) {
            tr.classList.add( 'bmc-home-header-row' );
            cells.forEach( function ( td ) {
              td.classList.add( 'bmc-home-header-cell' );
              td.classList.add( headerColorClass( td.textContent || '' ) );
            } );
          }
         } );
      }
 
    }
 
    /* 8. Smooth scroll */
    initSmoothScroll( main );
    initSmoothScroll( sidebar || main );
 
    /* 9. Auto-style only genuine data tables — not layout/service-grid tables.
          Real data tables have <th> header cells; layout tables use <td> only. */
    main.querySelectorAll(
      '.mw-parser-output table:not(.wikitable):not(.infobox):not(.navbox)'
    ).forEach( function ( t ) {
      var firstRow = t.querySelector( 'tr' );
      if ( firstRow && firstRow.querySelector( 'th' ) ) {
        t.classList.add( 'wikitable' );
      }
    } );
 
    /* 9b. Old-school "card grid" tables (e.g. Alumni/Staff bio grids) lay out
          N profile cells per row using a bare width="350" HTML attribute on
          each <td>, with narrow width="25" gap cells in between. Browsers
          only treat that width as a hint under automatic table layout, so
          columns end up sized by content instead of rendering equally.
          Force fixed layout via our own stylesheet (not user-editable
          wikitext, so nothing here is at risk of sanitizer stripping).
 
          Only tag tables with this exact, unambiguous shape: every
          width-bearing cell in the table uses one of exactly two values —
          a "content" width and a much narrower "gap" width — with no other
          width values mixed in. Rows are allowed to have a trailing/short
          row with fewer cells (e.g. 2 people instead of 3), so this checks
          values used, not a fixed count per row. This keeps the rule from
          ever reaching colspan header-row tables, rowspan/pricing tables,
          or nested single-cell formatting tables — none of those share
          this bare width="n" alternating content/gap pattern. */
    main.querySelectorAll( 'table:not(.wikitable):not(.infobox):not(.navbox)' ).forEach(
      function ( t ) {
        var widthCells = t.querySelectorAll( 'td[width]' );
        if ( widthCells.length < 3 ) { return; }
        var values = {};
        Array.prototype.forEach.call( widthCells, function ( td ) {
          var v = parseInt( td.getAttribute( 'width' ), 10 );
          if ( !isNaN( v ) ) { values[ v ] = ( values[ v ] || 0 ) + 1; }
        } );
        var distinct = Object.keys( values ).map( Number ).sort( function ( a, b ) { return a - b; } );
        if ( distinct.length === 2 && distinct[ 0 ] * 3 <= distinct[ 1 ] ) {
          t.classList.add( 'bmc-card-grid-table' );
          var contentWidth = distinct[ 1 ];
          Array.prototype.forEach.call( widthCells, function ( td ) {
             if ( parseInt( td.getAttribute( 'width' ), 10 ) === contentWidth ) {
              td.classList.add( 'bmc-card-grid-content-cell' );
            }
          } );
        }
      }
    );
 
    /* 10. Fix row separators on rowspan tables */
    requestAnimationFrame( function () {
      fixRowspanSeparators( main );
    } );
 
    /* 11. Pricing page — convert wikitables to flex layout so column
          borders and content stay perfectly aligned even with colspan rows.
          colspan cells get flex-grow = span so they span proportionally. */
    if ( currentPage.indexOf( 'Pricing' ) !== -1 ) {
      document.body.classList.add( 'bmc-pricing' );
      main.querySelectorAll( '.wikitable' ).forEach( function ( table ) {
         table.querySelectorAll( 'tr' ).forEach( function ( row ) {
          row.querySelectorAll( 'th, td' ).forEach( function ( cell ) {
            var span = parseInt( cell.getAttribute( 'colspan' ) || '1', 10 );
            cell.style.setProperty( 'flex', span + ' 1 0%', 'important' );
             cell.style.setProperty( 'min-width', '0', 'important' );
          } );
         } );
      } );
    }
 
  }
 
  /* ── Entry point ───────────────────────────────────────────── */
  mw.hook( 'wikipage.content' ).add( function () {
    /* Skip special pages (edit forms, history, etc.) to not break them */
    var ns    = mw.config.get( 'wgNamespaceNumber' );
    var action = mw.config.get( 'wgAction' );
    if ( action !== 'view' ) return;
    if ( ns < 0 ) return; /* Special: pages */
 
    var nav = extractNavFromDom();
    buildShell( nav );
  } );
 
} )();

Latest revision as of 17:04, 13 July 2026

/**
 * BioMicro Center Wiki Wrapper — Full DOM Replacement
 *
 * Strategy:
 * 1. Extract wiki article content from MediaWiki's DOM
 * 2. Inject the complete shell structure
 * 3. Place article content inside the shell
 * MediaWiki still runs underneath for editing, search, login, etc.
 */

( function () {
  'use strict';

  /* ── Extract nav from MediaWiki's sidebar DOM ─────────────── */
  function extractNavFromDom() {
    var SKIP_IDS = {
      'p-tb': 1, 'p-personal': 1, 'p-search': 1,
      'p-logo': 1, 'p-cactions': 1, 'p-views': 1,
      'p-lang': 1, 'p-namespaces': 1
    };

    /* Scope to the sidebar container to exclude Vector 2022 header user-menu portlets
       (p-vector-user-menu-userpage, p-user-menu-logout, p-personal-sticky-header, etc.) */
    var navContainer = document.getElementById( 'vector-main-menu' ) ||
                       document.getElementById( 'mw-panel' )         ||
                       document.getElementById( 'mw-navigation' )    ||
                       document.body;

    var portlets = Array.prototype.slice.call(
      navContainer.querySelectorAll( '[id^="p-"]' )
    );

    var nav = [];
    var currentUrl = window.location.href.split( '?' )[0];

    portlets.forEach( function ( portlet ) {
      if ( SKIP_IDS[ portlet.id ] ) return;

      var links = Array.prototype.slice.call(
        portlet.querySelectorAll( 'li a' )
      ).map( function ( a ) {
        return { label: a.textContent.trim(), href: a.href };
      } ).filter( function ( item ) { return item.label && item.href; } );

      if ( !links.length ) return;

      var headingEl = portlet.querySelector( '[id$="-label"], h3, h2' );
      var sectionLabel = headingEl ? headingEl.textContent.trim() : '';

      var isActive = links.some( function ( link ) {
        return link.href.split( '?' )[0] === currentUrl;
      } );

      if ( links.length === 1 ) {
        nav.push( {
          label:    links[0].label,
          href:     links[0].href,
          isActive: links[0].href.split( '?' )[0] === currentUrl
        } );
      } else {
        nav.push( {
          label:    sectionLabel || links[0].label,
          href:     links[0].href,
          items:    links,
          isActive: isActive
        } );
      }
    } );

    return nav;
  }

  /* ── Build topbar HTML ─────────────────────────────────────── */
  function buildTopbar() {
    var d = document.createElement( 'div' );
    d.className = 'bmc-topbar';
    d.innerHTML =
      '<div class="inner">' +
        '<a href="mailto:biomicro@mit.edu">biomicro@mit.edu</a>' +
        '<span>|</span>' +
        '617-715-4533' +
        '<span>|</span>' +
        'Building 68-322' +
      '</div>';
    return d;
  }

  /* ── Build nav dropdown item ───────────────────────────────── */
  function buildDropdownMenu( items ) {
    var menu = document.createElement( 'div' );
    menu.className = 'bmc-dropdown-menu';
    items.forEach( function ( item ) {
      if ( item.divider ) {
        var div = document.createElement( 'div' );
        div.className = 'bmc-divider';
        menu.appendChild( div );
      } else if ( item.groupLabel ) {
        var gl = document.createElement( 'div' );
        gl.className = 'bmc-group-label';
        gl.textContent = item.groupLabel;
        menu.appendChild( gl );
      } else {
        var a = document.createElement( 'a' );
        a.href = item.page ? mw.util.getUrl( item.page ) : item.href;
        a.textContent = item.label;
        if ( item.ext ) a.target = '_blank';
        menu.appendChild( a );
      }
    } );
    return menu;
  }

  /* ── Build full header ─────────────────────────────────────── */
  function buildHeader( currentPage, nav ) {
    /* Logo */
    var logo = document.createElement( 'a' );
    logo.href = mw.util.getUrl( 'BioMicroCenter' );
    logo.className = 'bmc-logo';
    logo.innerHTML =
      '<img class="bmc-logo-img" src="https://bmcwiki.mit.edu/images/c/c9/Logo.png" alt="MIT BioMicro Center" />';

    /* Nav */
    var ul = document.createElement( 'ul' );
    nav.forEach( function ( item ) {
      var li = document.createElement( 'li' );
      if ( item.isActive ) li.classList.add( 'bmc-active' );
      if ( item.items ) li.classList.add( 'bmc-dropdown' );

      var a = document.createElement( 'a' );
      a.href = item.page ? mw.util.getUrl( item.page ) : ( item.href || '#' );
      a.textContent = item.label;
      li.appendChild( a );

      if ( item.items ) {
        li.appendChild( buildDropdownMenu( item.items ) );
      }
      ul.appendChild( li );
    } );

    var navEl = document.createElement( 'nav' );
    navEl.className = 'bmc-nav';
    navEl.appendChild( ul );

    /* Search */
    var searchForm = document.createElement( 'form' );
    searchForm.className = 'bmc-search-form';
    searchForm.method = 'get';
    searchForm.action = '/index.php';
    searchForm.innerHTML =
      '<input type="hidden" name="title" value="Special:Search">' +
      '<input type="search" name="search" placeholder="Search wiki…" aria-label="Search">' +
      '<button type="submit" aria-label="Search"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></button>';

    /* User info — shown only when logged in */
    var userName = mw.config.get( 'wgUserName' );
    var userEl = null;
    if ( userName ) {
      userEl = document.createElement( 'span' );
      userEl.className = 'bmc-user-info';
      userEl.textContent = userName;
    }

    var right = document.createElement( 'div' );
    right.className = 'bmc-header-right';
    right.appendChild( navEl );
    right.appendChild( searchForm );
    var editBar = buildEditBar( currentPage );
    if ( editBar ) { right.appendChild( editBar ); }
    if ( userEl ) { right.appendChild( userEl ); }

    /* Inner wrapper */
    var inner = document.createElement( 'div' );
    inner.className = 'bmc-header-inner';
    inner.appendChild( logo );
    inner.appendChild( right );

    var header = document.createElement( 'header' );
    header.className = 'bmc-header';
    header.appendChild( inner );
    return header;
  }

  /* ── Build page hero ───────────────────────────────────────── */
  function buildHero( titleText ) {
    var crumb = document.createElement( 'div' );
    crumb.className = 'bmc-breadcrumb';
    crumb.innerHTML =
      '<a href="' + mw.util.getUrl( 'BioMicroCenter' ) + '">Home</a>' +
      '<span class="sep">›</span>' +
      document.createTextNode( titleText ).textContent; // plain text, no XSS

    var h1 = document.createElement( 'h1' );
    h1.textContent = titleText;

    var inner = document.createElement( 'div' );
    inner.className = 'inner';
    inner.appendChild( crumb );
    inner.appendChild( h1 );

    var hero = document.createElement( 'div' );
    hero.className = 'bmc-page-hero';
    hero.appendChild( inner );
    return hero;
  }

  /* ── Build sidebar TOC from MediaWiki #toc or heading scan ─── */
  function buildSidebarToc( mwToc, contentEl ) {
    var toc = document.createElement( 'div' );
    toc.className = 'bmc-toc';

    var h3 = document.createElement( 'h3' );
    h3.textContent = 'On this page';
    toc.appendChild( h3 );

    var list;

    if ( mwToc ) {
      /* Clone only the list from the MediaWiki TOC */
      var mwList = mwToc.querySelector( 'ul' );
      if ( mwList ) {
        list = mwList.cloneNode( true );
        /* Mark sub-items */
        list.querySelectorAll( 'li li' ).forEach( function ( li ) {
          li.classList.add( 'bmc-sub' );
        } );
      }
    } else if ( contentEl ) {
      /* Fall back to scanning h2/h3 headings directly */
      var headings = Array.prototype.slice.call(
        contentEl.querySelectorAll( 'h2, h3' )
      );
      if ( headings.length > 0 ) {
        list = document.createElement( 'ul' );
        headings.forEach( function ( heading ) {
          var anchor = heading.querySelector( '.mw-headline' );
          if ( !anchor || !anchor.id ) return;
          var li = document.createElement( 'li' );
          if ( heading.tagName === 'H3' ) li.classList.add( 'bmc-sub' );
          var a = document.createElement( 'a' );
          a.href = '#' + anchor.id;
          a.textContent = anchor.textContent.trim();
          li.appendChild( a );
          list.appendChild( li );
        } );
      }
    }

    if ( !list || list.childNodes.length === 0 ) return null;

    toc.appendChild( list );

    var aside = document.createElement( 'aside' );
    aside.className = 'bmc-sidebar';
    aside.appendChild( toc );
    return aside;
  }


  /* ── Smooth scroll for anchor links ───────────────────────── */
  function initSmoothScroll( container ) {
    container.addEventListener( 'click', function ( e ) {
      var a = e.target.closest( 'a[href^="#"]' );
      if ( !a ) return;
      var id = decodeURIComponent( a.getAttribute( 'href' ).slice( 1 ) );
      var target = document.getElementById( id );
      if ( target ) {
        e.preventDefault();
        target.scrollIntoView( { behavior: 'smooth', block: 'start' } );
        history.pushState( null, '', '#' + id );
      }
    } );
  }

  /* ── Add a separator line at the END of each rowspan group, on every
       cell whose own span also ends at that row. This marks the boundary
       between e.g. "5M" and "25M" without ever drawing a line through
       the inside of a single rowspan cell.                              */
  function fixRowspanSeparators( container ) {
    container.querySelectorAll( '.wikitable' ).forEach( function ( table ) {
      var tbody = table.querySelector( 'tbody' ) || table;
      var rows = Array.from( tbody.querySelectorAll( 'tr' ) );
      if ( !rows.length ) return;

      /* Build a grid: for each row index, find which row index each
         rowspan group ENDS at, per starting cell. Then for every row
         that is the last row of at least one rowspan group, draw a
         border-bottom on every td/th that is present in that row
         (cells whose own rowspan continues past this row are skipped,
         since the line would cut through them — instead they get their
         own end-of-group border drawn at their own last row). */
      rows.forEach( function ( row, rowIdx ) {
        row.querySelectorAll( 'td[rowspan], th[rowspan]' ).forEach( function ( cell ) {
          var span = parseInt( cell.getAttribute( 'rowspan' ), 10 ) || 1;
          var endRowIdx = rowIdx + span - 1;
          var endRow = rows[ endRowIdx ];
          if ( !endRow ) return;

          /* Draw the boundary on every cell present in the END row,
             except cells whose OWN rowspan extends beyond this row
             (those get their boundary drawn at their own end row). */
          endRow.querySelectorAll( 'td, th' ).forEach( function ( endCell ) {
            var ownSpan = parseInt( endCell.getAttribute( 'rowspan' ), 10 ) || 1;
            if ( ownSpan > 1 ) return; /* this cell's own group hasn't ended yet */
            endCell.classList.add( 'bmc-rowspan-boundary' );
          } );
        } );
      } );
    } );
  }


  /* ── Split <br>-separated items inside a <ul> into separate <li>s ── */
  function splitBrBullets( ul ) {
    Array.prototype.slice.call( ul.querySelectorAll( 'li' ) ).forEach( function ( li ) {
      if ( !li.querySelector( 'br' ) ) return;
      var nodes = Array.prototype.slice.call( li.childNodes );
      var segments = [[]];
      nodes.forEach( function ( node ) {
        if ( node.nodeName === 'BR' ) {
          segments.push( [] );
        } else {
          segments[ segments.length - 1 ].push( node );
        }
      } );
      /* Drop empty segments (only whitespace text nodes) */
      segments = segments.filter( function ( seg ) {
        return seg.some( function ( n ) {
          return n.nodeType === 1 ||
            ( n.nodeType === 3 && n.textContent.trim() !== '' );
        } );
      } );
      if ( segments.length <= 1 ) return;
      var parent = li.parentNode;
      var ref    = li.nextSibling;
      segments.forEach( function ( seg ) {
        var newLi = document.createElement( 'li' );
        seg.forEach( function ( n ) { newLi.appendChild( n ); } );
        parent.insertBefore( newLi, ref );
      } );
      parent.removeChild( li );
    } );
  }

  /* ── Edit bar for logged-in users ─────────────────────────── */
  function buildEditBar( currentPage ) {
    if ( !mw.config.get( 'wgUserId' ) ) return null;
    var bar = document.createElement( 'div' );
    bar.className = 'bmc-edit-bar';
    var editHref = mw.util.getUrl( currentPage, { action: 'edit' } );
    var histHref = mw.util.getUrl( currentPage, { action: 'history' } );
    var talkHref = mw.util.getUrl( 'Talk:' + currentPage );
    bar.innerHTML =
      '<a href="' + editHref + '">Edit</a>' +
      '<a href="' + histHref + '">History</a>' +
      '<a href="' + talkHref + '">Talk</a>';
    return bar;
  }

  /* ── Main: build and inject the shell ─────────────────────── */
  function buildShell( nav ) {
    var currentPage = mw.config.get( 'wgPageName' ) || '';

    /* 1. Extract wiki content BEFORE any DOM manipulation */
    var mwContentText = document.getElementById( 'mw-content-text' );
    if ( !mwContentText ) return; /* not a content page, bail */

    var contentClone = mwContentText.cloneNode( true );

    /* 2. Extract page title */
    var titleEl = document.getElementById( 'firstHeading' ) ||
      document.querySelector( '.mw-first-heading' );
    var titleText = titleEl
      ? titleEl.textContent.trim()
      : ( mw.config.get( 'wgTitle' ) || 'BioMicro Center' );

    /* 3. Extract MediaWiki TOC from the cloned content */
    var mwToc = contentClone.querySelector( '#toc, .toc' );
    if ( mwToc ) mwToc.parentNode.removeChild( mwToc ); /* remove from content body */

    /* 3b. Hide the BMC logo/header sticker by exact image filename.
           The sticker is BMC_Header_2020_3.png (from the {{BioMicroCenter}} template)
           and bmc_logo_square.png. Target the nearest meaningful container so the
           surrounding content (nav links, table structure) is preserved. */
    contentClone.querySelectorAll( 'img' ).forEach( function ( img ) {
      var src = img.getAttribute( 'src' ) || '';
      if ( /BMC_Header|bmc_logo_square|Bmc_logo|\/Logo\.png/i.test( src ) ) {
        var container = img.closest( '.thumb' ) || img.closest( 'figure' ) || img.closest( 'td' );
        if ( container ) {
          container.style.display = 'none';
        } else {
          img.style.display = 'none';
        }
      }
    } );

    /* 4. Build shell elements */
    var topbar = buildTopbar();
    var header = buildHeader( currentPage, nav );
    var hero   = buildHero( titleText );

    /* Page layout grid */
    var layout = document.createElement( 'div' );
    layout.className = 'bmc-page-layout';

    /* Sidebar — skip on the home page */
    var isHome = ( currentPage === 'BioMicroCenter' ||
                   currentPage === 'Main_Page' );
    var sidebar = isHome ? null : buildSidebarToc( mwToc, contentClone );
    if ( sidebar ) {
      layout.appendChild( sidebar );
    } else {
      /* No TOC — collapse to single-column via inline style */
      layout.style.gridTemplateColumns = '1fr';
    }

    /* Main content area */
    var main = document.createElement( 'main' );
    main.className = 'bmc-content';
    main.appendChild( contentClone );
    layout.appendChild( main );

    /* 5. Build wrapper */
    var wrapper = document.createElement( 'div' );
    wrapper.id = 'bmc-wrapper';
    wrapper.appendChild( topbar );
    wrapper.appendChild( header );
    wrapper.appendChild( hero );
    wrapper.appendChild( layout );

    var templateFooter = contentClone.querySelector( '#footer' );
    if ( templateFooter ) {
      templateFooter.remove();
      wrapper.appendChild( templateFooter );
    }

    /* 6. Inject into body (prepend so it appears first) */
    document.body.insertBefore( wrapper, document.body.firstChild );

    /* 7. Add class immediately so the CSS rule kicks in and hides
          all original MW elements (works for any skin version) */
    document.body.classList.add( 'bmc-active' );
    if ( isHome ) {
      document.body.classList.add( 'bmc-home' );

      /* Remove page title from hero — logo already identifies the site */
      var heroH1 = hero.querySelector( 'h1' );
      if ( heroH1 ) { heroH1.remove(); }
      var heroCrumb = hero.querySelector( '.bmc-breadcrumb' );
      if ( heroCrumb ) { heroCrumb.remove(); }

      var parserOutput = contentClone.querySelector( '.mw-parser-output' ) || contentClone;

      /* 7a. Remove the "Welcome to the MIT BioMicro Center!" tagline */
      var welcomeEl = null;
      Array.prototype.forEach.call( parserOutput.children, function ( el ) {
        if ( !welcomeEl && /Welcome to the MIT BioMicro Center/i.test( el.textContent ) ) {
          welcomeEl = el;
        }
      } );
      if ( welcomeEl ) { welcomeEl.parentNode.removeChild( welcomeEl ); }

      /* 7b. The first table on the page is the quick-links list (News,
             Pricing, Sample Submission, etc.) — pull its links up into
             the hero bar as pills so they don't eat vertical space, and
             remove the now-empty table. */
      var navLinksTable = parserOutput.querySelector( 'table' );
      if ( navLinksTable ) {
        var heroLinksBar = document.createElement( 'div' );
        heroLinksBar.className = 'bmc-hero-links';
        var seenHref = {};
        navLinksTable.querySelectorAll( 'a' ).forEach( function ( a ) {
          var label = a.textContent.trim();
          var href  = a.getAttribute( 'href' );
          if ( !label || !href || seenHref[ href ] ) return;
          seenHref[ href ] = true;
          var pill = document.createElement( 'a' );
          pill.className = 'bmc-hero-link';
          pill.setAttribute( 'href', href );
          pill.textContent = label;
          heroLinksBar.appendChild( pill );
        } );
        if ( heroLinksBar.children.length ) {
          hero.querySelector( '.inner' ).appendChild( heroLinksBar );
        }
        navLinksTable.parentNode.removeChild( navLinksTable );
      }

      /* 7c. The remaining table is one single "services" mega-table (5 rows,
             3 columns) where every cell — including the "Assisted Services" /
             "Walkup Services" / "Informatics Services" headers — wraps its
             content in its own nested one-cell table, purely for old-school
             formatting. Left as-is, our .wikitable styling was being applied
             to those nested tables too, double-boxing every cell. Unwrap them
             first, then style the table as a whole. */
      var servicesTable = parserOutput.querySelector( 'table' );
      if ( servicesTable ) {
        Array.prototype.slice.call( servicesTable.querySelectorAll( 'table' ) ).forEach(
          function ( inner ) {
            var frag = document.createDocumentFragment();
            Array.prototype.forEach.call( inner.querySelectorAll( 'td' ), function ( td ) {
              while ( td.firstChild ) { frag.appendChild( td.firstChild ); }
            } );
            inner.parentNode.replaceChild( frag, inner );
          }
        );

        servicesTable.classList.add( 'wikitable', 'bmc-home-table' );

        /* 7d. A row whose cells are ALL just a heading with no <ul> of
               items is a group-header row ("Assisted Services" / "Walkup
               Services", "Informatics/Bioinformatics Services") — style it
               as a colored bar. Real content rows (heading + <ul>) get
               normal styling. Because this uses the table's own
               <tr>/<td>/colspan, the bar aligns exactly with the columns
               below it — no guesswork.

               Color is chosen by matching the cell's own label text rather
               than its position, so "Walkup" / "Assisted" / "Bioinformatics"
               each get their own distinct color regardless of column order.
               Any header text that doesn't match one of these falls back to
               the neutral wide style, so new/renamed categories don't break. */
        function headerColorClass( label ) {
          var t = label.toLowerCase();
          if ( t.indexOf( 'walkup' ) !== -1 || t.indexOf( 'walk-up' ) !== -1 ) {
            return 'bmc-home-header-walkup';
          }
          if ( t.indexOf( 'bioinformatics' ) !== -1 || t.indexOf( 'informatics' ) !== -1 ) {
            return 'bmc-home-header-bioinformatics';
          }
          if ( t.indexOf( 'assisted' ) !== -1 ) {
            return 'bmc-home-header-assisted';
          }
          return 'bmc-home-header-wide';
        }

        Array.prototype.forEach.call( servicesTable.querySelectorAll( 'tr' ), function ( tr ) {
          var cells = Array.prototype.slice.call( tr.children ).filter( function ( c ) {
            return c.tagName === 'TD' || c.tagName === 'TH';
          } );
          if ( !cells.length ) return;
          var isHeaderRow = cells.every( function ( td ) { return !td.querySelector( 'ul' ); } );
          if ( isHeaderRow ) {
            tr.classList.add( 'bmc-home-header-row' );
            cells.forEach( function ( td ) {
              td.classList.add( 'bmc-home-header-cell' );
              td.classList.add( headerColorClass( td.textContent || '' ) );
            } );
          }
        } );
      }

    }

    /* 8. Smooth scroll */
    initSmoothScroll( main );
    initSmoothScroll( sidebar || main );

    /* 9. Auto-style only genuine data tables — not layout/service-grid tables.
          Real data tables have <th> header cells; layout tables use <td> only. */
    main.querySelectorAll(
      '.mw-parser-output table:not(.wikitable):not(.infobox):not(.navbox)'
    ).forEach( function ( t ) {
      var firstRow = t.querySelector( 'tr' );
      if ( firstRow && firstRow.querySelector( 'th' ) ) {
        t.classList.add( 'wikitable' );
      }
    } );

    /* 9b. Old-school "card grid" tables (e.g. Alumni/Staff bio grids) lay out
           N profile cells per row using a bare width="350" HTML attribute on
           each <td>, with narrow width="25" gap cells in between. Browsers
           only treat that width as a hint under automatic table layout, so
           columns end up sized by content instead of rendering equally.
           Force fixed layout via our own stylesheet (not user-editable
           wikitext, so nothing here is at risk of sanitizer stripping).

           Only tag tables with this exact, unambiguous shape: every
           width-bearing cell in the table uses one of exactly two values —
           a "content" width and a much narrower "gap" width — with no other
           width values mixed in. Rows are allowed to have a trailing/short
           row with fewer cells (e.g. 2 people instead of 3), so this checks
           values used, not a fixed count per row. This keeps the rule from
           ever reaching colspan header-row tables, rowspan/pricing tables,
           or nested single-cell formatting tables — none of those share
           this bare width="n" alternating content/gap pattern. */
    main.querySelectorAll( 'table:not(.wikitable):not(.infobox):not(.navbox)' ).forEach(
      function ( t ) {
        var widthCells = t.querySelectorAll( 'td[width]' );
        if ( widthCells.length < 3 ) { return; }
        var values = {};
        Array.prototype.forEach.call( widthCells, function ( td ) {
          var v = parseInt( td.getAttribute( 'width' ), 10 );
          if ( !isNaN( v ) ) { values[ v ] = ( values[ v ] || 0 ) + 1; }
        } );
        var distinct = Object.keys( values ).map( Number ).sort( function ( a, b ) { return a - b; } );
        if ( distinct.length === 2 && distinct[ 0 ] * 3 <= distinct[ 1 ] ) {
          t.classList.add( 'bmc-card-grid-table' );
          var contentWidth = distinct[ 1 ];
          Array.prototype.forEach.call( widthCells, function ( td ) {
            if ( parseInt( td.getAttribute( 'width' ), 10 ) === contentWidth ) {
              td.classList.add( 'bmc-card-grid-content-cell' );
            }
          } );
        }
      }
    );

    /* 10. Fix row separators on rowspan tables */
    requestAnimationFrame( function () {
      fixRowspanSeparators( main );
    } );

    /* 11. Pricing page — convert wikitables to flex layout so column
           borders and content stay perfectly aligned even with colspan rows.
           colspan cells get flex-grow = span so they span proportionally. */
    if ( currentPage.indexOf( 'Pricing' ) !== -1 ) {
      document.body.classList.add( 'bmc-pricing' );
      main.querySelectorAll( '.wikitable' ).forEach( function ( table ) {
        table.querySelectorAll( 'tr' ).forEach( function ( row ) {
          row.querySelectorAll( 'th, td' ).forEach( function ( cell ) {
            var span = parseInt( cell.getAttribute( 'colspan' ) || '1', 10 );
            cell.style.setProperty( 'flex', span + ' 1 0%', 'important' );
            cell.style.setProperty( 'min-width', '0', 'important' );
          } );
        } );
      } );
    }

  }

  /* ── Entry point ───────────────────────────────────────────── */
  mw.hook( 'wikipage.content' ).add( function () {
    /* Skip special pages (edit forms, history, etc.) to not break them */
    var ns     = mw.config.get( 'wgNamespaceNumber' );
    var action = mw.config.get( 'wgAction' );
    if ( action !== 'view' ) return;
    if ( ns < 0 ) return; /* Special: pages */

    var nav = extractNavFromDom();
    buildShell( nav );
  } );

} )();