Jump to content

User:Polygnotus/Scripts/Linter.js

From Wikipedia, the free encyclopedia
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.
// <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>