<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://deadlock.wiki/MediaWiki:Gadget-blame-text.js?action=history&amp;feed=atom</id>
	<title>MediaWiki:Gadget-blame-text.js - Revision history</title>
	<link rel="self" type="application/atom+xml" href="https://deadlock.wiki/MediaWiki:Gadget-blame-text.js?action=history&amp;feed=atom"/>
	<link rel="alternate" type="text/html" href="https://deadlock.wiki/MediaWiki:Gadget-blame-text.js?action=history"/>
	<updated>2026-09-13T14:06:33Z</updated>
	<subtitle>Revision history for this page on the wiki</subtitle>
	<generator>MediaWiki 1.46.0</generator>
	<entry>
		<id>https://deadlock.wiki/MediaWiki:Gadget-blame-text.js?diff=126546&amp;oldid=prev</id>
		<title>Kai: Created page with &quot;/*  * Gadget: Blame — text helpers. Loaded both by Gadget-blame.js as a package  * file and by tests/js/ under Node, so it must stay CommonJS, side-effect free  * and free of DOM and `mw`.  * --&gt; https://starcitizen.tools/index.php?title=MediaWiki:Gadget-blame-text.js&amp;action=edit &lt;--  * The normaliser only has to make words line up, not reproduce the parser. The  * two failure directions are not symmetric: over-inclusion is harmless, because  * a word must also appear...&quot;</title>
		<link rel="alternate" type="text/html" href="https://deadlock.wiki/MediaWiki:Gadget-blame-text.js?diff=126546&amp;oldid=prev"/>
		<updated>2026-09-05T10:05:22Z</updated>

		<summary type="html">&lt;p&gt;Created page with &amp;quot;/*  * Gadget: Blame — text helpers. Loaded both by Gadget-blame.js as a package  * file and by tests/js/ under Node, so it must stay CommonJS, side-effect free  * and free of DOM and `mw`.  * --&amp;gt; https://starcitizen.tools/index.php?title=MediaWiki:Gadget-blame-text.js&amp;amp;action=edit &amp;lt;--  * The normaliser only has to make words line up, not reproduce the parser. The  * two failure directions are not symmetric: over-inclusion is harmless, because  * a word must also appear...&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;b&gt;New page&lt;/b&gt;&lt;/p&gt;&lt;div&gt;/*&lt;br /&gt;
 * Gadget: Blame — text helpers. Loaded both by Gadget-blame.js as a package&lt;br /&gt;
 * file and by tests/js/ under Node, so it must stay CommonJS, side-effect free&lt;br /&gt;
 * and free of DOM and `mw`.&lt;br /&gt;
 * --&amp;gt; https://starcitizen.tools/index.php?title=MediaWiki:Gadget-blame-text.js&amp;amp;action=edit &amp;lt;--&lt;br /&gt;
 * The normaliser only has to make words line up, not reproduce the parser. The&lt;br /&gt;
 * two failure directions are not symmetric: over-inclusion is harmless, because&lt;br /&gt;
 * a word must also appear in the rendered article to be painted, while&lt;br /&gt;
 * under-inclusion silently hatches an article word as template output.&lt;br /&gt;
 */&lt;br /&gt;
( function () {&lt;br /&gt;
	&amp;#039;use strict&amp;#039;;&lt;br /&gt;
&lt;br /&gt;
	// &amp;lt;ref&amp;gt; is deliberately NOT here: moveRefs relocates its body to the reference&lt;br /&gt;
	// list instead, so the text stays attributable.&lt;br /&gt;
	const DROP_TAGS = &amp;#039;references|gallery|imagemap|syntaxhighlight|source|pre|math|score|templatedata|timeline|graph|mapframe|maplink|inputbox|categorytree&amp;#039;;&lt;br /&gt;
	// &amp;lt;nowiki&amp;gt; is handled separately: its body must survive the markup rules&lt;br /&gt;
	// literally, not merely lose its tags.&lt;br /&gt;
	const UNWRAP_TAGS = &amp;#039;noinclude|includeonly|onlyinclude&amp;#039;;&lt;br /&gt;
&lt;br /&gt;
	const NAMED_ENTITIES = {&lt;br /&gt;
		// Space entities decode to ASCII space, not their real code point: the&lt;br /&gt;
		// deploy transport rewrites non-ASCII whitespace in source.&lt;br /&gt;
		nbsp: &amp;#039; &amp;#039;, ensp: &amp;#039; &amp;#039;, emsp: &amp;#039; &amp;#039;, thinsp: &amp;#039; &amp;#039;,&lt;br /&gt;
		zwnj: String.fromCharCode( 0x200C ), zwj: String.fromCharCode( 0x200D ), shy: String.fromCharCode( 0xAD ),&lt;br /&gt;
		lt: &amp;#039;&amp;lt;&amp;#039;, gt: &amp;#039;&amp;gt;&amp;#039;, quot: &amp;#039;&amp;quot;&amp;#039;, apos: &amp;#039;\&amp;#039;&amp;#039;,&lt;br /&gt;
		ndash: &amp;#039;–&amp;#039;, mdash: &amp;#039;—&amp;#039;, hellip: &amp;#039;…&amp;#039;,&lt;br /&gt;
		laquo: &amp;#039;«&amp;#039;, raquo: &amp;#039;»&amp;#039;, ldquo: &amp;#039;“&amp;#039;, rdquo: &amp;#039;”&amp;#039;,&lt;br /&gt;
		lsquo: &amp;#039;‘&amp;#039;, rsquo: &amp;#039;’&amp;#039;, bull: &amp;#039;•&amp;#039;, middot: &amp;#039;·&amp;#039;,&lt;br /&gt;
		deg: &amp;#039;°&amp;#039;, times: &amp;#039;×&amp;#039;, minus: &amp;#039;−&amp;#039;, plusmn: &amp;#039;±&amp;#039;,&lt;br /&gt;
		micro: &amp;#039;µ&amp;#039;, copy: &amp;#039;©&amp;#039;, reg: &amp;#039;®&amp;#039;, trade: &amp;#039;™&amp;#039;,&lt;br /&gt;
		frac12: &amp;#039;½&amp;#039;, frac14: &amp;#039;¼&amp;#039;, frac34: &amp;#039;¾&amp;#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// String.fromCodePoint THROWS above U+10FFFF and on surrogates, which would&lt;br /&gt;
	// abort the whole run over one malformed entity.&lt;br /&gt;
	function codePoint( m, code ) {&lt;br /&gt;
		return code &amp;lt;= 0x10FFFF &amp;amp;&amp;amp; ( code &amp;lt; 0xD800 || code &amp;gt; 0xDFFF ) ? String.fromCodePoint( code ) : m;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function decodeEntities( s ) {&lt;br /&gt;
		return s&lt;br /&gt;
			.replace( /&amp;amp;#(\d+);/g, ( m, d ) =&amp;gt; codePoint( m, Number( d ) ) )&lt;br /&gt;
			.replace( /&amp;amp;#x([0-9a-f]+);/gi, ( m, h ) =&amp;gt; codePoint( m, parseInt( h, 16 ) ) )&lt;br /&gt;
			.replace( /&amp;amp;([a-z][a-z0-9]*);/gi, ( m, name ) =&amp;gt; {&lt;br /&gt;
				const v = NAMED_ENTITIES[ name.toLowerCase() ];&lt;br /&gt;
				return v === undefined ? m : v;&lt;br /&gt;
			} )&lt;br /&gt;
			// Last, so &amp;quot;&amp;amp;amp;lt;&amp;quot; decodes to the literal &amp;quot;&amp;amp;lt;&amp;quot; rather than &amp;quot;&amp;lt;&amp;quot;.&lt;br /&gt;
			.replace( /&amp;amp;amp;/g, &amp;#039;&amp;amp;&amp;#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Applied to BOTH sides of the comparison. Case folds because {{lc:}}/{{uc:}}&lt;br /&gt;
	// and CSS text-transform would otherwise split a match. Built from code points&lt;br /&gt;
	// because the deploy transport decodes \u escapes, so an invisible character&lt;br /&gt;
	// written literally would reach the wiki as raw bytes.&lt;br /&gt;
	// Soft hyphen, zero-width space/joiners/marks, word joiner, BOM.&lt;br /&gt;
	const ZERO_WIDTH = new RegExp( &amp;#039;[&amp;#039; + String.fromCharCode( 0xAD, 0x200B ) + &amp;#039;-&amp;#039; + String.fromCharCode( 0x200F, 0x2060, 0xFEFF ) + &amp;#039;]&amp;#039;, &amp;#039;g&amp;#039; );&lt;br /&gt;
	// NFC is a no-op on printable ASCII, and the test is far cheaper than it.&lt;br /&gt;
	const NON_ASCII = /[^\x20-\x7E\t\n\r]/;&lt;br /&gt;
	function normaliseText( s ) {&lt;br /&gt;
		return ( NON_ASCII.test( s ) ? s.normalize( &amp;#039;NFC&amp;#039; ) : s )&lt;br /&gt;
			.replace( ZERO_WIDTH, &amp;#039;&amp;#039; )&lt;br /&gt;
			// \s already covers nbsp, U+2028/9 and the rest.&lt;br /&gt;
			.replace( /\s+/g, &amp;#039; &amp;#039; )&lt;br /&gt;
			.trim()&lt;br /&gt;
			.toLowerCase();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// A &amp;lt;ref&amp;gt; body sits inline in the wikitext but renders at the foot of the page,&lt;br /&gt;
	// and the alignment is order-sensitive, so the bodies move to the end. Order is&lt;br /&gt;
	// Cite&amp;#039;s: first APPEARANCE of the name, definition or reuse, whichever comes&lt;br /&gt;
	// first. Ordering by definition would shift an entry whenever an editor&lt;br /&gt;
	// converts one to a reuse, dragging the alignment with it.&lt;br /&gt;
	const REF_RE = /&amp;lt;ref(\s[^&amp;gt;]*?)?\s*\/&amp;gt;|&amp;lt;ref(\s[^&amp;gt;]*?)?&amp;gt;([\s\S]*?)&amp;lt;\/ref\s*&amp;gt;/gi;&lt;br /&gt;
	const REF_NAME_RE = /name\s*=\s*(?:&amp;quot;([^&amp;quot;]*)&amp;quot;|&amp;#039;([^&amp;#039;]*)&amp;#039;|([^\s/&amp;gt;]+))/i;&lt;br /&gt;
	function moveRefs( s ) {&lt;br /&gt;
		const order = [];&lt;br /&gt;
		const byName = new Map();&lt;br /&gt;
		const stripped = s.replace( REF_RE, ( m, selfAttrs, openAttrs, body ) =&amp;gt; {&lt;br /&gt;
			const nm = REF_NAME_RE.exec( ( body === undefined ? selfAttrs : openAttrs ) || &amp;#039;&amp;#039; );&lt;br /&gt;
			const name = nm ? ( nm[ 1 ] || nm[ 2 ] || nm[ 3 ] ) : null;&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				if ( body !== undefined ) {&lt;br /&gt;
					order.push( { body } );&lt;br /&gt;
				}&lt;br /&gt;
				return &amp;#039; &amp;#039;;&lt;br /&gt;
			}&lt;br /&gt;
			let entry = byName.get( name );&lt;br /&gt;
			if ( !entry ) {&lt;br /&gt;
				entry = { body: &amp;#039;&amp;#039; };&lt;br /&gt;
				byName.set( name, entry );&lt;br /&gt;
				order.push( entry );&lt;br /&gt;
			}&lt;br /&gt;
			if ( body !== undefined &amp;amp;&amp;amp; !entry.body ) {&lt;br /&gt;
				entry.body = body;&lt;br /&gt;
			}&lt;br /&gt;
			return &amp;#039; &amp;#039;;&lt;br /&gt;
		} );&lt;br /&gt;
		const bodies = order.map( ( e ) =&amp;gt; e.body ).filter( Boolean );&lt;br /&gt;
		return bodies.length ? stripped + &amp;#039;\n&amp;#039; + bodies.join( &amp;#039;\n&amp;#039; ) : stripped;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Character codes, not substr: this runs over every character of every&lt;br /&gt;
	// revision, and substr allocates a string per position.&lt;br /&gt;
	function splitTopLevel( s ) {&lt;br /&gt;
		const parts = [];&lt;br /&gt;
		let depth = 0;&lt;br /&gt;
		let start = 0;&lt;br /&gt;
		for ( let i = 0; i &amp;lt; s.length; i++ ) {&lt;br /&gt;
			const c = s.charCodeAt( i );&lt;br /&gt;
			const d = s.charCodeAt( i + 1 );&lt;br /&gt;
			if ( ( c === 123 &amp;amp;&amp;amp; d === 123 ) || ( c === 91 &amp;amp;&amp;amp; d === 91 ) ) {&lt;br /&gt;
				depth++;&lt;br /&gt;
				i++;&lt;br /&gt;
			} else if ( ( c === 125 &amp;amp;&amp;amp; d === 125 ) || ( c === 93 &amp;amp;&amp;amp; d === 93 ) ) {&lt;br /&gt;
				depth = Math.max( 0, depth - 1 );&lt;br /&gt;
				i++;&lt;br /&gt;
			} else if ( c === 124 &amp;amp;&amp;amp; depth === 0 ) {&lt;br /&gt;
				parts.push( s.slice( start, i ) );&lt;br /&gt;
				start = i + 1;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		parts.push( s.slice( start ) );&lt;br /&gt;
		return parts;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Parameter VALUES are article-authored prose (infobox fields, {{Note|...}},&lt;br /&gt;
	// {{quote|...}}) and must stay matchable; names and keys never render. Every&lt;br /&gt;
	// parser-function branch is kept, since over-inclusion is safe.&lt;br /&gt;
	function stripTemplates( s ) {&lt;br /&gt;
		let out = &amp;#039;&amp;#039;;&lt;br /&gt;
		let i = 0;&lt;br /&gt;
		while ( i &amp;lt; s.length ) {&lt;br /&gt;
			if ( s.startsWith( &amp;#039;{{&amp;#039;, i ) ) {&lt;br /&gt;
				let depth = 0;&lt;br /&gt;
				let j = i;&lt;br /&gt;
				let end = -1;&lt;br /&gt;
				while ( j &amp;lt; s.length ) {&lt;br /&gt;
					if ( s.startsWith( &amp;#039;{{&amp;#039;, j ) ) {&lt;br /&gt;
						depth++;&lt;br /&gt;
						j += 2;&lt;br /&gt;
					} else if ( s.startsWith( &amp;#039;}}&amp;#039;, j ) ) {&lt;br /&gt;
						depth--;&lt;br /&gt;
						j += 2;&lt;br /&gt;
						if ( depth === 0 ) {&lt;br /&gt;
							end = j;&lt;br /&gt;
							break;&lt;br /&gt;
						}&lt;br /&gt;
					} else {&lt;br /&gt;
						j++;&lt;br /&gt;
					}&lt;br /&gt;
				}&lt;br /&gt;
				if ( end === -1 ) {&lt;br /&gt;
					out += s.slice( i );&lt;br /&gt;
					break;&lt;br /&gt;
				}&lt;br /&gt;
				const inner = s.slice( i + 2, end - 2 );&lt;br /&gt;
				const params = splitTopLevel( inner ).slice( 1 );&lt;br /&gt;
				const values = params.map( ( p ) =&amp;gt; {&lt;br /&gt;
					const m = /^\s*[^=|{}[\]&amp;lt;&amp;gt;]{1,80}?\s*=([\s\S]*)$/.exec( p );&lt;br /&gt;
					return stripTemplates( m ? m[ 1 ] : p );&lt;br /&gt;
				} );&lt;br /&gt;
				out += &amp;#039; &amp;#039; + values.join( &amp;#039; &amp;#039; ) + &amp;#039; &amp;#039;;&lt;br /&gt;
				i = end;&lt;br /&gt;
			} else {&lt;br /&gt;
				// Appending character by character dominated the profile.&lt;br /&gt;
				const next = s.indexOf( &amp;#039;{{&amp;#039;, i );&lt;br /&gt;
				if ( next === -1 ) {&lt;br /&gt;
					out += s.slice( i );&lt;br /&gt;
					break;&lt;br /&gt;
				}&lt;br /&gt;
				out += s.slice( i, next );&lt;br /&gt;
				i = next;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return out;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Balanced removal, because captions nest links. A LEADING COLON makes it a&lt;br /&gt;
	// link to the page rather than a file or category, so it is left alone.&lt;br /&gt;
	function stripFileLinks( s ) {&lt;br /&gt;
		const re = /\[\[(?:file|image|media|category):/gi;&lt;br /&gt;
		let out = &amp;#039;&amp;#039;;&lt;br /&gt;
		let last = 0;&lt;br /&gt;
		let m;&lt;br /&gt;
		while ( ( m = re.exec( s ) ) !== null ) {&lt;br /&gt;
			let depth = 0;&lt;br /&gt;
			let j = m.index;&lt;br /&gt;
			let end = -1;&lt;br /&gt;
			while ( j &amp;lt; s.length ) {&lt;br /&gt;
				if ( s.startsWith( &amp;#039;[[&amp;#039;, j ) ) {&lt;br /&gt;
					depth++;&lt;br /&gt;
					j += 2;&lt;br /&gt;
				} else if ( s.startsWith( &amp;#039;]]&amp;#039;, j ) ) {&lt;br /&gt;
					depth--;&lt;br /&gt;
					j += 2;&lt;br /&gt;
					if ( depth === 0 ) {&lt;br /&gt;
						end = j;&lt;br /&gt;
						break;&lt;br /&gt;
					}&lt;br /&gt;
				} else {&lt;br /&gt;
					j++;&lt;br /&gt;
				}&lt;br /&gt;
			}&lt;br /&gt;
			if ( end === -1 ) {&lt;br /&gt;
				break;&lt;br /&gt;
			}&lt;br /&gt;
			out += s.slice( last, m.index ) + &amp;#039; &amp;#039;;&lt;br /&gt;
			last = end;&lt;br /&gt;
			re.lastIndex = end;&lt;br /&gt;
		}&lt;br /&gt;
		return out + s.slice( last );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Must run AFTER stripTemplates, or &amp;quot;| key = value&amp;quot; parameters read as cells.&lt;br /&gt;
	function stripTables( s ) {&lt;br /&gt;
		// Most revisions have no table markup and the split and rejoin is costly.&lt;br /&gt;
		if ( !/^[|!]|^\{\|/m.test( s ) ) {&lt;br /&gt;
			return s;&lt;br /&gt;
		}&lt;br /&gt;
		return s.split( &amp;#039;\n&amp;#039; ).map( ( line ) =&amp;gt; {&lt;br /&gt;
			const t = line.trim();&lt;br /&gt;
			if ( /^\{\|/.test( t ) || t === &amp;#039;|}&amp;#039; || /^\|-/.test( t ) ) {&lt;br /&gt;
				return &amp;#039;&amp;#039;;&lt;br /&gt;
			}&lt;br /&gt;
			if ( /^\|\+/.test( t ) ) {&lt;br /&gt;
				return t.slice( 2 );&lt;br /&gt;
			}&lt;br /&gt;
			if ( /^[|!]/.test( t ) ) {&lt;br /&gt;
				return t.slice( 1 ).split( /\|\||!!/ ).map( ( cell ) =&amp;gt; {&lt;br /&gt;
					// A single pipe whose left side looks like attributes is a cell&lt;br /&gt;
					// prefix, not content.&lt;br /&gt;
					const k = cell.indexOf( &amp;#039;|&amp;#039; );&lt;br /&gt;
					if ( k !== -1 &amp;amp;&amp;amp; /^\s*[a-z-]+\s*=/i.test( cell ) ) {&lt;br /&gt;
						return cell.slice( k + 1 );&lt;br /&gt;
					}&lt;br /&gt;
					return cell;&lt;br /&gt;
				} ).join( &amp;#039; &amp;#039; );&lt;br /&gt;
			}&lt;br /&gt;
			return line;&lt;br /&gt;
		} ).join( &amp;#039;\n&amp;#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Wikitext → approximate rendered prose, normalised.&lt;br /&gt;
	 *&lt;br /&gt;
	 * @param {string} wt&lt;br /&gt;
	 * @param {{pageName?: string}} [opts] pageName substitutes {{PAGENAME}} family&lt;br /&gt;
	 * @return {string}&lt;br /&gt;
	 */&lt;br /&gt;
	function wikitextToText( wt, opts ) {&lt;br /&gt;
		const pageName = ( opts &amp;amp;&amp;amp; opts.pageName ) || &amp;#039;&amp;#039;;&lt;br /&gt;
		let s = wt;&lt;br /&gt;
		s = s.replace( /&amp;lt;!--[\s\S]*?--&amp;gt;/g, &amp;#039;&amp;#039; );&lt;br /&gt;
		// Restored before entity decoding, which MediaWiki still applies inside&lt;br /&gt;
		// nowiki. A bare &amp;lt;nowiki/&amp;gt; becomes an EMPTY PLACEHOLDER rather than&lt;br /&gt;
		// vanishing: editors use it to split apostrophe runs (&amp;#039;&amp;#039;Ship&amp;#039;&amp;#039;&amp;lt;nowiki/&amp;gt;&amp;#039;s),&lt;br /&gt;
		// and removing it outright would re-fuse them into bold markup.&lt;br /&gt;
		const nowikis = [];&lt;br /&gt;
		const NUL = String.fromCharCode( 0 );&lt;br /&gt;
		s = s.replace( /&amp;lt;nowiki\s*\/&amp;gt;|&amp;lt;nowiki&amp;gt;([\s\S]*?)&amp;lt;\/nowiki&amp;gt;/gi, ( m, body ) =&amp;gt; {&lt;br /&gt;
			nowikis.push( body || &amp;#039;&amp;#039; );&lt;br /&gt;
			return NUL + ( nowikis.length - 1 ) + NUL;&lt;br /&gt;
		} );&lt;br /&gt;
		s = moveRefs( s );&lt;br /&gt;
		s = s.replace( new RegExp( &amp;#039;&amp;lt;(&amp;#039; + DROP_TAGS + &amp;#039;)\\b[^&amp;gt;]*/&amp;gt;&amp;#039;, &amp;#039;gi&amp;#039; ), &amp;#039; &amp;#039; );&lt;br /&gt;
		s = s.replace( new RegExp( &amp;#039;&amp;lt;(&amp;#039; + DROP_TAGS + &amp;#039;)\\b[^&amp;gt;]*&amp;gt;[\\s\\S]*?&amp;lt;/\\1\\s*&amp;gt;&amp;#039;, &amp;#039;gi&amp;#039; ), &amp;#039; &amp;#039; );&lt;br /&gt;
		s = s.replace( /&amp;lt;templatestyles\b[^&amp;gt;]*\/?&amp;gt;/gi, &amp;#039;&amp;#039; );&lt;br /&gt;
		s = s.replace( new RegExp( &amp;#039;&amp;lt;/?(&amp;#039; + UNWRAP_TAGS + &amp;#039;)\\b[^&amp;gt;]*&amp;gt;&amp;#039;, &amp;#039;gi&amp;#039; ), &amp;#039;&amp;#039; );&lt;br /&gt;
		s = s.replace( /__[A-Z]+__/g, &amp;#039;&amp;#039; );&lt;br /&gt;
		if ( pageName ) {&lt;br /&gt;
			s = s.replace( /\{\{\s*(?:FULL)?PAGENAME\s*\}\}/g, pageName );&lt;br /&gt;
		}&lt;br /&gt;
		s = stripTemplates( s );&lt;br /&gt;
		s = stripTables( s );&lt;br /&gt;
		s = stripFileLinks( s );&lt;br /&gt;
		// Link trails ([[Page]]s) survive as the letters after the brackets.&lt;br /&gt;
		s = s.replace( /\[\[:?(?:[^|\]]*\|)?([^\]]*)\]\]/g, &amp;#039;$1&amp;#039; );&lt;br /&gt;
		// A bare [url] renders as a numbered marker, so it contributes no words.&lt;br /&gt;
		s = s.replace( /\[(?:https?:|ftp:|\/\/)[^\s\]]+\s+([^\]]*)\]/gi, &amp;#039;$1&amp;#039; );&lt;br /&gt;
		s = s.replace( /\[(?:https?:|ftp:|\/\/)[^\s\]]+\]/gi, &amp;#039; &amp;#039; );&lt;br /&gt;
		// doQuotes: 2, 3 and 5 are pure markup, but 4 is a literal apostrophe then&lt;br /&gt;
		// bold (&amp;#039;&amp;#039;&amp;#039;Ship&amp;#039;&amp;#039;&amp;#039;&amp;#039;s -&amp;gt; Ship&amp;#039;s), and beyond 5 the surplus is literal.&lt;br /&gt;
		s = s.replace( /&amp;#039;{2,}/g, ( run ) =&amp;gt; {&lt;br /&gt;
			const n = run.length;&lt;br /&gt;
			if ( n === 4 ) {&lt;br /&gt;
				return &amp;#039;\&amp;#039;&amp;#039;;&lt;br /&gt;
			}&lt;br /&gt;
			return n &amp;gt; 5 ? &amp;#039;\&amp;#039;&amp;#039;.repeat( n - 5 ) : &amp;#039;&amp;#039;;&lt;br /&gt;
		} );&lt;br /&gt;
		s = s.replace( /^(={1,6})\s*(.*?)\s*=+\s*$/gm, &amp;#039;$2&amp;#039; );&lt;br /&gt;
		s = s.replace( /^[*#:;]+\s*/gm, &amp;#039;&amp;#039; );&lt;br /&gt;
		s = s.replace( /^-{4,}\s*$/gm, &amp;#039;&amp;#039; );&lt;br /&gt;
		s = s.replace( /&amp;lt;br\s*\/?&amp;gt;/gi, &amp;#039; &amp;#039; );&lt;br /&gt;
		s = s.replace( /&amp;lt;\/?[a-z][^&amp;gt;]*&amp;gt;/gi, &amp;#039;&amp;#039; );&lt;br /&gt;
		s = s.replace( new RegExp( NUL + &amp;#039;(\\d+)&amp;#039; + NUL, &amp;#039;g&amp;#039; ), ( m, n ) =&amp;gt; nowikis[ Number( n ) ] );&lt;br /&gt;
		s = decodeEntities( s );&lt;br /&gt;
		return normaliseText( s );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Punctuation is its own token so a comma edit recolours the comma, not the&lt;br /&gt;
	// word either side of it.&lt;br /&gt;
	const TOKEN_RE = /[\p{L}\p{N}]+(?:[&amp;#039;’][\p{L}\p{N}]+)*|[^\s\p{L}\p{N}]/gu;&lt;br /&gt;
&lt;br /&gt;
	function tokenize( text ) {&lt;br /&gt;
		return text.match( TOKEN_RE ) || [];&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// One interner must be shared by every sequence that will be diffed together.&lt;br /&gt;
	function makeInterner() {&lt;br /&gt;
		const ids = new Map();&lt;br /&gt;
		return function ( tokens ) {&lt;br /&gt;
			const out = new Int32Array( tokens.length );&lt;br /&gt;
			for ( let i = 0; i &amp;lt; tokens.length; i++ ) {&lt;br /&gt;
				let id = ids.get( tokens[ i ] );&lt;br /&gt;
				if ( id === undefined ) {&lt;br /&gt;
					id = ids.size;&lt;br /&gt;
					ids.set( tokens[ i ], id );&lt;br /&gt;
				}&lt;br /&gt;
				out[ i ] = id;&lt;br /&gt;
			}&lt;br /&gt;
			return out;&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Myers O(ND) diff over two integer sequences. Returns, for every position&lt;br /&gt;
	 * in `b`, the matching position in `a`, or -1 where b&amp;#039;s token has no&lt;br /&gt;
	 * counterpart (inserted). Common prefix and suffix are matched first. The&lt;br /&gt;
	 * edit distance is capped: past `maxD` the unmatched middle is left at -1,&lt;br /&gt;
	 * i.e. treated as a full rewrite, which bounds time to O((N+M)·D) and the&lt;br /&gt;
	 * backtrack trace to O(D²) ints.&lt;br /&gt;
	 *&lt;br /&gt;
	 * @param {Int32Array|number[]} a&lt;br /&gt;
	 * @param {Int32Array|number[]} b&lt;br /&gt;
	 * @param {number} maxD&lt;br /&gt;
	 * @param {Object} [stats] Receives `capped: true` when the cap was hit, so a&lt;br /&gt;
	 *   caller can tell &amp;quot;nothing in common&amp;quot; from &amp;quot;gave up looking&amp;quot;.&lt;br /&gt;
	 * @return {Int32Array}&lt;br /&gt;
	 */&lt;br /&gt;
	function diffMap( a, b, maxD, stats ) {&lt;br /&gt;
		const n = a.length;&lt;br /&gt;
		const m = b.length;&lt;br /&gt;
		const map = new Int32Array( m ).fill( -1 );&lt;br /&gt;
		let pre = 0;&lt;br /&gt;
		while ( pre &amp;lt; n &amp;amp;&amp;amp; pre &amp;lt; m &amp;amp;&amp;amp; a[ pre ] === b[ pre ] ) {&lt;br /&gt;
			map[ pre ] = pre;&lt;br /&gt;
			pre++;&lt;br /&gt;
		}&lt;br /&gt;
		let suf = 0;&lt;br /&gt;
		while ( suf &amp;lt; n - pre &amp;amp;&amp;amp; suf &amp;lt; m - pre &amp;amp;&amp;amp; a[ n - 1 - suf ] === b[ m - 1 - suf ] ) {&lt;br /&gt;
			map[ m - 1 - suf ] = n - 1 - suf;&lt;br /&gt;
			suf++;&lt;br /&gt;
		}&lt;br /&gt;
		const N = n - pre - suf;&lt;br /&gt;
		const M = m - pre - suf;&lt;br /&gt;
		if ( N === 0 || M === 0 ) {&lt;br /&gt;
			return map;&lt;br /&gt;
		}&lt;br /&gt;
		const cap = Math.min( N + M, maxD );&lt;br /&gt;
		const offset = cap + 1;&lt;br /&gt;
		const V = new Int32Array( 2 * offset + 1 );&lt;br /&gt;
		V[ offset + 1 ] = 0;&lt;br /&gt;
		// trace[d] holds V for k in [-d-1, d+1] as it stood before step d.&lt;br /&gt;
		const trace = [];&lt;br /&gt;
		let dFound = -1;&lt;br /&gt;
		for ( let d = 0; d &amp;lt;= cap &amp;amp;&amp;amp; dFound &amp;lt; 0; d++ ) {&lt;br /&gt;
			trace.push( V.slice( offset - d - 1, offset + d + 2 ) );&lt;br /&gt;
			for ( let k = -d; k &amp;lt;= d; k += 2 ) {&lt;br /&gt;
				let x;&lt;br /&gt;
				if ( k === -d || ( k !== d &amp;amp;&amp;amp; V[ offset + k - 1 ] &amp;lt; V[ offset + k + 1 ] ) ) {&lt;br /&gt;
					x = V[ offset + k + 1 ];&lt;br /&gt;
				} else {&lt;br /&gt;
					x = V[ offset + k - 1 ] + 1;&lt;br /&gt;
				}&lt;br /&gt;
				let y = x - k;&lt;br /&gt;
				while ( x &amp;lt; N &amp;amp;&amp;amp; y &amp;lt; M &amp;amp;&amp;amp; a[ pre + x ] === b[ pre + y ] ) {&lt;br /&gt;
					x++;&lt;br /&gt;
					y++;&lt;br /&gt;
				}&lt;br /&gt;
				V[ offset + k ] = x;&lt;br /&gt;
				if ( x &amp;gt;= N &amp;amp;&amp;amp; y &amp;gt;= M ) {&lt;br /&gt;
					dFound = d;&lt;br /&gt;
					break;&lt;br /&gt;
				}&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		if ( dFound &amp;lt; 0 ) {&lt;br /&gt;
			if ( stats ) {&lt;br /&gt;
				stats.capped = true;&lt;br /&gt;
			}&lt;br /&gt;
			return map;&lt;br /&gt;
		}&lt;br /&gt;
		let x = N;&lt;br /&gt;
		let y = M;&lt;br /&gt;
		for ( let d = dFound; d &amp;gt; 0; d-- ) {&lt;br /&gt;
			const Vd = trace[ d ];&lt;br /&gt;
			const base = d + 1;&lt;br /&gt;
			const k = x - y;&lt;br /&gt;
			const prevK = ( k === -d || ( k !== d &amp;amp;&amp;amp; Vd[ k - 1 + base ] &amp;lt; Vd[ k + 1 + base ] ) ) ? k + 1 : k - 1;&lt;br /&gt;
			const prevX = Vd[ prevK + base ];&lt;br /&gt;
			const prevY = prevX - prevK;&lt;br /&gt;
			const midX = prevK === k + 1 ? prevX : prevX + 1;&lt;br /&gt;
			const midY = midX - k;&lt;br /&gt;
			while ( x &amp;gt; midX &amp;amp;&amp;amp; y &amp;gt; midY ) {&lt;br /&gt;
				x--;&lt;br /&gt;
				y--;&lt;br /&gt;
				map[ pre + y ] = pre + x;&lt;br /&gt;
			}&lt;br /&gt;
			x = prevX;&lt;br /&gt;
			y = prevY;&lt;br /&gt;
		}&lt;br /&gt;
		while ( x &amp;gt; 0 &amp;amp;&amp;amp; y &amp;gt; 0 ) {&lt;br /&gt;
			x--;&lt;br /&gt;
			y--;&lt;br /&gt;
			map[ pre + y ] = pre + x;&lt;br /&gt;
		}&lt;br /&gt;
		return map;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// An isolated &amp;quot;.&amp;quot;, &amp;quot;/&amp;quot; or &amp;quot;-&amp;quot; lines up with some punctuation in the source by&lt;br /&gt;
	// coincidence, and the diff takes the match because it lowers the edit&lt;br /&gt;
	// distance. Crediting a named editor with it is noise. Runs holding at least&lt;br /&gt;
	// one real word keep their punctuation.&lt;br /&gt;
	const WORD_CHAR = /[\p{L}\p{N}]/u;&lt;br /&gt;
	function dropPunctuationOnlyRuns( map, tokens ) {&lt;br /&gt;
		let i = 0;&lt;br /&gt;
		while ( i &amp;lt; map.length ) {&lt;br /&gt;
			if ( map[ i ] &amp;lt; 0 ) {&lt;br /&gt;
				i++;&lt;br /&gt;
				continue;&lt;br /&gt;
			}&lt;br /&gt;
			let j = i;&lt;br /&gt;
			let hasWord = false;&lt;br /&gt;
			while ( j &amp;lt; map.length &amp;amp;&amp;amp; map[ j ] &amp;gt;= 0 ) {&lt;br /&gt;
				if ( WORD_CHAR.test( tokens[ j ] ) ) {&lt;br /&gt;
					hasWord = true;&lt;br /&gt;
				}&lt;br /&gt;
				j++;&lt;br /&gt;
			}&lt;br /&gt;
			if ( !hasWord ) {&lt;br /&gt;
				for ( let k = i; k &amp;lt; j; k++ ) {&lt;br /&gt;
					map[ k ] = -1;&lt;br /&gt;
				}&lt;br /&gt;
			}&lt;br /&gt;
			i = j;&lt;br /&gt;
		}&lt;br /&gt;
		return map;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// A repeated sha1 means the page was restored to a state it already held, so&lt;br /&gt;
	// the restorer inherits that state&amp;#039;s origins rather than being credited with&lt;br /&gt;
	// the text. Covers a rollback of any length, not just an adjacent A,B,A.&lt;br /&gt;
	function duplicateSha1s( sha1s ) {&lt;br /&gt;
		const seen = new Set();&lt;br /&gt;
		const dup = new Set();&lt;br /&gt;
		sha1s.forEach( ( h ) =&amp;gt; {&lt;br /&gt;
			if ( !h ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			if ( seen.has( h ) ) {&lt;br /&gt;
				dup.add( h );&lt;br /&gt;
			} else {&lt;br /&gt;
				seen.add( h );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		return dup;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function blameStep( prevOrigins, prevStream, stream, index, maxD, stats ) {&lt;br /&gt;
		const map = diffMap( prevStream, stream, maxD, stats );&lt;br /&gt;
		const origins = new Int32Array( stream.length );&lt;br /&gt;
		for ( let j = 0; j &amp;lt; stream.length; j++ ) {&lt;br /&gt;
			origins[ j ] = map[ j ] &amp;gt;= 0 ? prevOrigins[ map[ j ] ] : index;&lt;br /&gt;
		}&lt;br /&gt;
		return origins;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Fed oldest revision first. Only the previous stream is retained, so peak&lt;br /&gt;
	// memory is two revisions however long the history.&lt;br /&gt;
	//&lt;br /&gt;
	// Call reuse() FIRST: when it returns true the revision restores a state the&lt;br /&gt;
	// page already held, and the caller can skip normalising, tokenising and&lt;br /&gt;
	// diffing it entirely.&lt;br /&gt;
	function makeBlamer( duplicates, maxD ) {&lt;br /&gt;
		const memo = new Map();&lt;br /&gt;
		let prevStream = null;&lt;br /&gt;
		let origins = null;&lt;br /&gt;
		let capped = 0;&lt;br /&gt;
		return {&lt;br /&gt;
			reuse( sha1 ) {&lt;br /&gt;
				const hit = sha1 ? memo.get( sha1 ) : null;&lt;br /&gt;
				if ( !hit ) {&lt;br /&gt;
					return false;&lt;br /&gt;
				}&lt;br /&gt;
				prevStream = hit.stream;&lt;br /&gt;
				origins = hit.origins;&lt;br /&gt;
				return true;&lt;br /&gt;
			},&lt;br /&gt;
			push( stream, index, sha1 ) {&lt;br /&gt;
				if ( origins === null ) {&lt;br /&gt;
					origins = new Int32Array( stream.length ).fill( index );&lt;br /&gt;
				} else {&lt;br /&gt;
					const stats = {};&lt;br /&gt;
					origins = blameStep( origins, prevStream, stream, index, maxD, stats );&lt;br /&gt;
					if ( stats.capped ) {&lt;br /&gt;
						capped++;&lt;br /&gt;
					}&lt;br /&gt;
				}&lt;br /&gt;
				prevStream = stream;&lt;br /&gt;
				if ( sha1 &amp;amp;&amp;amp; duplicates.has( sha1 ) ) {&lt;br /&gt;
					memo.set( sha1, { stream, origins } );&lt;br /&gt;
				}&lt;br /&gt;
			},&lt;br /&gt;
			get origins() {&lt;br /&gt;
				return origins;&lt;br /&gt;
			},&lt;br /&gt;
			get stream() {&lt;br /&gt;
				return prevStream;&lt;br /&gt;
			},&lt;br /&gt;
			// Everything unmatched was credited to the newer revision, so the caller&lt;br /&gt;
			// must report this count rather than present it as certain.&lt;br /&gt;
			get capped() {&lt;br /&gt;
				return capped;&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	module.exports = {&lt;br /&gt;
		decodeEntities,&lt;br /&gt;
		normaliseText,&lt;br /&gt;
		wikitextToText,&lt;br /&gt;
		TOKEN_RE,&lt;br /&gt;
		tokenize,&lt;br /&gt;
		makeInterner,&lt;br /&gt;
		diffMap,&lt;br /&gt;
		dropPunctuationOnlyRuns,&lt;br /&gt;
		duplicateSha1s,&lt;br /&gt;
		blameStep,&lt;br /&gt;
		makeBlamer&lt;br /&gt;
	};&lt;br /&gt;
}() );&lt;/div&gt;</summary>
		<author><name>Kai</name></author>
	</entry>
</feed>