UNPKG

w3c-html-validator

Version:

Check the markup validity of HTML files using the W3C validator

204 lines (202 loc) 10.7 kB
//! w3c-html-validator v2.1.0 ~~ https://github.com/center-key/w3c-html-validator ~~ MIT License import { cliArgvUtil } from 'cli-argv-util'; import { globSync } from 'glob'; import chalk from 'chalk'; import fs from 'fs'; import log from 'fancy-log'; import request from 'superagent'; import slash from 'slash'; const w3cHtmlValidator = { version: '2.1.0', defaultIgnoreList: [ 'with computed level', 'Section lacks heading.', ], assert(ok, message) { if (!ok) throw new Error(`[w3c-html-validator] ${message}`); }, cli() { const validFlags = ['continue', 'default-rules', 'delay', 'dry-run', 'exclude', 'ignore', 'ignore-config', 'note', 'quiet', 'trim']; const cli = cliArgvUtil.parse(validFlags); const files = cli.params.length ? cli.params.map(cliArgvUtil.cleanPath) : ['.']; const excludeList = cli.flagMap.exclude?.split(',') ?? []; const ignore = cli.flagMap.ignore ?? null; const ignoreConfig = cli.flagMap.ignoreConfig ?? null; const defaultRules = cli.flagOn.defaultRules; const delay = Number(cli.flagMap.delay) || 500; const trim = Number(cli.flagMap.trim) || null; const dryRun = cli.flagOn.dryRun || process.env.w3cHtmlValidator === 'dry-run'; const getFilenames = () => { const readFilenames = (file) => globSync(file, { ignore: '**/node_modules/**/*' }).map(slash); const readHtmlFiles = (folder) => readFilenames(folder + '/**/*.html'); const addHtml = (file) => fs.lstatSync(file).isDirectory() ? readHtmlFiles(file) : file; const keep = (file) => excludeList.every(exclude => !file.includes(exclude)); return files.map(readFilenames).flat().map(addHtml).flat().filter(keep).sort(); }; const filenames = getFilenames(); const error = cli.invalidFlag ? cli.invalidFlagMsg : !filenames.length ? 'No files to validate.' : cli.flagOn.trim && !trim ? 'Value of "trim" must be a positive whole number.' : null; w3cHtmlValidator.assert(!error, error); if (dryRun) w3cHtmlValidator.dryRunNotice(); if (filenames.length > 1 && !cli.flagOn.quiet) w3cHtmlValidator.summary(filenames.length); const reporterOptions = { continueOnFail: cli.flagOn.continue, maxMessageLen: trim, quiet: cli.flagOn.quiet, title: null, }; const getIgnoreMessages = () => { const toArray = (text) => text.replace(/\r/g, '').split('\n').map(line => line.trim()); const notComment = (line) => line.length > 1 && !line.startsWith('#'); const readLines = (file) => toArray(fs.readFileSync(file).toString()).filter(notComment); const rawLines = ignoreConfig ? readLines(ignoreConfig) : []; if (ignore) rawLines.push(ignore); const isRegex = /^\/.*\/$/; return rawLines.map(line => isRegex.test(line) ? new RegExp(line.slice(1, -1)) : line); }; const ignoreMessages = getIgnoreMessages(); const options = (filename) => ({ filename, ignoreMessages, defaultRules, dryRun }); const handleResults = (results) => w3cHtmlValidator.reporter(results, reporterOptions); const getReport = (filename) => w3cHtmlValidator.validate(options(filename)).then(handleResults); const processFile = (filename, i) => globalThis.setTimeout(() => getReport(filename), i * delay); filenames.forEach(processFile); }, validate(options) { const defaults = { checkUrl: 'https://validator.w3.org/nu/', defaultRules: false, dryRun: false, filename: null, html: null, ignoreLevel: null, ignoreMessages: [], output: 'json', website: null, }; const settings = { ...defaults, ...options }; const missingInput = !settings.html && !settings.filename && !settings.website; const badLevel = ![null, 'info', 'warning'].includes(settings.ignoreLevel); const invalidOutput = settings.output !== 'json' && settings.output !== 'html'; const error = missingInput ? 'Must specify the "html", "filename", or "website" option.' : badLevel ? `Invalid ignoreLevel option: ${settings.ignoreLevel}` : invalidOutput ? 'Option "output" must be "json" or "html".' : null; w3cHtmlValidator.assert(!error, error); const filename = settings.filename ? slash(settings.filename) : null; const mode = settings.html ? 'html' : filename ? 'filename' : 'website'; const unixify = (text) => text.replace(/\r/g, ''); const readFile = (filename) => unixify(fs.readFileSync(filename, 'utf-8')); const inputHtml = settings.html ?? (filename ? readFile(filename) : null); const makePostRequest = () => request.post(settings.checkUrl) .set('Content-Type', 'text/html; encoding=utf-8') .send(inputHtml); const makeGetRequest = () => request.get(settings.checkUrl) .query({ doc: settings.website }); const w3cRequest = inputHtml ? makePostRequest() : makeGetRequest(); const userAgent = 'W3C HTML Validator ~ github.com/center-key/w3c-html-validator'; w3cRequest.set('User-Agent', userAgent); w3cRequest.query({ out: settings.output }); const json = settings.output === 'json'; const success = '<p class="success">'; const titleLookup = { html: `HTML String (characters: ${inputHtml?.length})`, filename: filename, website: settings.website, }; const filterMessages = (response) => { const aboveInfo = (subType) => settings.ignoreLevel === 'info' && !!subType; const aboveIgnoreLevel = (message) => !settings.ignoreLevel || message.type !== 'info' || aboveInfo(message.subType); const defaultList = settings.defaultRules ? w3cHtmlValidator.defaultIgnoreList : []; const ignoreList = [...settings.ignoreMessages, ...defaultList]; const tester = (title) => (pattern) => typeof pattern === 'string' ? title.includes(pattern) : pattern.test(title); const skipMatchFound = (title) => ignoreList.some(tester(title)); const isImportant = (message) => aboveIgnoreLevel(message) && !skipMatchFound(message.message); if (json) response.body.messages = response.body.messages?.filter(isImportant) ?? []; return response; }; const toValidatorResults = (response) => ({ validates: json ? !response.body.messages.length : !!response.text?.includes(success), mode: mode, title: titleLookup[mode], html: inputHtml, filename: filename, website: settings.website || null, output: settings.output, status: response.statusCode || -1, messages: json ? response.body.messages : null, display: json ? null : response.text, dryRun: settings.dryRun, }); const handleError = (reason) => { const errRes = reason.response ?? {}; const getMsg = () => [errRes.status, errRes.res.statusMessage, errRes.request.url]; const message = reason.response ? getMsg() : [reason.errno, reason.message]; errRes.body = { messages: [{ type: 'network-error', message: message.join(' ') }] }; return toValidatorResults(errRes); }; const pseudoResponse = { statusCode: 200, body: { messages: [] }, text: 'Validation bypassed.', }; const pseudoRequest = () => new Promise(resolve => resolve(pseudoResponse)); const validation = settings.dryRun ? pseudoRequest() : w3cRequest; return validation.then(filterMessages).then(toValidatorResults).catch(handleError); }, dryRunNotice() { log(chalk.gray('w3c-html-validator'), chalk.yellowBright('dry run mode:'), chalk.whiteBright('validation being bypassed')); }, summary(numFiles) { log(chalk.gray('w3c-html-validator'), chalk.magenta('files: ' + String(numFiles))); }, reporter(results, options) { const defaults = { continueOnFail: false, maxMessageLen: null, quiet: false, title: null, }; const settings = { ...defaults, ...options }; const hasResults = 'validates' in results && typeof results.validates === 'boolean'; w3cHtmlValidator.assert(hasResults, `Invalid results for reporter(): ${results}`); const messages = results.messages ?? []; const title = settings.title ?? results.title; const status = results.validates ? chalk.green.bold('✔ pass') : chalk.red.bold('✘ fail'); const count = results.validates ? '' : `(messages: ${messages.length})`; if (!results.validates || !settings.quiet) log(chalk.gray('w3c-html-validator'), status, chalk.blue.bold(title), chalk.white(count)); const typeColorMap = { error: chalk.red.bold, warning: chalk.yellow.bold, info: chalk.white.bold, }; const logMessage = (message) => { const type = message.subType ?? message.type; const typeColor = typeColorMap[type] ?? chalk.redBright.bold; const location = `line ${message.lastLine}, column ${message.firstColumn}:`; const lineText = message.extract?.replace(/\n/g, '\\n'); const maxLen = settings.maxMessageLen ?? undefined; log(typeColor('HTML ' + type + ':'), message.message.substring(0, maxLen)); if (message.lastLine) log(chalk.white(location), chalk.magenta(lineText)); }; messages.forEach(logMessage); const failDetails = () => { const toString = (message) => `${message.subType ?? message.type} line ${message.lastLine} column ${message.firstColumn}`; const fileDetails = () => `${results.filename} -- ${results.messages.map(toString).join(', ')}`; return !results.filename ? results.messages[0].message : fileDetails(); }; const failed = !settings.continueOnFail && !results.validates; w3cHtmlValidator.assert(!failed, `Failed: ${failDetails()}`); return results; }, }; export { w3cHtmlValidator };