MediaWiki:Gadget-blame.js

From The Deadlock Wiki
Jump to navigation Jump to search

Note: After publishing, you may need to reload with your cache disabled to see your changes.

  • Windows / Linux: Press Ctrl-Shift-R
  • macOS: Press ⌘-Shift-R
/*
 * Gadget: Blame — git blame for articles. Colours every word by the editor who
 * last changed it; words a template or module produced are hatched instead.
 * --> https://starcitizen.tools/index.php?title=MediaWiki:Gadget-blame.js&action=edit <--
 * Special:Export/<page>?history=1 is the transport because the Action API's
 * 50-row content cap does not apply there and $wgExportMaxHistory is not
 * consulted for GET. It also costs the server about a third of what the obvious
 * alternative does: paging content by revision id spends more total server time
 * and spreads it over ten requests instead of one. A wiki with
 * $wgExportAllowHistory=false returns ONE revision rather than an error, hence
 * the fallback to prop=revisions.
 *
 * Parsoid is never consulted. Its source offsets cannot attribute template
 * parameter prose or the reference list, and its HTML can carry unexpanded
 * {{#parsoidfragment}} markers (T432547).
 *
 * Codex components are avoided for bundle size: the chrome is this gadget's own
 * CSS over Codex design tokens, and the popover is the platform's own.
 */
( function () {
	'use strict';

	const text = require( './blame-text.js' );
	const teleportTarget = require( 'mediawiki.page.ready' ).teleportTarget;

	// Time-based, not every N revisions: per-revision cost varies by orders of
	// magnitude, so a fixed count still leaves 50 ms+ tasks.
	const SLICE_MS = 16;
	const PALETTE_SIZE = 8;
	const HIDDEN = '(hidden)';
	// Past the cap the unmatched middle is credited to the newer revision, so a
	// cap below a plausible scattered sweep hands one editor text they never
	// wrote. Myers costs O(cap^2) once exhausted.
	const MAX_D_REVISION = 5000;
	// Page-to-source diverges much further, since template output has no source
	// counterpart at all: 3565 measured on an infobox fed from an API. Exhausting
	// this hatches the WHOLE page rather than the excess.
	const MAX_D_ALIGN = 20000;
	// <style> is here because TemplateStyles injects it inside .mw-parser-output.
	// The reference LIST is deliberately absent: moveRefs makes it attributable.
	//
	// Template classes must NOT be added. Their parameter values stay in the
	// source stream, and the alignment is a monotone LCS, so excluding the
	// rendered region strands those tokens for the first body word that matches.
	const UNPAINTED = '.mw-editsection, sup.reference, .mw-cite-backlink, style, script, .gadget-blame-popover, .gadget-blame-legend';
	// cdxIconClose, inlined: @wikimedia/codex-icons is not a ResourceLoader module.
	const ICON_CLOSE = 'M16.707 4.707 11.414 10l5.293 5.293-1.414 1.414L10 11.414l-5.293 5.293-1.414-1.414L8.586 10 3.293 4.707l1.414-1.414L10 8.586l5.293-5.293z';

	const config = mw.config.get( [
		'wgIsArticle', 'wgArticleId', 'wgAction', 'wgPageName', 'wgRevisionId',
		'wgCurRevisionId'
	] );
	if ( !config.wgIsArticle || !config.wgArticleId || config.wgAction !== 'view' ) {
		return;
	}

	let historyPromise = null;
	const page = { on: false, token: 0, painted: [], legend: null, revById: null };

	function el( tag, attrs, children ) {
		const node = document.createElement( tag );
		Object.keys( attrs || {} ).forEach( ( k ) => {
			if ( k === 'class' ) {
				node.className = attrs[ k ];
			} else if ( k === 'text' ) {
				node.textContent = attrs[ k ];
			} else {
				node.setAttribute( k, attrs[ k ] );
			}
		} );
		( children || [] ).forEach( ( c ) => {
			if ( c ) {
				node.appendChild( typeof c === 'string' ? document.createTextNode( c ) : c );
			}
		} );
		return node;
	}

	function link( href, label, cls ) {
		return el( 'a', { href, class: cls || '', text: label } );
	}

	function iconButton( label, pathD ) {
		const svgNs = 'http://www.w3.org/2000/svg';
		const svg = document.createElementNS( svgNs, 'svg' );
		svg.setAttribute( 'width', '20' );
		svg.setAttribute( 'height', '20' );
		svg.setAttribute( 'viewBox', '0 0 20 20' );
		svg.setAttribute( 'aria-hidden', 'true' );
		const path = document.createElementNS( svgNs, 'path' );
		path.setAttribute( 'd', pathD );
		svg.appendChild( path );
		return el( 'button', {
			type: 'button',
			class: 'gadget-blame__button',
			'aria-label': label,
			title: label
		}, [ svg ] );
	}

	function message( type, children ) {
		return el( 'div', {
			class: 'gadget-blame__notice gadget-blame__notice--' + type,
			'aria-live': 'polite'
		}, children );
	}

	function formatDate( iso ) {
		const d = new Date( iso );
		return isNaN( d ) ? iso : d.toLocaleString( undefined, { dateStyle: 'medium', timeStyle: 'short' } );
	}

	function userLink( name ) {
		if ( !name ) {
			return el( 'span', { class: 'gadget-blame__meta', text: HIDDEN } );
		}
		const isIp = mw.util.isIPAddress ? mw.util.isIPAddress( name ) : /^\d+\.\d+\.\d+\.\d+$/.test( name );
		const target = isIp ? 'Special:Contributions/' + name : 'User:' + name;
		return link( mw.util.getUrl( target ), name, 'gadget-blame__user' );
	}

	function contentRoot() {
		return document.querySelector( '#mw-content-text .mw-parser-output' );
	}

	function tick() {
		return new Promise( ( resolve ) => setTimeout( resolve ) );
	}

	function textOf( parent, selector ) {
		const n = parent.querySelector( selector );
		return n ? n.textContent : '';
	}

	function parseExport( xml ) {
		const doc = new DOMParser().parseFromString( xml, 'application/xml' );
		if ( doc.querySelector( 'parsererror' ) ) {
			throw new Error( 'blame-export-parse' );
		}
		const revs = [];
		doc.querySelectorAll( 'revision' ).forEach( ( r ) => {
			const t = r.querySelector( ':scope > text' );
			const hidden = !t || t.getAttribute( 'deleted' ) === 'deleted';
			revs.push( {
				id: Number( textOf( r, ':scope > id' ) ),
				parentid: Number( textOf( r, ':scope > parentid' ) || 0 ),
				timestamp: textOf( r, ':scope > timestamp' ),
				user: textOf( r, ':scope > contributor > username' ) || textOf( r, ':scope > contributor > ip' ),
				comment: textOf( r, ':scope > comment' ),
				sha1: textOf( r, ':scope > sha1' ) || ( t && t.getAttribute( 'sha1' ) ) || '',
				// null, not '': revision-deleted text is excluded, not an empty page.
				text: hidden ? null : t.textContent
			} );
		} );
		revs.sort( ( a, b ) => ( a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : a.id - b.id ) );
		return revs;
	}

	function fetchExport() {
		const url = mw.util.getUrl( 'Special:Export/' + config.wgPageName, { history: 1 } );
		return fetch( url, { credentials: 'same-origin' } ).then( ( r ) => {
			if ( !r.ok ) {
				throw new Error( 'blame-export-http-' + r.status );
			}
			return r.text();
		} ).then( parseExport );
	}

	function fetchViaApi() {
		const api = new mw.Api();
		const revs = [];
		function batch( cont ) {
			const params = Object.assign( {
				action: 'query',
				prop: 'revisions',
				titles: config.wgPageName,
				rvprop: 'ids|timestamp|user|comment|sha1|content',
				rvslots: 'main',
				rvlimit: 50,
				rvdir: 'newer',
				formatversion: 2
			}, cont || {} );
			return api.get( params ).then( ( data ) => {
				const p = data.query.pages[ 0 ];
				( p.revisions || [] ).forEach( ( r ) => {
					const slot = r.slots && r.slots.main;
					revs.push( {
						id: r.revid,
						parentid: r.parentid || 0,
						timestamp: r.timestamp,
						user: r.user || '',
						comment: r.comment || '',
						sha1: r.sha1 || '',
						text: slot && !slot.texthidden && !r.texthidden &&
							typeof slot.content === 'string' ? slot.content : null
					} );
				} );
				return data.continue ? batch( data.continue ) : revs;
			} );
		}
		return batch();
	}

	function loadHistory() {
		if ( !historyPromise ) {
			historyPromise = fetchExport()
				.catch( fetchViaApi )
				.then( ( revs ) => (
					// $wgExportAllowHistory=false: a one-revision export with a parent.
					revs.length === 1 && revs[ 0 ].parentid > 0 ? fetchViaApi() : revs
				) )
				.catch( ( e ) => {
					historyPromise = null;
					throw e;
				} );
		}
		return historyPromise;
	}

	function setBody( body, children ) {
		body.textContent = '';
		children.forEach( ( c ) => c && body.appendChild( c ) );
	}

	function loading( body, label ) {
		setBody( body, [
			el( 'div', { class: 'gadget-blame__bar', role: 'progressbar', 'aria-label': label } ),
			el( 'div', { class: 'gadget-blame__meta', text: label } )
		] );
	}

	function renderError( body, err ) {
		setBody( body, [ message( 'error', [
			'Could not load the page history (' + ( err && err.message ? err.message : 'unknown error' ) + ').'
		] ) ] );
	}

	// The popover attribute gives light dismissal, Escape and the top layer. Only
	// placement is ours: CSS anchor positioning would do it declaratively but is
	// not yet in Firefox or Safari.
	let popoverEl = null;
	let popoverAnchor = null;

	function setActive( on ) {
		if ( popoverAnchor ) {
			popoverAnchor.classList.toggle( 'gadget-blame-w--active', on );
		}
	}

	function place() {
		if ( !popoverEl || !popoverAnchor ) {
			return;
		}
		const gap = 8;
		const a = popoverAnchor.getBoundingClientRect();
		const p = popoverEl.getBoundingClientRect();
		let top = a.bottom + gap;
		if ( top + p.height > window.innerHeight - gap && a.top - gap - p.height > gap ) {
			top = a.top - gap - p.height;
		}
		const left = Math.max( gap, Math.min( a.left, window.innerWidth - p.width - gap ) );
		popoverEl.style.top = Math.max( gap, top ) + 'px';
		popoverEl.style.left = left + 'px';
	}

	function hidePopover() {
		if ( !popoverEl ) {
			return;
		}
		if ( popoverEl.hidePopover ) {
			if ( popoverEl.matches( ':popover-open' ) ) {
				popoverEl.hidePopover();
			}
		} else {
			popoverEl.classList.remove( 'gadget-blame-popover--open' );
		}
		setActive( false );
		popoverAnchor = null;
	}

	function ensurePopoverEl() {
		if ( popoverEl ) {
			return popoverEl;
		}
		popoverEl = el( 'div', { class: 'gadget-blame-popover', role: 'dialog', 'aria-label': 'Revision' } );
		if ( 'popover' in popoverEl ) {
			popoverEl.setAttribute( 'popover', 'auto' );
			// Light dismissal bypasses hidePopover, so the ring is cleared here.
			popoverEl.addEventListener( 'toggle', ( e ) => {
				if ( e.newState === 'closed' ) {
					setActive( false );
					popoverAnchor = null;
				}
			} );
		} else {
			document.addEventListener( 'click', ( e ) => {
				if ( popoverAnchor && !popoverEl.contains( e.target ) && !e.target.closest( '.gadget-blame-w' ) ) {
					hidePopover();
				}
			} );
			document.addEventListener( 'keydown', ( e ) => {
				if ( e.key === 'Escape' && popoverAnchor ) {
					hidePopover();
				}
			} );
		}
		// The anchor is inline text, so it moves with the page.
		window.addEventListener( 'scroll', place, true );
		window.addEventListener( 'resize', place );
		teleportTarget.appendChild( popoverEl );
		return popoverEl;
	}

	function card( rev ) {
		if ( !rev ) {
			return [ message( 'notice', [ 'Not written on this page — template or module output.' ] ) ];
		}
		const isIp = mw.util.isIPAddress ? mw.util.isIPAddress( rev.user ) : false;
		const who = rev.user ?
			link( mw.util.getUrl( ( isIp ? 'Special:Contributions/' : 'User:' ) + rev.user ), rev.user, 'gadget-blame__user' ) :
			el( 'span', { class: 'gadget-blame__meta', text: HIDDEN } );
		return [ el( 'div', { class: 'gadget-blame__card' }, [
			el( 'div', { class: 'gadget-blame__date', text: formatDate( rev.timestamp ) } ),
			el( 'div', null, [ who ] ),
			rev.comment ? el( 'div', { class: 'gadget-blame__summary', text: rev.comment } ) : null,
			el( 'div', { class: 'gadget-blame__links' }, [
				link( mw.util.getUrl( 'Special:Diff/' + rev.id ), 'Diff' ),
				link( mw.util.getUrl( 'Special:PermanentLink/' + rev.id ), 'Revision ' + rev.id )
			] )
		] ) ];
	}

	function showPopover( anchor, rev ) {
		const node = ensurePopoverEl();
		setActive( false );
		popoverAnchor = anchor;
		setBody( node, card( rev ) );
		if ( node.showPopover ) {
			if ( node.matches( ':popover-open' ) ) {
				node.hidePopover();
			}
			node.showPopover();
		} else {
			node.classList.add( 'gadget-blame-popover--open' );
		}
		setActive( true );
		place();
	}

	// Text is gathered per block so a word split across inline markup
	// ("<i>Title</i>'s") is tokenised whole rather than as three fragments.
	const BLOCKS = 'address, article, aside, blockquote, caption, dd, details, dialog, div, dl, dt, fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, li, main, nav, ol, p, pre, section, summary, table, tbody, td, tfoot, th, thead, tr, ul';

	// A word straddling an inline element has more than one text-node range.
	function renderedTokens( root ) {
		const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, {
			acceptNode: ( n ) => {
				const p = n.parentElement;
				return p && !p.closest( UNPAINTED ) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
			}
		} );
		const re = new RegExp( text.TOKEN_RE.source, text.TOKEN_RE.flags );
		const out = [];
		let block = null;
		let joined = '';
		let segments = [];

		function flush() {
			if ( !joined ) {
				return;
			}
			re.lastIndex = 0;
			let m;
			let seg = 0;
			while ( ( m = re.exec( joined ) ) ) {
				const norm = text.normaliseText( m[ 0 ] );
				if ( !norm ) {
					continue;
				}
				const from = m.index;
				const to = from + m[ 0 ].length;
				while ( seg < segments.length && segments[ seg ].end <= from ) {
					seg++;
				}
				const pieces = [];
				for ( let i = seg; i < segments.length && segments[ i ].start < to; i++ ) {
					const sgm = segments[ i ];
					pieces.push( {
						node: sgm.node,
						start: Math.max( from, sgm.start ) - sgm.start,
						end: Math.min( to, sgm.end ) - sgm.start
					} );
				}
				out.push( { pieces, norm } );
			}
			joined = '';
			segments = [];
		}

		let n;
		while ( ( n = walker.nextNode() ) ) {
			const owner = n.parentElement.closest( BLOCKS ) || root;
			if ( owner !== block ) {
				flush();
				block = owner;
			}
			segments.push( { node: n, start: joined.length, end: joined.length + n.nodeValue.length } );
			joined += n.nodeValue;
		}
		flush();
		return out;
	}

	// One replacement per text node; the original is kept for unpaint().
	function paint( tokens, classify ) {
		// A span cannot straddle text nodes, so a word split across inline markup
		// is painted once per node, each piece carrying the same origin.
		const byNode = new Map();
		tokens.forEach( ( t, k ) => {
			const c = classify( k );
			t.pieces.forEach( ( piece ) => {
				if ( !byNode.has( piece.node ) ) {
					byNode.set( piece.node, [] );
				}
				byNode.get( piece.node ).push( { start: piece.start, end: piece.end, c } );
			} );
		} );
		byNode.forEach( ( pieces, node ) => {
			const s = node.nodeValue;
			const frag = document.createDocumentFragment();
			const added = [];
			let pos = 0;
			let k = 0;
			while ( k < pieces.length ) {
				const c = pieces[ k ].c;
				const key = c ? c.rev : -1;
				const start = pieces[ k ].start;
				let end = pieces[ k ].end;
				let next = k + 1;
				while ( next < pieces.length ) {
					const cn = pieces[ next ].c;
					if ( ( cn ? cn.rev : -1 ) !== key ) {
						break;
					}
					end = pieces[ next ].end;
					next++;
				}
				if ( start > pos ) {
					added.push( frag.appendChild( document.createTextNode( s.slice( pos, start ) ) ) );
				}
				const span = el( 'span', {
					class: 'gadget-blame-w ' + ( c ? 'gadget-blame-a' + c.cls : 'gadget-blame-w--template' ),
					text: s.slice( start, end )
				} );
				if ( c ) {
					span.setAttribute( 'data-gadget-blame-revid', String( c.rev ) );
					// So attribution is not carried by colour alone.
					span.setAttribute( 'title', c.who );
				}
				added.push( frag.appendChild( span ) );
				pos = end;
				k = next;
			}
			if ( pos < s.length ) {
				added.push( frag.appendChild( document.createTextNode( s.slice( pos ) ) ) );
			}
			node.parentNode.replaceChild( frag, node );
			page.painted.push( { original: node, pieces: added } );
		} );
	}

	// Roving tabindex: thousands of tab stops would make the article unusable.
	// The runs deliberately carry no role either — one would make a screen reader
	// announce every phrase as a control instead of reading it as prose.
	function runs() {
		const root = contentRoot();
		return root ? Array.from( root.querySelectorAll( '.gadget-blame-w' ) ) : [];
	}

	function setTabStop( run ) {
		runs().forEach( ( r ) => r.setAttribute( 'tabindex', r === run ? '0' : '-1' ) );
	}

	function moveTabStop( from, delta ) {
		const all = runs();
		const i = all.indexOf( from );
		const next = all[ Math.min( all.length - 1, Math.max( 0, ( i < 0 ? 0 : i ) + delta ) ) ];
		if ( next ) {
			setTabStop( next );
			next.focus();
			next.scrollIntoView( { block: 'nearest' } );
		}
		return next;
	}

	function unpaint() {
		page.painted.forEach( ( p ) => {
			const first = p.pieces[ 0 ];
			if ( first && first.parentNode ) {
				first.parentNode.insertBefore( p.original, first );
			}
			p.pieces.forEach( ( n ) => n.parentNode && n.parentNode.removeChild( n ) );
		} );
		page.painted = [];
	}

	function closeLegend() {
		if ( page.legend ) {
			page.legend.remove();
			page.legend = null;
		}
	}

	function turnOff() {
		page.token++;
		page.on = false;
		unpaint();
		closeLegend();
		page.revById = null;
		hidePopover();
	}

	function openLegend() {
		closeLegend();
		const body = el( 'div', { class: 'gadget-blame__body' } );
		const off = iconButton( 'Turn off', ICON_CLOSE );
		off.addEventListener( 'click', turnOff );
		page.legend = el( 'div', { class: 'gadget-blame-legend', role: 'region', 'aria-label': 'Blame legend' }, [
			el( 'div', { class: 'gadget-blame__header' }, [
				el( 'span', { class: 'gadget-blame__title', text: 'Blame' } ),
				off
			] ),
			body
		] );
		teleportTarget.appendChild( page.legend );
		return body;
	}

	function pct( n, total ) {
		if ( !total || !n ) {
			return '0%';
		}
		const p = Math.round( 100 * n / total );
		return p === 0 ? '<1%' : p + '%';
	}

	function legendRow( swatchClass, label, share ) {
		const labelNode = typeof label === 'string' ? el( 'span', { text: label } ) : label;
		labelNode.classList.add( 'gadget-blame__label' );
		return el( 'div', { class: 'gadget-blame__row' }, [
			el( 'span', { class: 'gadget-blame-swatch ' + swatchClass } ),
			labelNode,
			el( 'span', { class: 'gadget-blame__meta', text: share } )
		] );
	}

	function plural( n, word ) {
		return n + ' ' + word + ( n === 1 ? '' : 's' );
	}

	function renderLegend( body, stats ) {
		const rows = stats.authors.slice( 0, PALETTE_SIZE ).map( ( pair, idx ) => legendRow(
			'gadget-blame-a' + idx,
			userLink( pair[ 0 ] === HIDDEN ? '' : pair[ 0 ] ),
			pct( pair[ 1 ], stats.total )
		) );
		const rest = stats.authors.slice( PALETTE_SIZE );
		if ( rest.length ) {
			const n = rest.reduce( ( sum, pair ) => sum + pair[ 1 ], 0 );
			rows.push( legendRow( 'gadget-blame-ax', plural( rest.length, 'other editor' ), pct( n, stats.total ) ) );
		}
		if ( stats.template ) {
			rows.push( legendRow( 'gadget-blame-w--template', 'Template output', pct( stats.template, stats.total ) ) );
		}
		const notes = [ plural( stats.revisions, 'revision' ) + ' blamed' ];
		if ( stats.reused ) {
			notes.push( plural( stats.reused, 'revision' ) + ' restored an earlier version' );
		}
		if ( stats.capped ) {
			notes.push( plural( stats.capped, 'revision' ) + ' changed too much to trace word by word' );
		}
		if ( stats.gaps ) {
			notes.push( 'text older than this history, or from a hidden revision, is credited to the oldest revision that shows it' );
		}
		if ( stats.alignCapped ) {
			notes.push( 'this page and its source differ too much to line up, so nothing could be attributed' );
		}
		rows.push( el( 'div', {
			class: 'gadget-blame__footer',
			text: notes.join( '. ' ) + '. Click a word for its revision, or tab into the text and use the arrow keys.'
		} ) );
		setBody( body, rows );
	}

	// One revision at a time: only the previous token stream is ever needed, and
	// holding every stream at once runs to hundreds of megabytes.
	async function buildOrigins( revs, body, token ) {
		const pageName = config.wgPageName.replace( /_/g, ' ' );
		const blamer = text.makeBlamer(
			text.duplicateSha1s( revs.map( ( r ) => r.sha1 ) ),
			MAX_D_REVISION
		);
		const intern = text.makeInterner();
		let reused = 0;
		let slice = performance.now();
		for ( let i = 0; i < revs.length; i++ ) {
			const r = revs[ i ];
			if ( blamer.reuse( r.sha1 ) ) {
				reused++;
			} else {
				blamer.push( intern( text.tokenize( text.wikitextToText( r.text, { pageName } ) ) ), i, r.sha1 );
			}
			if ( performance.now() - slice > SLICE_MS ) {
				loading( body, 'Blaming ' + ( i + 1 ) + ' of ' + revs.length + ' revisions…' );
				await tick();
				if ( token !== page.token ) {
					return null;
				}
				slice = performance.now();
			}
		}
		return { intern, origins: blamer.origins, stream: blamer.stream, capped: blamer.capped, reused };
	}

	async function blamePage() {
		if ( page.on ) {
			turnOff();
			return;
		}
		const root = contentRoot();
		if ( !root ) {
			return;
		}
		page.on = true;
		const token = ++page.token;
		const body = openLegend();
		loading( body, 'Fetching history…' );
		try {
			const all = await loadHistory();
			if ( token !== page.token ) {
				return;
			}
			const upTo = config.wgRevisionId || config.wgCurRevisionId || Infinity;
			const visible = all.filter( ( r ) => r.id <= upTo );
			const revs = visible.filter( ( r ) => r.text !== null );
			if ( !revs.length ) {
				setBody( body, [ message( 'warning', [ 'No readable revisions to blame on this page.' ] ) ] );
				return;
			}
			// Text this history cannot account for: revisions hidden inside it, or a
			// history not starting at the page's creation.
			const gaps = ( visible.length - revs.length ) + ( revs[ 0 ].parentid > 0 ? 1 : 0 );
			const built = await buildOrigins( revs, body, token );
			if ( !built ) {
				return;
			}
			const toks = renderedTokens( root );
			const alignStats = {};
			const norms = toks.map( ( t ) => t.norm );
			const map = text.dropPunctuationOnlyRuns(
				text.diffMap( built.stream, built.intern( norms ), MAX_D_ALIGN, alignStats ),
				norms
			);
			const counts = new Map();
			let aligned = 0;
			for ( let k = 0; k < toks.length; k++ ) {
				if ( map[ k ] >= 0 ) {
					aligned++;
					const u = revs[ built.origins[ map[ k ] ] ].user || HIDDEN;
					counts.set( u, ( counts.get( u ) || 0 ) + 1 );
				}
			}
			const authors = Array.from( counts.entries() ).sort( ( a, b ) => b[ 1 ] - a[ 1 ] );
			const cls = new Map();
			authors.forEach( ( pair, idx ) => cls.set( pair[ 0 ], idx < PALETTE_SIZE ? String( idx ) : 'x' ) );
			page.revById = new Map( revs.map( ( r ) => [ r.id, r ] ) );
			paint( toks, ( k ) => {
				const w = map[ k ];
				if ( w < 0 ) {
					return null;
				}
				const r = revs[ built.origins[ w ] ];
				return { cls: cls.get( r.user || HIDDEN ), rev: r.id, who: r.user || HIDDEN };
			} );
			setTabStop( toks.length ? contentRoot().querySelector( '.gadget-blame-w' ) : null );
			renderLegend( body, {
				authors,
				total: toks.length,
				template: toks.length - aligned,
				revisions: revs.length,
				reused: built.reused,
				capped: built.capped,
				alignCapped: !!alignStats.capped,
				gaps
			} );
			// Tens of megabytes on a long history; only popover metadata is kept.
			revs.forEach( ( r ) => {
				r.text = null;
			} );
			historyPromise = null;
		} catch ( err ) {
			if ( token === page.token ) {
				renderError( body, err );
			}
		}
	}

	function openRun( w ) {
		setTabStop( w );
		const id = w.getAttribute( 'data-gadget-blame-revid' );
		showPopover( w, id === null ? null : page.revById.get( Number( id ) ) );
	}

	// While blame is on, a run inside a link opens its revision instead of
	// following the link.
	document.addEventListener( 'click', ( e ) => {
		const w = page.on && e.target.closest && e.target.closest( '.gadget-blame-w' );
		if ( !w ) {
			return;
		}
		if ( w.closest( 'a' ) ) {
			e.preventDefault();
		}
		openRun( w );
	} );

	document.addEventListener( 'keydown', ( e ) => {
		const w = page.on && e.target.closest && e.target.closest( '.gadget-blame-w' );
		if ( !w || e.altKey || e.ctrlKey || e.metaKey ) {
			return;
		}
		if ( e.key === 'ArrowRight' || e.key === 'ArrowDown' ) {
			e.preventDefault();
			moveTabStop( w, 1 );
		} else if ( e.key === 'ArrowLeft' || e.key === 'ArrowUp' ) {
			e.preventDefault();
			moveTabStop( w, -1 );
		} else if ( e.key === 'Enter' || e.key === ' ' ) {
			e.preventDefault();
			openRun( w );
		}
	} );

	const item = mw.util.addPortletLink( 'p-tb', '#', 'Blame this page', 'gadget-blame-page', 'Colour every word by the editor who last changed it' );
	if ( item ) {
		item.addEventListener( 'click', ( e ) => {
			e.preventDefault();
			blamePage();
		} );
	}
}() );