Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
mw.loader.using([
'mediawiki.util', 'mediawiki.Title', 'mediawiki.api'
], function smartDiff() {
mw.loader.addStyleTag('.smartdiff-link.extiw, .smartdiff-link.external{color:var(--color-progressive,#36c)} .smartdiff-link.extiw:visited, .smartdiff-link.external:visited{color:#795cb2} .smartdiff-link.extiw:active, .smartdiff-link.external:active{color:#faa700}');
class SmartDiff {
constructor($diff) {
this.$diff = $diff;
this.isSpecial = mw.config.get('wgNamespaceNumber') === -1;
this.isView = mw.config.get('wgAction') === 'view' &&
new URLSearchParams(location.search).get('diffonly') !== '1';
this.magicWords = [
'!', 'BASEPAGENAME', 'BASEPAGENAME:', 'BASEPAGENAMEE', 'BASEPAGENAMEE:',
'canonicalurl:', 'CURRENTDAY', 'CURRENTDAY2', 'CURRENTDAYNAME',
'CURRENTDOW', 'CURRENTHOUR', 'CURRENTMONTH', 'CURRENTMONTH1',
'CURRENTMONTHABBREV', 'CURRENTMONTHNAME', 'CURRENTMONTHNAMEGEN',
'CURRENTTIME', 'CURRENTTIMESTAMP', 'CURRENTVERSION', 'CURRENTWEEK',
'CURRENTYEAR', 'DEFAULTCATEGORYSORT:', 'DEFAULTSORT:', 'DEFAULTSORTKEY:',
'DISPLAYTITLE:', 'filepath:', 'formatnum:', 'FULLPAGENAME',
'FULLPAGENAME:', 'FULLPAGENAMEE', 'FULLPAGENAMEE:', 'fullurl:',
'gender:', 'int:', 'lc:', 'lcfirst:', 'LOCALDAY', 'LOCALDAY2',
'LOCALDAYNAME', 'LOCALDOW', 'LOCALHOUR', 'LOCALMONTH', 'LOCALMONTH1',
'LOCALMONTHABBREV', 'LOCALMONTHNAME', 'LOCALMONTHNAMEGEN', 'LOCALTIME',
'LOCALTIMESTAMP', 'LOCALWEEK', 'LOCALYEAR', 'msg:', 'msgnw:',
'NAMESPACE', 'NAMESPACE:', 'NAMESPACEE', 'NAMESPACEE:', 'NAMESPACENUMBER',
'NAMESPACENUMBER:', 'ns:', 'NUMBEROFACTIVEUSERS', 'NUMBEROFARTICLES',
'NUMBEROFEDITS', 'NUMBEROFFILES', 'NUMBEROFPAGES', 'NUMBEROFUSERS',
'padleft:', 'PAGENAME', 'PAGENAMEE', 'PAGESINCAT:', 'PAGESINCATEGORY:',
'plural:', 'REVISIONDAY', 'REVISIONDAY:', 'REVISIONDAY2', 'REVISIONDAY2:',
'REVISIONID', 'REVISIONID:', 'REVISIONMONTH', 'REVISIONMONTH:',
'REVISIONMONTH1', 'REVISIONMONTH1:', 'REVISIONSIZE', 'REVISIONTIMESTAMP',
'REVISIONTIMESTAMP:', 'REVISIONUSER', 'REVISIONUSER:', 'REVISIONYEAR',
'REVISIONYEAR:', 'ROOTPAGENAME', 'ROOTPAGENAME:', 'ROOTPAGENAMEE',
'ROOTPAGENAMEE:', 'SHORTDESC:', 'SUBJECTPAGENAME', 'SUBJECTPAGENAME:',
'SUBJECTPAGENAMEE', 'SUBJECTPAGENAMEE:', 'SUBJECTSPACE', 'SUBJECTSPACE:',
'SUBJECTSPACEE', 'SUBJECTSPACEE:', 'SUBPAGENAME', 'SUBPAGENAME:',
'SUBPAGENAMEE', 'SUBPAGENAMEE:', 'TALKPAGENAME', 'TALKPAGENAME:',
'TALKPAGENAMEE', 'TALKPAGENAMEE:', 'TALKSPACE', 'TALKSPACE:',
'TALKSPACEE', 'TALKSPACEE:', 'uc:', 'ucfirst:', 'urlencode:'
];
if (window.smartdiffMagicWords) {
this.magicWords.push(...window.smartdiffMagicWords);
}
try {
this.subNs = mw.config.get('wgVisualEditorConfig').namespacesWithSubpages;
} catch (e) {}
if (!this.subNs) {
this.subNs = Object.keys(mw.config.get('wgFormattedNamespaces'))
.map(k => Number(k)).filter(ns => ![0, 6, 8].includes(ns));
}
this.re = /((?:\[(?:<[^>]*>)?\[|(?<!{(?:<[^>]*>)?){(?:<[^>]*>)?{(?:<[^>]*>)?(?:(?:#(?:<[^>]*>)?invoke|(?:safe)?subst|msg(?:nw)?|raw)(?:<[^>]*>)?:)?)(?:\s*(?:<[^>]*>)?<(?:<[^>]*>)?tvar(?:<[^>]*>)?\s(?!>).*?>)?\s*)((?:(?!&[gl]t;)[^\[\]{|}])+?)(?=\s*(?:(?:<[^>]*>)?<(?:<[^>]*>)?\/(?:<[^>]*>)?tvar(?:<[^>]*>)?>(?:<[^>]*>)?\s*)?(?:\||\](?:<[^>]*>)?\]|}(?:<[^>]*>)?}|$))/g;
this.headRe = /^((?:(?:<[^>]*>)*=){1,6}(?:<[^>]*>)?\s*)((?:(?!&[gl]t;).)+?)(?=\s*(?:(?:<[^>]*>)?=){1,6}(?:<[^>]*>|\s)*(?:<|$))/g;
this.urlRe = /(?:https?(?:<[^>]*>)?:(?:<[^>]*>)?|(?<=\[(?:<[^>]*>)?))\/(?:<[^>]*>)?\/(?:[-\dA-Za-z]+|<[^>]*>)+\.(?:[-.\d:A-Za-z]+|<[^>]*>)+(?:\/(?:(?:[!#-%(-;=?-Z_a-z~]+|&|<[^>]*>)*(?:[#-%(+\-\/-9=?-Z_a-z~]|&)(?:<[^>]*>)?)?)?/g;
if (window.smartdiffTemplates) {
this.tempRe = /( data-smartdiff-temp="(\d+)">[^{|}]+)(\|(?:(?!&[gl]t;)[^\[\]{}]|{(?:<[^>]*>)?{(?:<[^>]*>)?!(?:<[^>]*>)?}(?:<[^>]*>)?})+)(?=}(?:<[^>]*>)?}|$)/g;
this.tempSubRe = /((?:\s|{(?:<[^>]*>)?{(?:<[^>]*>)?!(?:<[^>]*>)?}(?:<[^>]*>)?}[^<>|]*|<[^>]*>)*(?:\|(?:\s|(?:<[^>]*>)|\d+(?:\s|<[^>]*>)*=|[^\d<=>|](?:[^<=>|]|<[^>]*>)*=(?:[^<=>|]|<[^>]*>)*\|?)*|$))/;
this.templates = window.smartdiffTemplates;
}
['rep', 'headRep', 'urlRep', 'tempRep'].forEach(fn => {
this[fn] = this[fn].bind(this);
});
this.side = 'old';
$diff.find('.diff-deletedline > div').get().forEach(this.processDiv, this);
this.side = 'new';
$diff.find('.diff-addedline > div').get().forEach(this.processDiv, this);
let $contexts = $diff.find('.diff-context > div');
$contexts.each((i, div) => {
if (i % 2) {
this.side = 'new';
if (this.propUsed && this.getProp() !== this.getProp('pn', 'old')) {
this.processDiv(div);
} else {
$contexts.eq(i).replaceWith($contexts.eq(i - 1).clone());
}
} else {
this.side = 'old';
this.propUsed = false;
this.processDiv(div);
}
});
this.links = {};
$diff.find('.smartdiff-link:not(.external)').each((i, link) => {
let title = link.title;
if (!title) return;
if (!this.links.hasOwnProperty(title)) {
this.links[title] = [];
}
this.links[title].push(link);
});
this.query(Object.keys(this.links).slice(0, 500));
if (this.hasError) {
mw.notify('SmartDiff error', { type: 'warn' });
}
}
processDiv(div) {
if (div.querySelector('a[href]')) return;
let origHtml = div.innerHTML;
let newHtml = origHtml.replace(this.urlRe, this.urlRep)
.replace(this.re, this.rep).replace(this.headRe, this.headRep);
if (this.tempRe) {
newHtml = newHtml.replace(this.tempRe, this.tempRep);
}
if (newHtml === origHtml) return;
let $newDiv = $('<div>').html(newHtml);
if (this.detectErrors($newDiv, newHtml, origHtml, div)) return;
div.textContent = '';
$newDiv.contents().appendTo(div);
}
rep($0, $1, $2) {
if ($0.includes('<a class="smartdiff-link')) return $0;
let [s, pre, mid, post] = this.stripTags($2, true, $1);
let t = mw.Title.newFromText(s), isTemp;
if (t) {
if ($1.includes('invoke')) {
t = mw.Title.makeTitle(828, s);
} else if (s[0] === '/') {
if (this.subNs.includes(this.getProp('ns'))) {
t = mw.Title.newFromText(
this.getProp() + s.replace(/\/+$/, '')
);
} else if ($1[0] === '{') {
t.namespace = 10;
}
} else if ($1[0] === '{') {
if (s[0] === '#') return $0;
if (!t.namespace && s[0] !== ':') {
if (!$1.includes('msg') && !$1.includes('raw')) {
let match = s.match(/^[^:]+(?::(?=.)|$)/);
if (match && this.magicWords.includes(match[0])) {
return $0;
}
}
t.namespace = 10;
isTemp = true;
}
} else if ((this.isSpecial || !this.isView) && s[0] === '#') {
t.title = this.getProp();
}
} else if (s.startsWith('../') && this.subNs.includes(this.getProp('ns'))) {
let chunks = s.split('/');
let levelCount = chunks.findIndex(v => v !== '..');
let sup = this.getProp().split('/').slice(0, -levelCount).join('/');
if (sup) {
let sub = chunks.slice(levelCount).join('/').replace(/\/+$/, '');
t = mw.Title.newFromText(sub ? sup + '/' + sub : sup);
}
}
if (!t) return $0;
let attrs = {
class: 'smartdiff-link',
href: t.getUrl()
};
if (this.isSpecial || !this.isView || s[0] !== '#') {
attrs.title = t.toText();
}
if (isTemp && this.tempRe) {
let name = t.getMainText();
let idx = this.templates.findIndex(temp => temp.names.includes(name));
if (idx !== -1) {
attrs['data-smartdiff-temp'] = idx;
}
}
return pre + $('<a>').attr(attrs).html(mid)[0].outerHTML + post;
}
stripTags(s, decode, pre = '', post = '') {
let mid = s, tags = s.match(/<\/?(?:ins|del)[^>]*>/g);
s = $($.parseHTML(s.replace(/&/g, '&'))).text();
if (decode) {
try {
s = decodeURIComponent(s);
} catch (e) {}
}
if (tags) {
if (tags[0][1] === '/') {
pre += tags[0];
mid = `<${tags[0].slice(2, 5)} class="diffchange diffchange-inline">` + mid;
}
let lastTag = tags.pop();
if (lastTag[1] !== '/') {
mid += `</${lastTag.slice(1, 4)}>`;
post = lastTag + post;
}
}
return [s, pre, mid, post];
}
headRep($0, $1, $2) {
if ($0.includes('<a class="smartdiff-link')) return $0;
let [s, pre, mid, post] = this.stripTags($2, true, $1);
s = s.replace(/'''(.+?)'''|<\/?(?:abbr|b|bdi|bdo|big|cite|code|data|del|dfn|em|font|i|ins|kbd|mark|nowiki|q|rb|ref|rp|rt|rtc|ruby|s|samp|small|span|strike|strong|sub|sup|templatestyles|time|translate|tt|u|var)(?:\s[^>]*)?>/gi, '$1')
.replace(/''(.+?)''/g, '$1')
.replace(/^_+|_+$/g, '');
let t = mw.Title.newFromText(
`${this.isSpecial || !this.isView ? this.getProp() : ''}#${s}`
);
if (!t) return $0;
let attrs = {
class: 'smartdiff-link',
href: t.getUrl()
};
if (this.isSpecial || !this.isView) {
attrs.title = t.toText();
}
return pre + $('<a>').attr(attrs).html(mid)[0].outerHTML + post;
}
urlRep($0) {
let [url, pre, mid, post] = this.stripTags($0);
return pre + $('<a>').attr({
class: 'smartdiff-link external',
href: url,
rel: 'nofollow'
}).html(mid)[0].outerHTML + post;
}
tempRep($0, $1, $2, $3) {
if ($3.includes('<a class="smartdiff-link')) return $0;
let temp = this.templates[$2];
return $1 + $3.split(this.tempSubRe).map((os, i) => {
if (!os || i % 2) return os;
let j = i / 2;
if (j < temp.start || j > temp.end ||
temp.skipOdd && j % 2 || temp.skipEven && j % 2 === 0
) {
return os;
}
let [s, pre, mid, post] = this.stripTags(os, true);
if (temp.prefix) {
s = temp.prefix + s;
}
if (temp.suffix) {
s += temp.suffix;
}
let t = temp.forceNs
? mw.Title.makeTitle(temp.namespace, s)
: mw.Title.newFromText(s, temp.namespace);
if (!t) return os;
let params = (j >= temp.noRedirectStart || j <= temp.noRedirectEnd) &&
{ redirect: 'no' };
return pre + $('<a>').attr({
class: 'smartdiff-link',
href: t.getUrl(params),
title: t.toText()
}).html(mid)[0].outerHTML + post;
}).join('');
}
getProp(n = 'pn', side = this.side) {
this.propUsed = true;
if (this[side]) {
if (this[side][n]) {
return this[side][n];
}
} else {
this[side] = {};
let link = this.$diff[0].querySelector(
side === 'old'
? '#mw-diff-otitle1 a, #differences-prevlink'
: '#mw-diff-ntitle1 a, #differences-nextlink'
);
if (link) {
let pn = mw.util.getParamValue('title', link.search);
this[side].pn = pn;
this[side].ns = mw.Title.newFromText(pn).namespace;
return this[side][n];
}
}
if (this[n]) {
return this[n];
}
if (this.isSpecial) {
this.pn = '';
this.ns = 0;
} else {
this.pn = mw.config.get('wgPageName');
this.ns = mw.config.get('wgNamespaceNumber');
}
return this[n];
}
query(titles) {
if (!titles.length) return;
new mw.Api().post({
action: 'query',
titles: titles.slice(0, 50),
iwurl: 1,
prop: 'info',
inprop: 'linkclasses',
inlinkcontext: this.getProp(),
formatversion: 2
}, {
headers: { 'Promise-Non-Write-API-Action': 1 }
}).then(response => {
let query = response && response.query;
if (!query) return;
let data = {};
(query.pages || []).forEach(page => {
let obj = { classes: page.linkclasses || [] };
if (page.missing && !page.known) {
obj.classes.push('new');
obj.params = { action: 'edit', redlink: 1 };
}
data[page.title] = obj;
});
(query.interwiki || []).forEach(interwiki => {
data[interwiki.title] = {
classes: ['extiw'],
url: interwiki.url
};
});
(query.normalized || []).forEach(entry => {
if (!data.hasOwnProperty(entry.to)) return;
let obj = data[entry.to];
obj.canonical = entry.to;
if (!obj.url) {
obj.url = mw.util.getUrl(entry.to, obj.params);
}
data[entry.from] = obj;
});
Object.entries(data).forEach(([title, obj]) => {
if (!this.links.hasOwnProperty(title)) return;
let $links = $(this.links[title]).addClass(obj.classes)
.attr('title', obj.canonical);
if (obj.url) {
$links.attr('href', function () {
return obj.url + this.hash;
});
}
});
this.query(titles.slice(50));
});
}
detectErrors($newDiv, newHtml, origHtml, div) {
let comp = $newDiv.html();
if (comp !== newHtml) {
console.warn(
'SmartDiff syntax error at:\n',
div,
`\nNew HTML:\n${newHtml}\nCompared against:\n${comp}`
);
this.hasError = true;
return true;
}
let $comp = $newDiv.clone();
$comp.find('.smartdiff-link').contents().unwrap();
comp = $comp.html().replace(/<\/(ins|del)><\1[^>]*>/g, '');
if (comp !== origHtml) {
console.warn(
'SmartDiff mutation error at:\n',
div,
`\nOriginal HTML:\n${origHtml}\nCompared against:\n${comp}`
);
this.hasError = true;
return true;
}
}
}
mw.hook('wikipage.diff').add($diff => {
new SmartDiff($diff);
});
});