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.
/*** Article Link Diffs ***/
// Automatically summarizes article links added or removed when viewing a diff page.
// Also, incomplete links will be caught and shown as errors.
// Documentation at [[en:w:User:StefenTower/ArticleLinkDiffs]]
// By [[en:w:User:StefenTower]]
(function() {
'use strict';
if (!mw.config.get('wgDiffNewId') && !window.location.search.includes('diff=')) {
return;
}
// Exit if it is a JavaScript code page diff
if (mw.config.get('wgPageName').endsWith('.js')) return;
mw.loader.using(['mediawiki.util'], function() {
var namespaceIds = mw.config.get('wgNamespaceIds') || {};
var brokenLinks = []; // Track unclosed link snippets globally within instance
// Fortified CSS selectors targeting dark, desaturated link block baselines natively
mw.util.addCSS(
'div#article-link-diff-summary a.alschip { display: inline-flex; align-items: center; background: var(--background-color-neutral-subtle, #f8f9fa) !important; border: 1px solid var(--border-color-muted, #c8ccd1) !important; border-radius: 4px !important; padding: 2px 6px !important; margin: 3px 6px 3px 0 !important; font-size: 0.9em !important; white-space: nowrap !important; color: var(--color-progressive, #36c) !important; font-weight: 500 !important; text-decoration: none !important; text-decoration-line: none !important; background-image: none !important; box-shadow: 0 1px 2px rgba(0,0,0,0.04) !important; transition: background 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; }' +
'div#article-link-diff-summary a.alschip-added:hover { background: var(--background-color-success-subtle, #d4eed8) !important; border-color: var(--border-color-success, #a3cfad) !important; box-shadow: 0 1px 0 0 var(--color-success, #137333), 0 2px 4px rgba(0,0,0,0.05) !important; color: var(--color-success, #137333) !important; text-decoration: none !important; }' +
'div#article-link-diff-summary a.alschip-removed:hover { background: var(--background-color-error-subtle, #fadbd8) !important; border-color: var(--border-color-error, #e6b0aa) !important; box-shadow: 0 1px 0 0 var(--color-error, #c5221f), 0 2px 4px rgba(0,0,0,0.05) !important; color: var(--color-error, #c5221f) !important; text-decoration: none !important; }' +
// Base Light Mode style configuration for error chips (spans)
'div#article-link-diff-summary span.alschip-error { display: inline-flex; align-items: center; background: var(--background-color-error-subtle, #fdf2f2) !important; border: 1px solid var(--border-color-error, #f8b4b4) !important; border-radius: 3px !important; padding: 1px 5px !important; margin: 1px 3px 1px 0 !important; font-family: monospace !important; font-size: 0.85em !important; color: var(--color-error, #9c1a1a) !important; white-space: nowrap !important; }' +
// --- DARK MODE COMPONENT OVERRIDES ---
// Tones down the main card summary block row container
'.mw-theme-client-dark div#article-link-diff-summary { background: #1a1b1c !important; border-color: #3a3d40 !important; }' +
'.mw-theme-client-dark div#article-link-diff-summary div { border-color: #2a2b2c !important; }' +
'.mw-theme-client-dark div#article-link-diff-summary span.alschip-error { background: #351716 !important; border-color: #54211e !important; color: #f2a6a1 !important; }'
);
function getLinkCounts(text) {
// Strip raw "File:Name.jpg|" prefixes from <gallery> rows so their captions parse like normal text
var processedText = text.replace(/^\s*(File|Image):[^|\n]+\|/gim, '');
// 1. Pre-process text to isolate inner links from File/Image captions
var extractedInnerLinks = [];
var fileRegex = /\[\[(File|Image):[^[\]]*(?:\[\[[^[\]]*\]\][^[\]]*)*\]\]/gi;
var fileMatch;
// Extract links nested inside file captions and save them
while ((fileMatch = fileRegex.exec(processedText)) !== null) {
var fileMarkup = fileMatch[0];
var innerLinks = fileMarkup.match(/\[\[(?!File:|Image:)[^\]]+\]\]/gi);
if (innerLinks) {
extractedInnerLinks = extractedInnerLinks.concat(innerLinks);
}
}
// Remove the outer File/Image blocks entirely so they don't get double-counted
var baseTextWithoutFiles = processedText.replace(fileRegex, '');
// Combine the file-free text with our single copy of the extracted caption links
var cleanText = baseTextWithoutFiles + ' \n ' + extractedInnerLinks.join(' ');
// Standard link extraction loop running on the deduplicated text pool
var linkRegex = /\[\[([^\]|]{1,150})(\|[^\]]{1,150})?\]\]/g;
var counts = {};
var match;
while ((match = linkRegex.exec(cleanText)) !== null) {
var rawTitle = match[1].trim();
// Remove leading colon (used for inline category/file links)
var cleanTitle = rawTitle.startsWith(':') ? rawTitle.slice(1) : rawTitle;
var parts = cleanTitle.split(':');
if (parts.length > 1) {
var prefix = parts[0].toLowerCase().replace(/ /g, '_');
// If the prefix is a known namespace (Category, File, User, etc.), skip it
if (namespaceIds.hasOwnProperty(prefix)) {
continue;
}
}
// Normalize: Capitalize first letter and underscores to spaces
var title = cleanTitle.charAt(0).toUpperCase() + cleanTitle.slice(1).replace(/_/g, ' ');
var display = match[2] ? match[2].substring(1).trim() : null;
var key = display ? title + "|||" + display : title;
counts[key] = (counts[key] || 0) + 1;
}
// 2. Independent scanner to parse and report broken runaway links
var idx = 0;
while ((idx = text.indexOf('[[', idx)) !== -1) {
// Lookahead check to skip media assets entirely from errors panel
var preview = text.substring(idx + 2, idx + 15).trim().toLowerCase();
if (preview.startsWith('file:') || preview.startsWith('image:')) {
idx += 2;
continue;
}
var nextClose = text.indexOf(']]', idx);
var nextOpen = text.indexOf('[[', idx + 2);
if (nextClose === -1 || (nextOpen !== -1 && nextOpen < nextClose)) {
var endPos = (nextOpen !== -1) ? nextOpen : idx + 40;
var snippet = text.substring(idx, endPos).trim();
if (snippet.length > 40) {
snippet = snippet.substring(0, 40) + '...';
}
if (!brokenLinks.includes(snippet)) {
brokenLinks.push(snippet);
}
}
idx += 2;
}
return counts;
}
// Ensure the diff table exists before proceeding
var diffTable = document.querySelector('table.diff');
if (!diffTable) return;
var oldText = Array.from(document.querySelectorAll('.diff-deletedline')).map(td => td.textContent).join(' \n ');
var newText = Array.from(document.querySelectorAll('.diff-addedline')).map(td => td.textContent).join(' \n ');
var oldCounts = getLinkCounts(oldText);
var oldErrors = [].concat(brokenLinks);
brokenLinks = [];
var newCounts = getLinkCounts(newText);
var freshErrors = brokenLinks.filter(function(err) { return !oldErrors.includes(err); });
function getDifference(source, target) {
var diff = [];
for (var key in source) {
var sourceCount = source[key];
var targetCount = target[key] || 0;
if (sourceCount > targetCount) {
var p = key.split('|||');
diff.push({ title: p[0], display: p[1] || null, count: sourceCount - targetCount });
}
}
return diff.sort((a, b) => a.title.localeCompare(b.title));
}
var added = getDifference(newCounts, oldCounts);
var removed = getDifference(oldCounts, newCounts);
if (added.length === 0 && removed.length === 0 && freshErrors.length === 0) return;
function renderList(list, typeClass) {
if (list.length === 0) return '<span style="color: var(--color-subtle, #72777d); font-style: italic; font-size: 0.9em; margin-left: 4px; align-self: center;">None</span>';
var chipsHTML = list.map(item => {
var html = '<a class="alschip ' + typeClass + '" href="' + mw.util.getUrl(item.title) + '">';
html += '<span>' + item.title + '</span>';
if (item.display && item.display !== item.title) {
html += '<span style="color: var(--color-subtle, #72777d); font-size: 0.85em; margin-left: 3px; font-weight: normal;">(as <b>“</b>' + item.display + '<b>”</b>)</span>';
}
if (item.count > 1) {
html += '<span style="background: var(--background-color-neutral, #eaecf0); color: var(--color-base, #202122); font-weight: bold; font-size: 0.8em; padding: 0 3px; border-radius: 2px; margin-left: 4px;">×' + item.count + '</span>';
}
html += '</a>';
return html;
}).join('');
return '<div style="display: inline-flex; flex-wrap: wrap; align-items: center; vertical-align: middle; flex: 1;">' + chipsHTML + '</div>';
}
var priorReport = document.getElementById('article-link-diff-summary');
if (priorReport) priorReport.remove();
var report = document.createElement('div');
report.id = 'article-link-diff-summary';
var cardStyles = 'margin: 8px 0; padding: 8px 12px; background: var(--background-color-neutral-subtle, #ffffff); border: 1px solid var(--border-color-base, #a2a9b1); border-left: 4px solid #36c; border-radius: 4px; font-family: sans-serif; box-sizing: border-box; width: 100%; line-height: 1.4; color: var(--color-base, #202122);';
report.style.cssText = cardStyles;
var totalAdded = added.reduce((sum, i) => sum + i.count, 0);
var totalRemoved = removed.reduce((sum, i) => sum + i.count, 0);
// Lightweight, uniform link emblem using style filters to ensure a sleek monochrome look
var reportHTML = '<div style="font-size: 0.95em; font-weight: bold; color: var(--color-base, #202122); margin-bottom: 4px; padding-bottom: 6px; border-bottom: 1px solid var(--border-color-subtle, #f0f1f2); display: flex; align-items: center; gap: 5px;">' +
'<span style="font-size: 1.15em; display: inline-flex; align-items: center; justify-content: center; filter: grayscale(1) brightness(1.2); opacity: 0.75; transform: rotate(-15deg); line-height: 1;">🔗</span>' +
'<span>Article Link Diff Summary</span>' +
'</div>';
reportHTML += '<div style="display: flex; margin-bottom: 3px; align-items: flex-start;">' +
'<div style="display: inline-block; width: 95px; flex-shrink: 0; background: var(--background-color-success-subtle, #e6f4ea); color: var(--color-success, #137333); font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 4px; border-radius: 3px; text-align: center; margin-right: 6px; margin-top: 2px; white-space: nowrap;">Added (' + totalAdded + ')</div>' +
renderList(added, 'alschip-added') +
'</div>';
reportHTML += '<div style="display: flex; margin-bottom: 3px; align-items: flex-start;">' +
'<div style="display: inline-block; width: 95px; flex-shrink: 0; background: var(--background-color-error-subtle, #fce8e6); color: var(--color-error, #c5221f); font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 4px; border-radius: 3px; text-align: center; margin-right: 6px; margin-top: 2px; white-space: nowrap;">Removed (' + totalRemoved + ')</div>' +
renderList(removed, 'alschip-removed') +
'</div>';
if (freshErrors.length > 0) {
report.style.borderLeftColor = '#d33';
var errorChips = freshErrors.map(function(e) {
return '<span class="alschip-error">' + mw.html.escape(e) + '</span>';
}).join('');
reportHTML += '<div style="display: flex; margin-top: 4px; padding-top: 4px; border-top: 1px dashed #eaecf0; align-items: flex-start;">' +
'<div style="display: inline-block; width: 95px; flex-shrink: 0; background: var(--background-color-notice-subtle, #feefe3); color: var(--color-notice, #b06000); font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 4px; border-radius: 3px; text-align: center; margin-right: 6px; margin-top: 2px; white-space: nowrap;">⚠️ Errors (' + freshErrors.length + ')</div>' +
'<div style="display: inline-flex; flex-wrap: wrap; align-items: center; vertical-align: middle; flex: 1;">' + errorChips + '</div>' +
'</div>';
}
report.innerHTML = reportHTML;
diffTable.parentNode.insertBefore(report, diffTable);
mw.hook('wikipage.content').fire($(report));
});
})();
