User:Daniel Quinlan/Scripts/Vanilla.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Daniel Quinlan/Scripts/Vanilla. |
"use strict";
mw.loader.using(['mediawiki.util', 'user.options']).then(function () {
// state variables
const nsNum = mw.config.get('wgNamespaceNumber');
const userName = mw.config.get('wgRelevantUserName');
const userOptions = { debug: false, replace: true };
const userPages = {};
// only process signatures in signature namespaces
if (nsNum % 2 === 0 && !mw.config.get('wgExtraSignatureNamespaces').includes(nsNum)) {
vanillaMode();
return;
}
// constants
const SIGNATURE_ELEMENTS = new Set(['B', 'BDI', 'BIG', 'BR', 'CODE', 'EM', 'FONT', 'I', 'IMG', 'INS', 'KBD', 'RP', 'RT', 'RUBY', 'SMALL', 'S', 'SAMP', 'SPAN', 'STRONG', 'SUB', 'SUP', 'U']);
const SIGNATURE_SPAN_CLASSES = new Set([null, 'FTTCmt', 'ext-discussiontools-init-replylink-buttons', 'fn', 'nickname', 'nowrap', 'signature-talk', 'skin-invert', 'vcard']);
const SERVER_PREFIX = window.location.origin;
const ARTICLE_PATH = mw.config.get('wgArticlePath').replace(/\$1/, '');
const SCRIPT_PATH = mw.config.get('wgScript') + '?title=';
const SIGNATURE_LINKS = ['User:', 'User_talk:', 'Special:Contributions/', 'Special:Log/', 'Special:EmailUser/'];
const SIGNATURE_DELIMITERS = new Set(['', '#', '&', '/', '?']);
const ALTERNATIVE_SUFFIXES = /^(?:[ \-\.]?(?!\w+\x29)| ?\x28(?=\w+\x29))?(?:2|alt|alternate|awb|bot|ii|mobile|onmobile|public|sock|test|too)\b\x29?/i;
const SIGNATURE_PARAMS = new Set(['action', 'redlink', 'safemode', 'title']);
const SIGNATURE_SEPARATOR = /(?:[\-~\xa0\xb7\u2000-\u200d\u2010-\u2017\u2022\u2026\u202f\u205f\u2060\u2190-\u2bff\u2e3a\u2e3b\u3000\u3127]+\s*|[\u{1F000}-\u{1F9FF}]+(?=[\s\xa0\u2000-\u200d\u202f\u205f\u2060\u3000]?$)|\x26(?:hairsp|nbsp|nobreak);\s*)+\s*$/iu;
const AUTHOR_REGEX = /\#c-(.*?)-\d{4}(?:(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|2[0-3])[0-5]\d[0-5]\d(?:\-|$)|-\d\d-\d\dT\d\d:\d\d:\d\d\b)/;
const STYLES_REGEX = /\b(?:background|background-color|border|border-color|box-shadow|color|outline|text-shadow)\s*:/i;
// conditionally remove styles from user talk pages
if (nsNum === 3 && userName) {
vanillaExtract();
}
function vanillaExtract() {
const getVanillaText = () => 'Vanilla ' + (userPages[userName] ? 'restore' : 'extract');
getUserOption(userPages, 'userjs-vanilla-users');
if (userPages[userName]) {
renameStyleAttributes(true);
delete userPages[userName];
userPages[userName] = 1;
setUserOption(userPages, 'userjs-vanilla-users');
}
const link = mw.util.addPortletLink('p-tb', '#', getVanillaText(), 'ca-vanilla-extract');
link.addEventListener('click', async (event) => {
event.preventDefault();
if (userPages[userName]) {
delete userPages[userName];
} else {
userPages[userName] = 1;
}
const span = link.querySelector('span');
if (span) {
span.textContent = getVanillaText();
}
renameStyleAttributes(userPages[userName]);
setUserOption(userPages, 'userjs-vanilla-users');
});
}
function renameStyleAttributes(deactivate) {
const content = document.querySelector('#mw-content-text');
const source = deactivate ? 'style' : 'data-vanilla-style';
const destination = deactivate ? 'data-vanilla-style' : 'style';
const styledElements = content.querySelectorAll(`[${source}]`);
for (const element of styledElements) {
const style = element.getAttribute(source);
if (style && STYLES_REGEX.test(style)) {
element.setAttribute(destination, style);
element.setAttribute(source, '');
}
}
}
function vanillaMode() {
if (mw.config.get('wgTitle') !== mw.config.get('wgUserName') + '/common.css') {
return;
}
getUserOption(userOptions, 'userjs-vanilla');
mw.util.addPortletLink('p-tb', '#', 'Vanilla mode', 'ca-vanilla-mode')
.addEventListener('click', async (event) => {
event.preventDefault();
userOptions.replace = !userOptions.replace;
const mode = userOptions.replace ? 'replacing signatures' : 'removing styling';
try {
setUserOption(userOptions, 'userjs-vanilla');
mw.notify(`Now ${mode}!`, { title: 'Vanilla' });
} catch {
mw.notify('Failed to save options!', { type: 'error' });
}
});
}
function getUserOption(destination, optionName) {
const userOptionsString = mw.user.options.get(optionName);
try {
if (userOptionsString) {
Object.assign(destination, JSON.parse(userOptionsString));
}
} catch (error) {
console.error(`Vanilla error parsing user option "${optionName}":`, error);
}
}
async function setUserOption(source, optionName) {
let jsonString = JSON.stringify(source);
const keys = Object.keys(userPages);
while (jsonString.length > 32768 || keys.length > 256) {
delete userPages[keys.shift()];
jsonString = JSON.stringify(source);
}
await new mw.Api().saveOption(optionName, jsonString);
}
function extractAuthor(span) {
const comment = span?.getAttribute('href');
const match = comment?.match(AUTHOR_REGEX);
return match ? match[1] : '';
}
function isUserPage(node, authorString) {
let url = node.href;
let pagetype = 'exact';
if (url.startsWith(SERVER_PREFIX)) {
url = url.substring(SERVER_PREFIX.length);
} else if (node.classList.contains('extiw')) {
try {
const urlObject = new URL(url);
url = urlObject.pathname + urlObject.search + urlObject.hash;
} catch (error) {
console.warn(`Vanilla error parsing "${url}":`, error);
return false;
}
} else if (!url && nsNum === 3 && !mw.config.get('wgRelevantPageName').includes('/') && node.classList.contains('selflink')) {
return 'self';
}
if (url.startsWith(ARTICLE_PATH)) {
url = url.substring(ARTICLE_PATH.length);
} else if (url.startsWith(SCRIPT_PATH)) {
url = url.substring(SCRIPT_PATH.length);
} else {
return false;
}
for (const prefix of SIGNATURE_LINKS) {
if (url.startsWith(prefix)) {
url = url.substring(prefix.length);
if (url.includes('%')) {
try {
const decoded = decodeURIComponent(url);
url = decoded;
} catch (error) {
console.warn(`Vanilla error decoding "${url}":`, error);
return false;
}
}
if (url.toLowerCase().startsWith(authorString)) {
url = url.substring(authorString.length);
// check for ALTERNATIVE_SUFFIXES match
const alternativeSuffixMatch = url.match(ALTERNATIVE_SUFFIXES);
if (alternativeSuffixMatch) {
url = url.substring(alternativeSuffixMatch[0].length);
pagetype = 'alternative';
}
if (!SIGNATURE_DELIMITERS.has(url.charAt(0))) {
return false;
}
if (url.match(/\.(?:css|js|json)\b|^\/(?:[Aa]rchive|.*\/)/)) {
return false;
}
const paramsIndex = node.href.indexOf('?');
if (paramsIndex !== -1) {
const queryString = node.href.substring(paramsIndex + 1);
const queryParams = new URLSearchParams(queryString);
if ([...queryParams.keys()].some(key => !SIGNATURE_PARAMS.has(key))) {
return false;
}
}
return pagetype;
}
return false;
}
}
return false;
}
function isVanillaSignature(matchedNodes, author) {
return matchedNodes.length === 4 &&
matchedNodes[0].nodeType === Node.ELEMENT_NODE &&
matchedNodes[0].children.length === 0 &&
matchedNodes[0].tagName === 'A' &&
matchedNodes[0].textContent.toLowerCase() === author.toLowerCase() &&
matchedNodes[1].nodeType === Node.TEXT_NODE && matchedNodes[1].nodeValue === ' (' &&
matchedNodes[2].nodeType === Node.ELEMENT_NODE &&
matchedNodes[2].children.length === 0 &&
matchedNodes[2].tagName === 'A' &&
matchedNodes[2].textContent.toLowerCase() === 'talk' &&
matchedNodes[3].nodeType === Node.TEXT_NODE && matchedNodes[3].nodeValue === ') ';
}
function createSignature(authorLink, authorText) {
const linkname = authorLink.replace(/ /g, '_');
const linktitle = authorLink.replace(/_/g, ' ');
const userLink = document.createElement('a');
userLink.href = `/wiki/User:${linkname}`;
userLink.title = `User:${linktitle}`;
userLink.textContent = authorText.replace(/_/g, ' ');
userLink.classList.add('vanilla-replaced');
const talkLink = document.createElement('a');
talkLink.href = `/wiki/User_talk:${linkname}`;
talkLink.title = `User talk:${linktitle}`;
talkLink.textContent = 'talk';
talkLink.classList.add('vanilla-replaced');
const fragment = document.createDocumentFragment();
fragment.appendChild(userLink);
fragment.appendChild(document.createTextNode(' ('));
fragment.appendChild(talkLink);
fragment.appendChild(document.createTextNode(') '));
return fragment;
}
function reviewTextNode(node, author) {
let parentNode = node.parentNode;
while (parentNode) {
if (parentNode.nodeType === Node.ELEMENT_NODE) {
if (parentNode.tagName === 'A') {
return null;
}
if (parentNode.tagName === 'DIV') {
break;
}
}
parentNode = parentNode.parentNode;
}
const anchor = document.createElement('a');
parentNode = node.parentNode;
anchor.href = `/wiki/User:${author}`;
anchor.textContent = node.nodeValue;
parentNode.insertBefore(anchor, node);
parentNode.removeChild(node);
return parentNode;
}
function signatureNodes(endNode, author) {
const authorString = author.toLowerCase().replace(/ /g, '_');
const conditions = {};
const matchedNodes = [];
const authorNodes = [];
const styleNodes = [];
let textLength = 0;
function checkNode(node) {
const childNodes = node.childNodes;
for (let i = childNodes.length - 1; i >= 0; i--) {
if (!checkNode(childNodes[i])) {
return false;
}
}
if (node.nodeType === Node.TEXT_NODE) {
textLength += node.nodeValue.length;
if (textLength > 120) {
return false;
}
if (node.nodeValue.toLowerCase().includes(authorString)) {
authorNodes.push(node);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.tagName === 'A') {
const userPageCheck = isUserPage(node, authorString);
if (userPageCheck) {
conditions.userPage = true;
if (userPageCheck === 'alternative') {
conditions.alternative = true;
}
if (userOptions.replace && conditions.originalAuthor === undefined) {
const authorText = node.textContent.trim();
const searchAuthor = author.replace(/_/g, ' ').toLowerCase();
if (authorText.toLowerCase() === searchAuthor) {
conditions.originalAuthor = authorText;
} else {
const titleText = (node.getAttribute('title') || '').trim()
if (titleText.toLowerCase().endsWith(searchAuthor)) {
conditions.originalAuthor = titleText.slice(-searchAuthor.length);
}
}
}
} else if (node.href) {
// allow other links until user page seen
if (conditions.userPage) {
return false;
}
} else {
return false;
}
} else if (node.tagName === 'SPAN') {
if (node.hasAttribute('data-mw-comment-start') ||
(node.classList.length === 1 && !SIGNATURE_SPAN_CLASSES.has(node.classList[0]) && !/sig/i.test(node.classList[0])) ||
(node.classList.length > 1 && ![...node.classList].every(name => SIGNATURE_SPAN_CLASSES.has(name)))) {
return false;
}
} else if (!SIGNATURE_ELEMENTS.has(node.tagName)) {
return false;
}
} else {
return false;
}
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('style')) {
styleNodes.push(node);
}
return true;
}
function isAutosigned(node) {
return node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SMALL' && node.classList.contains('autosigned');
}
function reviewPreviousText(firstMatchedNode, parentNode) {
// check previous sibling or previous sibling of span parent nodes
let priorNode = firstMatchedNode.previousSibling;
if (!priorNode && parentNode.nodeType === Node.ELEMENT_NODE && parentNode.nodeName === 'SPAN') {
priorNode = parentNode.previousSibling;
}
if (priorNode && priorNode.nodeType === Node.TEXT_NODE) {
// remove signature separators
let textBefore = priorNode.nodeValue.replace(SIGNATURE_SEPARATOR, ' ');
// add a space if needed
if (!textBefore.endsWith(' ')) {
textBefore += ' ';
}
// replace text if modified
if (textBefore !== priorNode.nodeValue) {
priorNode.nodeValue = textBefore;
}
}
}
// skip unwanted nodes from end
let node = endNode;
for (const className of ['ext-discussiontools-init-replylink-buttons', 'ext-discussiontools-init-timestamplink']) {
if (node && node.nodeType === Node.ELEMENT_NODE && node.getAttribute('class') === className) {
if (node.previousSibling) {
node = node.previousSibling;
} else if (node.parentNode && SIGNATURE_ELEMENTS.has(node.parentNode.nodeName)) {
// edge case: discussion tools element wrapped in a signature element
if (node.parentNode.previousSibling) {
node = node.parentNode.previousSibling;
}
}
}
}
// adjust for signatures below previous sibling, possibly after non-signature content
if (node && node.firstChild && node.firstChild.hasAttribute && node.firstChild.hasAttribute('data-mw-comment-start')) {
if (userOptions.debug) {
console.debug("changing node to last child after data-mw-comment-start", node);
}
node = node.lastChild;
}
// add nodes
while (node) {
if (checkNode(node)) {
matchedNodes.unshift(node);
} else {
break;
}
node = node.previousSibling;
}
// track modified nodes
const modified = [];
// no user page
if (!conditions.userPage) {
return modified;
}
// signature replacement mode
let replaced = false;
if (userOptions.replace) {
// remove unwanted nodes from start
while (matchedNodes.length) {
const firstNode = matchedNodes[0];
if (firstNode.nodeType === Node.ELEMENT_NODE) {
// first node is an anchor or contains an anchor
if (firstNode.tagName === 'A' || firstNode.getElementsByTagName('A').length) {
break
}
// first node is a styled signature element or has styled descendants
if (SIGNATURE_ELEMENTS.has(firstNode.tagName) && (firstNode.hasAttribute('style') || firstNode.querySelector('[style]')))
break;
}
// otherwise, remove the node
matchedNodes.shift();
}
// return early if there are no nodes left
if (!matchedNodes.length) {
return modified;
}
// skip autosigned signatures
const firstMatchedNode = matchedNodes[0];
const parentNode = firstMatchedNode.parentNode;
if (isAutosigned(firstMatchedNode) || isAutosigned(parentNode))
return modified;
// review text directly before signature
reviewPreviousText(firstMatchedNode, parentNode);
// signature modifications
if (conditions.originalAuthor === undefined) {
conditions.originalAuthor = author;
}
if (!conditions.alternative && !isVanillaSignature(matchedNodes, conditions.originalAuthor)) {
// replace signature
parentNode.classList.add('vanilla-processed');
parentNode.insertBefore(createSignature(author, conditions.originalAuthor), firstMatchedNode);
matchedNodes.forEach(node => node.parentNode.removeChild(node));
modified.push(parentNode);
replaced = true;
}
}
if (!replaced) {
// remove style attributes
styleNodes.forEach(node => node.removeAttribute('style'));
// review authorNodes
authorNodes.forEach(node => {
const reviewedNode = reviewTextNode(node, author);
if (reviewedNode) {
modified.push(reviewedNode);
}
});
}
// debugging
if (userOptions.debug && matchedNodes.length) {
matchedNodes.forEach(node => {
console.debug(`${author}\t${node.nodeType}\t${node.nodeType === 3 ? node.textContent : node.outerHTML}`);
});
}
// return any modified elements
return modified;
}
function filterProcessed(element) {
if (element.id === 'mw-content-text') {
element = element.querySelector('div.mw-parser-output') ?? element;
}
if (element.classList.contains('vanilla-processed')) {
return true;
}
element.classList.add('vanilla-processed');
return false;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function execute($content) {
// debugging
if (userOptions.debug) {
console.debug("Vanilla execute: content", $content, "timestamp", performance.now());
}
// process each element in $content
const modifiedElements = [];
for (const element of $content) {
// avoid reprocessing elements
if (filterProcessed(element))
continue;
// select and process signatures
let links = element.querySelectorAll('a.ext-discussiontools-init-timestamplink');
let loopCount = 0;
while (links.length) {
// avoid infinite loops
if (++loopCount > 100) {
if (userOptions.debug) {
console.warn("Vanilla excecute: maximum loop count exceeded");
}
break;
}
// process links
if (userOptions.debug) {
console.info(`Vanilla execute: processing ${links.length} links`);
}
const currentLinks = links;
links = [];
for (let i = 0; i < currentLinks.length; i++) {
const link = currentLinks[i];
if (link.hasAttribute('data-event-name')) {
const author = extractAuthor(link);
if (author) {
modifiedElements.push(...signatureNodes(link, author));
}
} else {
links.push(link);
}
}
const delayPromise = delay(10);
// fire the mw.hook if needed
if (modifiedElements.length) {
mw.hook('wikipage.content').fire($(modifiedElements));
modifiedElements.length = 0;
}
await delayPromise;
}
}
}
getUserOption(userOptions, 'userjs-vanilla');
mw.hook('wikipage.content').add(execute);
});