| /* This file is a part of @mdn/browser-compat-data |
| * See LICENSE file for more information. */ |
| |
| import { styleText } from 'node:util'; |
| |
| import HTMLParser from '@desertnet/html-parser'; |
| import { marked } from 'marked'; |
| |
| import { VALID_ELEMENTS } from '../utils.js'; |
| |
| /** @import {Linter, LinterData} from '../types.js' */ |
| /** @import {Logger} from '../utils.js' */ |
| /** @import {BrowserName, CompatStatement, SupportStatement} from '../../types/types.js' */ |
| |
| const parser = new HTMLParser(); |
| |
| /** |
| * Recursively test a DOM node for valid elements |
| * @param {*} node The DOM node to test |
| * @returns {string[]} The errors found during validation |
| */ |
| const testNode = (node) => { |
| /** @type {string[]} */ |
| const errors = []; |
| if (node.type == 'TAG') { |
| const tag = node.tagName?.toLowerCase(); |
| if (tag && !VALID_ELEMENTS.includes(tag)) { |
| // Ensure we're only using select nodes |
| errors.push( |
| `HTML element ${styleText('bold', `<${tag}>`)} is ${styleText('bold', 'not allowed')}. Allowed HTML elements are: ${VALID_ELEMENTS.join(', ')}`, |
| ); |
| } |
| |
| // Ensure nodes only contain specific attributes |
| const attrs = node.attributes.map((x) => x._name); |
| if (tag === 'a') { |
| if (attrs.length !== 1 || !attrs.includes('href')) { |
| // Ensure 'a' nodes only contain an 'href' |
| errors.push( |
| `HTML element ${styleText('bold', `<${tag}>`)} has ${styleText('bold', 'invalid attributes')}. ${styleText('bold', `<${tag}>`)} elements may only have (and must have) an ${styleText('bold', 'href')} attribute; found ${styleText('bold', attrs.length ? attrs.join(', ') : 'no attributes')}.`, |
| ); |
| } |
| } else { |
| if (attrs.length > 0) { |
| // Ensure nodes (besides 'a') contain no attributes |
| errors.push( |
| `HTML element ${styleText('bold', `<${tag}>`)} has ${styleText('bold', 'invalid attributes')}. Elements other than ${styleText('bold', '<a>')} may ${styleText('bold', 'not')} have any attributes; found ${styleText('bold', attrs.join(', '))}.`, |
| ); |
| } |
| } |
| } |
| |
| for (const childNode of node.children || []) { |
| errors.push(...testNode(childNode)); |
| } |
| |
| return errors; |
| }; |
| |
| /** |
| * Test a string for valid HTML |
| * @param {string} string The string to test |
| * @returns {string[]} The errors found during validation |
| */ |
| export const validateHTML = (string) => { |
| /** @type {string[]} */ |
| const errors = []; |
| const html = marked.parseInline(string); |
| const htmlErrors = HTMLParser.validate(html); |
| |
| if (htmlErrors.length === 0) { |
| // If HTML is valid, ensure we're only using valid elements |
| errors.push(...testNode(parser.parse(html))); |
| } else { |
| errors.push( |
| `Invalid HTML: ${htmlErrors.map((x) => x._message).join(', ')}`, |
| ); |
| } |
| |
| if (string.includes(' ')) { |
| errors.push('Double-spaces are not allowed.'); |
| } |
| |
| if (string.includes('\n')) { |
| errors.push('Newlines are not allowed.'); |
| } |
| |
| return errors; |
| }; |
| |
| /** |
| * Check the notes in the data |
| * @param {string | string[]} notes The notes to test |
| * @param {BrowserName} browser The browser the notes belong to |
| * @param {string} feature The identifier of the feature |
| * @param {Logger} logger The logger to output errors to |
| * @returns {void} |
| */ |
| const checkNotes = (notes, browser, feature, logger) => { |
| /** @type {*} */ |
| let errors = []; |
| if (Array.isArray(notes)) { |
| for (const note of notes) { |
| errors = validateHTML(note); |
| } |
| } else { |
| errors = validateHTML(notes); |
| } |
| |
| if (errors) { |
| for (const error of errors) { |
| logger.error(`Notes for ${styleText('bold', browser)} → ${error}`); |
| } |
| } |
| }; |
| |
| /** |
| * Process the data for notes errors |
| * @param {CompatStatement} data The data to test |
| * @param {Logger} logger The logger to output errors to |
| * @param {string} feature The identifier of the feature |
| * @returns {void} |
| */ |
| const processData = (data, logger, feature) => { |
| for (const [ |
| browser, |
| support, |
| ] of /** @type {[BrowserName, SupportStatement][]} */ ( |
| Object.entries(data.support) |
| )) { |
| if (Array.isArray(support)) { |
| for (const s of support) { |
| if (s.notes) { |
| checkNotes(s.notes, browser, feature, logger); |
| } |
| } |
| } else { |
| if (support.notes) { |
| checkNotes(support.notes, browser, feature, logger); |
| } |
| } |
| } |
| }; |
| |
| /** @type {Linter} */ |
| export default { |
| name: 'Notes', |
| description: 'Test the notes in each support statement', |
| scope: 'feature', |
| /** |
| * Test the data |
| * @param {Logger} logger The logger to output errors to |
| * @param {LinterData} root The data to test |
| */ |
| check: (logger, { data, path: { full } }) => { |
| processData(/** @type {CompatStatement} */ (data), logger, full); |
| }, |
| }; |