User:Polygnotus/Scripts/Linter.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 code will be executed when previewing this page.
Documentation for this user script can be added at User:Polygnotus/Scripts/Linter.
// <nowiki>
/**
* Default linter configuration
*/
const DEFAULT_CONFIG = {
LinterCategories: {
"fostered": {
"dbid": 1,
"enabled": true,
"priority": "medium",
"no-params": true
},
"obsolete-tag": {
"dbid": 2,
"enabled": true,
"priority": "low",
"has-name": true
},
"bogus-image-options": {
"dbid": 3,
"enabled": true,
"priority": "medium"
},
"missing-end-tag": {
"dbid": 4,
"enabled": true,
"priority": "low",
"has-name": true
},
"stripped-tag": {
"dbid": 5,
"enabled": true,
"priority": "low",
"has-name": true
},
"self-closed-tag": {
"dbid": 6,
"enabled": true,
"priority": "high",
"has-name": true
},
"deletable-table-tag": {
"dbid": 7,
"enabled": true,
"priority": "high",
"has-name": true
},
"misnested-tag": {
"dbid": 8,
"enabled": true,
"priority": "medium",
"has-name": true
},
"pwrap-bug-workaround": {
"dbid": 9,
"enabled": true,
"priority": "high"
},
"tidy-whitespace-bug": {
"dbid": 10,
"enabled": true,
"priority": "high"
},
"multi-colon-escape": {
"dbid": 11,
"enabled": true,
"priority": "medium"
},
"html5-misnesting": {
"dbid": 12,
"enabled": true,
"priority": "high",
"has-name": true
},
"tidy-font-bug": {
"dbid": 13,
"enabled": true,
"priority": "high",
"has-name": true
},
"multiple-unclosed-formatting-tags": {
"dbid": 14,
"enabled": true,
"priority": "high",
"has-name": true
},
"unclosed-quotes-in-heading": {
"dbid": 15,
"enabled": true,
"priority": "high",
"has-name": true
},
"multiline-html-table-in-list": {
"dbid": 16,
"enabled": true,
"priority": "high"
},
"misc-tidy-replacement-issues": {
"dbid": 17,
"enabled": true,
"priority": "high"
},
"wikilink-in-extlink": {
"dbid": 18,
"enabled": true,
"priority": "medium",
"no-params": true
},
"inline-media-caption": {
"dbid": 19,
"enabled": false,
"priority": "high",
"no-params": true
},
"large-tables": {
"dbid": 20,
"enabled": true,
"priority": "none"
},
"missing-end-tag-in-heading": {
"dbid": 21,
"enabled": true,
"priority": "low",
"has-name": true
},
"night-mode-unaware-background-color": {
"dbid": 22,
"enabled": true,
"priority": "low",
"no-params": true
},
"missing-image-alt-text": {
"dbid": 23,
"enabled": false,
"priority": "none"
},
"fostered-transparent": {
"dbid": 24,
"enabled": true,
"priority": "none",
"no-params": true
},
"duplicate-ids": {
"dbid": 25,
"enabled": true,
"priority": "high"
},
"empty-heading": {
"dbid": 26,
"enabled": true,
"priority": "low",
"no-params": true
}
}
};
/**
* Wikitext Linter - detects lint errors in wikitext/HTML
* @class WikitextLinter
*/
class WikitextLinter {
/**
* @param {Object} [config] - Optional custom linter configuration
*/
constructor(config = DEFAULT_CONFIG) {
this.categories = config.LinterCategories || {};
this.enabledCategories = this._getEnabledCategories();
}
/**
* Get all enabled categories
* @private
*/
_getEnabledCategories() {
return Object.keys(this.categories).filter(
cat => this.categories[cat].enabled === true
);
}
/**
* Lint wikitext and return all errors
* @param {string} wikitext - The wikitext to lint
* @param {Object} [options]
* @param {string[]} [options.categories] - Specific categories to check
* @returns {Object[]} Array of lint errors
*/
lint(wikitext, options = {}) {
const categoriesToCheck = options.categories || this.enabledCategories;
const errors = [];
for (const category of categoriesToCheck) {
if (!this.categories[category] || !this.categories[category].enabled) {
continue;
}
const categoryErrors = this._checkCategory(category, wikitext);
errors.push(...categoryErrors);
}
return errors;
}
/**
* Check for errors in a specific category
* @private
*/
_checkCategory(category, wikitext) {
const method = `_check_${category.replace(/-/g, '_')}`;
if (typeof this[method] === 'function') {
return this[method](wikitext);
}
return [];
}
/**
* Create an error object
* @private
*/
_createError(category, start, end, params = {}) {
return {
category,
location: { start, end },
params
};
}
// ============================================================================
// LINT CHECKS - Each category has its own check method
// ============================================================================
/**
* Check for obsolete HTML tags
*/
_check_obsolete_tag(wikitext) {
const errors = [];
const obsoleteTags = [
'acronym', 'applet', 'basefont', 'bgsound', 'big', 'blink', 'center',
'dir', 'font', 'frame', 'frameset', 'isindex', 'listing', 'marquee',
'menuitem', 'multicol', 'nextid', 'nobr', 'noembed', 'noframes',
'plaintext', 'rb', 'spacer', 'strike', 'tt', 'xmp'
];
for (const tag of obsoleteTags) {
const regex = new RegExp(`<(${tag})(\\s[^>]*)?>`, 'gi');
let match;
while ((match = regex.exec(wikitext)) !== null) {
errors.push(this._createError(
'obsolete-tag',
match.index,
match.index + match[0].length,
{ name: tag }
));
}
}
return errors;
}
/**
* Check for self-closed tags that shouldn't be self-closed
*/
_check_self_closed_tag(wikitext) {
const errors = [];
const selfClosedTags = [
'div', 'span', 'table', 'tr', 'td', 'th', 'tbody', 'thead', 'tfoot',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'center', 'blockquote'
];
for (const tag of selfClosedTags) {
const regex = new RegExp(`<(${tag})(\\s[^>]*)?\\s*/>`, 'gi');
let match;
while ((match = regex.exec(wikitext)) !== null) {
errors.push(this._createError(
'self-closed-tag',
match.index,
match.index + match[0].length,
{ name: tag }
));
}
}
return errors;
}
/**
* Check for missing end tags
*/
_check_missing_end_tag(wikitext) {
const errors = [];
const tagsToCheck = ['div', 'span', 'table', 'tr', 'td', 'th', 'ul', 'ol', 'li'];
for (const tag of tagsToCheck) {
const openRegex = new RegExp(`<${tag}(\\s[^>]*)?>`, 'gi');
const closeRegex = new RegExp(`</${tag}>`, 'gi');
const opens = [...wikitext.matchAll(openRegex)];
const closes = [...wikitext.matchAll(closeRegex)];
if (opens.length > closes.length) {
// More opens than closes - report the last unclosed one
const unclosedCount = opens.length - closes.length;
for (let i = opens.length - unclosedCount; i < opens.length; i++) {
const match = opens[i];
errors.push(this._createError(
'missing-end-tag',
match.index,
match.index + match[0].length,
{ name: tag }
));
}
}
}
return errors;
}
/**
* Check for stripped tags (tags that will be stripped by parser)
*/
_check_stripped_tag(wikitext) {
const errors = [];
const strippedTags = ['caption', 'b', 'i', 'u', 's', 'ruby', 'rb', 'rp', 'rt', 'rtc'];
for (const tag of strippedTags) {
// Look for these tags in invalid contexts
const regex = new RegExp(`<(${tag})(\\s[^>]*)?>`, 'gi');
let match;
while ((match = regex.exec(wikitext)) !== null) {
// Check if it's in a list context where it might be stripped
const before = wikitext.substring(Math.max(0, match.index - 10), match.index);
if (/[*#:;]/.test(before)) {
errors.push(this._createError(
'stripped-tag',
match.index,
match.index + match[0].length,
{ name: tag }
));
}
}
}
return errors;
}
/**
* Check for misnested tags
*/
_check_misnested_tag(wikitext) {
const errors = [];
const stack = [];
const tagRegex = /<\/?([a-z][a-z0-9]*)[^>]*>/gi;
let match;
while ((match = tagRegex.exec(wikitext)) !== null) {
const fullMatch = match[0];
const tagName = match[1].toLowerCase();
const isClosing = fullMatch.startsWith('</');
const isSelfClosing = fullMatch.endsWith('/>');
// Skip self-closing and void tags
const voidTags = ['br', 'hr', 'img', 'input', 'meta', 'link'];
if (isSelfClosing || voidTags.includes(tagName)) {
continue;
}
if (isClosing) {
// Check if this closing tag matches the most recent open tag
if (stack.length > 0 && stack[stack.length - 1].tag === tagName) {
stack.pop();
} else {
// Misnested - closing tag doesn't match
errors.push(this._createError(
'misnested-tag',
match.index,
match.index + fullMatch.length,
{ name: tagName }
));
}
} else {
// Opening tag
stack.push({
tag: tagName,
index: match.index,
length: fullMatch.length
});
}
}
return errors;
}
/**
* Check for multiple unclosed formatting tags
*/
_check_multiple_unclosed_formatting_tags(wikitext) {
const errors = [];
const formattingTags = ['b', 'i', 'u', 'em', 'strong', 's', 'strike'];
const unclosedCounts = {};
for (const tag of formattingTags) {
const openRegex = new RegExp(`<${tag}(\\s[^>]*)?>`, 'gi');
const closeRegex = new RegExp(`</${tag}>`, 'gi');
const opens = [...wikitext.matchAll(openRegex)];
const closes = [...wikitext.matchAll(closeRegex)];
const unclosed = opens.length - closes.length;
if (unclosed > 1) {
unclosedCounts[tag] = unclosed;
// Report each unclosed tag
for (let i = closes.length; i < opens.length; i++) {
const match = opens[i];
errors.push(this._createError(
'multiple-unclosed-formatting-tags',
match.index,
match.index + match[0].length,
{ name: tag }
));
}
}
}
return errors;
}
/**
* Check for wikilinks inside external links
*/
_check_wikilink_in_extlink(wikitext) {
const errors = [];
const extlinkRegex = /\[https?:\/\/[^\]]+\]/gi;
let match;
while ((match = extlinkRegex.exec(wikitext)) !== null) {
const extlinkContent = match[0];
// Check if there's a wikilink inside
if (/\[\[[^\]]+\]\]/.test(extlinkContent)) {
errors.push(this._createError(
'wikilink-in-extlink',
match.index,
match.index + extlinkContent.length
));
}
}
return errors;
}
/**
* Check for bogus image options
*/
_check_bogus_image_options(wikitext) {
const errors = [];
const imageRegex = /\[\[(File|Image):([^\]]+)\]\]/gi;
const validOptions = [
'thumb', 'thumbnail', 'frame', 'framed', 'frameless',
'border', 'right', 'left', 'center', 'none',
'baseline', 'sub', 'super', 'top', 'text-top',
'middle', 'bottom', 'text-bottom'
];
let match;
while ((match = imageRegex.exec(wikitext)) !== null) {
const content = match[2];
const parts = content.split('|');
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i].trim().toLowerCase();
// Check if it's an option (not a size or caption)
if (!/^\d+px$/.test(part) && !part.includes('=')) {
if (!validOptions.includes(part) && part !== '') {
errors.push(this._createError(
'bogus-image-options',
match.index,
match.index + match[0].length,
{ option: part }
));
}
}
}
}
return errors;
}
/**
* Check for deletable table tags
*/
_check_deletable_table_tag(wikitext) {
const errors = [];
// Look for empty table tags that can be deleted
const patterns = [
/<(tbody|thead|tfoot)(\s[^>]*)?>[\s\n]*<\/\1>/gi,
/<(tr|td|th)(\s[^>]*)?>[\s\n]*<\/\1>/gi
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(wikitext)) !== null) {
errors.push(this._createError(
'deletable-table-tag',
match.index,
match.index + match[0].length,
{ name: match[1] }
));
}
}
return errors;
}
/**
* Check for fostered content (content incorrectly placed in tables)
*/
_check_fostered(wikitext) {
const errors = [];
// Look for text/content outside of proper table cells
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
let match;
while ((match = tableRegex.exec(wikitext)) !== null) {
const tableContent = match[1];
const tableStart = match.index + match[0].indexOf('>') + 1;
// Look for content that's not in <tr>, <td>, <th>
const contentRegex = /(?:^|>)([^<]+)(?:<|$)/g;
let contentMatch;
while ((contentMatch = contentRegex.exec(tableContent)) !== null) {
const text = contentMatch[1].trim();
if (text && !/^[\s\n]*$/.test(text)) {
// Check if this text is fostered (not in a cell)
const beforeText = tableContent.substring(0, contentMatch.index);
const inCell = /<t[dh][^>]*>/.test(beforeText) &&
!/<\/t[dh]>/.test(beforeText.substring(beforeText.lastIndexOf('<t')));
if (!inCell) {
errors.push(this._createError(
'fostered',
tableStart + contentMatch.index,
tableStart + contentMatch.index + contentMatch[1].length
));
}
}
}
}
return errors;
}
/**
* Check for unclosed quotes in headings
*/
_check_unclosed_quotes_in_heading(wikitext) {
const errors = [];
const headingRegex = /^(={1,6})(.+?)\1\s*$/gm;
let match;
while ((match = headingRegex.exec(wikitext)) !== null) {
const headingContent = match[2];
const quotes = ["'", '"'];
for (const quote of quotes) {
const count = (headingContent.match(new RegExp(quote, 'g')) || []).length;
if (count % 2 === 1) {
errors.push(this._createError(
'unclosed-quotes-in-heading',
match.index,
match.index + match[0].length,
{ name: quote === "'" ? 'single' : 'double' }
));
}
}
}
return errors;
}
/**
* Check for empty headings
*/
_check_empty_heading(wikitext) {
const errors = [];
const emptyHeadingRegex = /^(={1,6})\s*\1\s*$/gm;
let match;
while ((match = emptyHeadingRegex.exec(wikitext)) !== null) {
errors.push(this._createError(
'empty-heading',
match.index,
match.index + match[0].length
));
}
return errors;
}
/**
* Check for duplicate IDs
*/
_check_duplicate_ids(wikitext) {
const errors = [];
const idRegex = /\bid\s*=\s*["']([^"']+)["']/gi;
const seenIds = new Map();
let match;
while ((match = idRegex.exec(wikitext)) !== null) {
const id = match[1];
if (seenIds.has(id)) {
errors.push(this._createError(
'duplicate-ids',
match.index,
match.index + match[0].length,
{ id }
));
} else {
seenIds.set(id, match.index);
}
}
return errors;
}
/**
* Check for multiline HTML tables in lists
*/
_check_multiline_html_table_in_list(wikitext) {
const errors = [];
const lines = wikitext.split('\n');
let inList = false;
let tableStart = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check if line starts a list
if (/^[*#:;]/.test(line)) {
inList = true;
} else if (inList && line.trim() === '') {
inList = false;
}
// Check for table start in list
if (inList && /<table/i.test(line)) {
tableStart = i;
}
// Check for table end
if (tableStart !== -1 && /<\/table>/i.test(line)) {
if (i > tableStart) {
// Multiline table in list
const startPos = lines.slice(0, tableStart).join('\n').length;
const endPos = lines.slice(0, i + 1).join('\n').length;
errors.push(this._createError(
'multiline-html-table-in-list',
startPos,
endPos
));
}
tableStart = -1;
}
}
return errors;
}
/**
* Convert errors to JSON format
* @param {Object[]} errors
* @returns {string}
*/
toJSON(errors) {
return JSON.stringify(errors, null, 2);
}
}
// </nowiki>