w3c-html-validator
Version:
Check the markup validity of HTML files using the W3C validator
204 lines (202 loc) • 10.7 kB
JavaScript
//! 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 };