├── .gitattributes ├── .github └── workflows │ ├── release-on-vtag.yaml │ └── run-spec-on-push.yaml ├── .gitignore ├── LICENSE.txt ├── README.md ├── bin └── cli.js ├── dist ├── w3c-html-validator.d.ts └── w3c-html-validator.js ├── eslint.config.js ├── examples.js ├── examples.png ├── package.json ├── spec ├── html │ ├── invalid.html │ └── valid.html ├── ignore-config.txt └── mocha.spec.js ├── src └── w3c-html-validator.ts ├── task-runner.sh.command └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js linguist-detectable=false 3 | -------------------------------------------------------------------------------- /.github/workflows/release-on-vtag.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 #see: https://github.com/actions/checkout/releases 13 | - uses: actions/create-release@v1 #see: https://github.com/actions/create-release/releases 14 | env: 15 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 16 | with: 17 | tag_name: ${{github.ref}} 18 | release_name: Release ${{github.ref}} 19 | draft: false 20 | prerelease: false 21 | -------------------------------------------------------------------------------- /.github/workflows/run-spec-on-push.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 #see: https://github.com/actions/checkout/releases 10 | - uses: actions/setup-node@v4 #see: https://github.com/actions/setup-node/releases 11 | - run: npm install 12 | - run: npm test 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Downloaded and generated files 2 | /node_modules/ 3 | /package-lock.json 4 | /build/ 5 | 6 | # Extraneous system files 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2025 Individual contributors to w3c-html-validator 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # W3C HTML Validator 2 | logo 3 | 4 | _Check the markup validity of HTML files using the W3C validator_ 5 | 6 | [![License:MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/center-key/w3c-html-validator/blob/main/LICENSE.txt) 7 | [![npm](https://img.shields.io/npm/v/w3c-html-validator.svg)](https://www.npmjs.com/package/w3c-html-validator) 8 | [![Build](https://github.com/center-key/w3c-html-validator/actions/workflows/run-spec-on-push.yaml/badge.svg)](https://github.com/center-key/w3c-html-validator/actions/workflows/run-spec-on-push.yaml) 9 | 10 | **w3c-html-validator** takes HTML files and returns detailed validation results.  11 | The reporter produces formatted output indented for use in build scripts and test suites. 12 | 13 | screenshot 15 | 16 | ## A) Setup 17 | Install package for node: 18 | ```shell 19 | $ npm install --save-dev w3c-html-validator 20 | ``` 21 | 22 | ## B) Usage 23 | ### 1. npm package.json scripts 24 | Run `html-validator` from the `"scripts"` section of your **package.json** file. 25 | 26 | The parameters are folders and files to be validated. 27 | 28 | Example **package.json** scripts: 29 | ```json 30 | "scripts": { 31 | "validate": "html-validator docs flyer.html", 32 | "one-folder": "html-validator docs", 33 | "all": "html-validator --quiet" 34 | }, 35 | ``` 36 | 37 | Passing no parameters defaults to validating all HTML files in the project (skipping the 38 | **node_modules** folder). 39 | 40 | ### 2. Command-line npx 41 | Example terminal commands: 42 | ```shell 43 | $ npm install --save-dev w3c-html-validator 44 | $ npx html-validator docs 45 | ``` 46 | The above `npx` line validates all the HTML files in the **docs** folder. 47 | 48 | You can also install **w3c-html-validator** globally (`--global`) and then run it anywhere directly from the terminal. 49 | 50 | ### 3. CLI flags 51 | Command-line flags: 52 | | Flag | Description | Value | 53 | | ----------------- | ------------------------------------------------------------------- | ---------- | 54 | | `--continue` | Report messages but do not throw an error if validation failed. | N/A | 55 | | `--delay` | Debounce pause in milliseconds between each file validation. | **number** | 56 | | `--dry-run` | Bypass validation (for usage while building your CI). | N/A | 57 | | `--exclude` | Comma separated list of strings to match in paths to skip. | **string** | 58 | | `--ignore` | Skip validation messages containing a string or matching a regex. | **string** | 59 | | `--ignore-config` | File containing strings and regexes of validation messages to skip. | **string** | 60 | | `--note` | Place to add a comment only for humans. | **string** | 61 | | `--quiet` | Suppress messages for successful validations. | N/A | 62 | | `--trim` | Truncate validation messages to not exceed a maximum length. | **number** | 63 | 64 | ### 4. Example CLI usage 65 | Examples: 66 | - `html-validator`
67 | Validates all HTML files in the project. 68 | 69 | - `html-validator docs --exclude=build,tmp`
70 | Validates all HTML files in the **docs** folder except files which have "build" or "tmp" anywhere in their pathname or filename. 71 | 72 | - `html-validator docs '--ignore=Trailing slash on void elements'`
73 | Allows the ugly slashes of self-closing tags despite XHTML being a hideous scourge on the web. 74 | 75 | - `html-validator docs '--ignore=/^Duplicate ID/'`
76 | Uses a regex (regular expression) to skip all HTML validation messages that start with "Duplicate ID". 77 | 78 | - `html-validator docs '--ignore=/^Duplicate ID|^Section lacks|^Element .blockquote. not allowed/'`
79 | Uses a regex with "or" operators (`|`) to skip multiple HTML validation messages. 80 | 81 | - `html-validator docs --ignore-config=spec/ignore-config.txt`
82 | Similar to the pervious command but strings and regexes are stored in a configuration file (see the _Configuration File for Ignore Patterns_ section below). 83 | 84 | - `html-validator --quiet`
85 | Suppresses all the "pass" status messages. 86 | 87 | - `html-validator docs --delay=200`
88 | Validates all HTML files in the "docs" folder at a rate of 1 file per 200 ms (default is 500 ms). 89 | 90 | - `html-validator docs --trim=30 --continue`
91 | Truncates validation messages to 30 characters and does not abort CI if validation fails. 92 | 93 | _**Note:** Single quotes in commands are normalized so they work cross-platform and avoid the errors often encountered on Microsoft Windows._ 94 | 95 | ### 5. Configuration File for Ignore Patterns 96 | The optional `--ignore-config=FILENAME` flag specifies a configuration file with one string or regex per line.  97 | HTML validation messages containing any of the strings or matching any of the regexes will be skipped.  98 | Empty lines and lines starting with a hash sign (`#`) are treated as comments and do nothing. 99 | 100 | Example configuration file with 3 regexes: 101 | ```config 102 | # Ignore Config for w3c-html-validator 103 | 104 | /^Duplicate ID/ 105 | /^Element .blockquote. not allowed/ 106 | /^Element .style. not allowed/ 107 | ``` 108 | The caret (`^`) regex operator says to match from the beginning of the validation message.  109 | The dot (`.`) regex operator says to match any one character, which is a handy way to avoid typing the special curly quote characters in some of the validation messages. 110 | 111 | ## D) Application Code and Testing Frameworks 112 | In addition to the CLI interface, the **w3c-html-validator** package can also be imported and called directly in ESM and TypeScript projects. 113 | 114 | Note that if your application calls `w3cHtmlValidator.validate()` multiple times, you must throttle (debounce) the calls or risk getting rejected by the W3C server. 115 | 116 | ### 1. Import 117 | Example call to the `validate()` function: 118 | ```typescript 119 | import { w3cHtmlValidator } from 'w3c-html-validator'; 120 | 121 | const options = { filename: 'docs/index.html' }; 122 | w3cHtmlValidator.validate(options).then(console.log); 123 | ``` 124 | To display formatted output, replace `console.log` with `w3cHtmlValidator.reporter`: 125 | ```typescript 126 | w3cHtmlValidator.validate(options).then(w3cHtmlValidator.reporter); 127 | ``` 128 | 129 | To see some example validation results, run the commands: 130 | ```shell 131 | $ cd w3c-html-validator 132 | $ node examples.js 133 | ``` 134 | 135 | ### 2. Options 136 | #### w3cHtmlValidator.validate(options) 137 | | Name (key) | Type | Default | Description | 138 | | :--------------- | :---------------------- | :------------------------------- | :------------------------------------------------------ | 139 | | `checkUrl` | **string** | `'https://validator.w3.org/nu/'` | W3C validation API endpoint. | 140 | | `dryRun` | **boolean** | `false` | Bypass validation (for usage while building your CI). | 141 | | `filename` | **string** | `null` | HTML file to validate. | 142 | | `html` | **string** | `null` | HTML string to validate. | 143 | | `ignoreLevel` | `'info'` or `'warning'` | `null` | Skip unwanted messages.* | 144 | | `ignoreMessages` | **array** | `[]` | Skip messages containing a string or matching a regex.* | 145 | | `output` | `'json'` or `'html'` | `'json'` | Get results as an array or as a web page. | 146 | | `website` | **string** | `null` | URL of website to validate. | 147 | 148 | *The `ignoreMessages` and `ignoreLevel` options only work for `'json'` output.  149 | Setting `ignoreLevel` to `'warning'` skips both `'warning'` level and `'info'` level validation messages. 150 | 151 | #### w3cHtmlValidator.reporter(results, options) 152 | | Name (key) | Type | Default | Description | 153 | | :--------------- | :---------- | :------ | :-------------------------------------------------------------- | 154 | | `continueOnFail` | **boolean** | `false` | Report messages but do not throw an error if validation failed. | 155 | | `maxMessageLen` | **number** | `null` | Trim validation messages to not exceed a maximum length. | 156 | | `quiet` | **boolean** | `false` | Suppress status messages for successful validations. | 157 | | `title` | **string** | `null` | Override display title (useful for naming HTML string inputs). | 158 | 159 | ### 3. TypeScript declarations 160 | See the TypeScript declarations at the top of the 161 | [w3c-html-validator.ts](src/w3c-html-validator.ts) file. 162 | 163 | The output of the `w3cHtmlValidator.validate(options: ValidatorOptions)` function is a **promise** 164 | for a `ValidatorResults` object: 165 | ```typescript 166 | type ValidatorResults = { 167 | validates: boolean, 168 | mode: 'html' | 'filename' | 'website'; 169 | html: string | null, 170 | filename: string | null, 171 | website: string | null, 172 | output: 'json' | 'html', 173 | status: number, 174 | messages: ValidatorResultsMessage[] | null, //for 'json' output 175 | display: string | null, //for 'html' output 176 | dryRun: boolean, 177 | }; 178 | ``` 179 | 180 | ### 4. Mocha example 181 | ```javascript 182 | import assert from 'assert'; 183 | import { w3cHtmlValidator } from 'w3c-html-validator'; 184 | 185 | describe('Home page', () => { 186 | 187 | it('validates', (done) => { 188 | const handleResults = (results) => { 189 | assert(results.status === 200, 'Request succeeded'); 190 | assert(results.validates, 'Home page validates'); 191 | done(); 192 | }; 193 | const options = { filename: 'docs/index.html' }; 194 | w3cHtmlValidator.validate(options).then(handleResults); 195 | }); 196 | 197 | }); 198 | ``` 199 | 200 |
201 | 202 | --- 203 | **CLI Build Tools for package.json** 204 | - 🎋 [add-dist-header](https://github.com/center-key/add-dist-header):  _Prepend a one-line banner comment (with license notice) to distribution files_ 205 | - 📄 [copy-file-util](https://github.com/center-key/copy-file-util):  _Copy or rename a file with optional package version number_ 206 | - 📂 [copy-folder-util](https://github.com/center-key/copy-folder-util):  _Recursively copy files from one folder to another folder_ 207 | - 🪺 [recursive-exec](https://github.com/center-key/recursive-exec):  _Run a command on each file in a folder and its subfolders_ 208 | - 🔍 [replacer-util](https://github.com/center-key/replacer-util):  _Find and replace strings or template outputs in text files_ 209 | - 🔢 [rev-web-assets](https://github.com/center-key/rev-web-assets):  _Revision web asset filenames with cache busting content hash fingerprints_ 210 | - 🚆 [run-scripts-util](https://github.com/center-key/run-scripts-util):  _Organize npm package.json scripts into groups of easy to manage commands_ 211 | - 🚦 [w3c-html-validator](https://github.com/center-key/w3c-html-validator):  _Check the markup validity of HTML files using the W3C validator_ 212 | 213 | Feel free to submit questions at:
214 | [github.com/center-key/w3c-html-validator/issues](https://github.com/center-key/w3c-html-validator/issues) 215 | 216 | [MIT License](LICENSE.txt) 217 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | //////////////////////// 3 | // w3c-html-validator // 4 | // MIT License // 5 | //////////////////////// 6 | 7 | // Usage in package.json: 8 | // "scripts": { 9 | // "validate": "html-validator docs flyer.html", 10 | // "all": "html-validator" 11 | // }, 12 | // 13 | // Usage from command line: 14 | // $ npm install --save-dev w3c-html-validator 15 | // $ npx html-validator dist #validate all html files in the dist folder 16 | // $ npx html-validator docs flyer.html 17 | // 18 | // Contributors to this project: 19 | // $ cd w3c-html-validator 20 | // $ npm install 21 | // $ npm test 22 | // $ node bin/cli.js spec --continue 23 | 24 | // Imports 25 | import { cliArgvUtil } from 'cli-argv-util'; 26 | import { globSync } from 'glob'; 27 | import { w3cHtmlValidator } from '../dist/w3c-html-validator.js'; 28 | import fs from 'fs'; 29 | import slash from 'slash'; 30 | 31 | // Parameters and flags 32 | const validFlags = ['continue', 'delay', 'dry-run', 'exclude', 'ignore', 'ignore-config', 'note', 'quiet', 'trim']; 33 | const cli = cliArgvUtil.parse(validFlags); 34 | const files = cli.params; 35 | const ignore = cli.flagMap.ignore ?? null; 36 | const ignoreConfig = cli.flagMap.ignoreConfig ?? null; 37 | const delay = Number(cli.flagMap.delay) || 500; //default half second debounce pause 38 | const trim = Number(cli.flagMap.trim) || null; 39 | const dryRunMode = cli.flagOn.dryRun || process.env.w3cHtmlValidator === 'dry-run'; //bash: export w3cHtmlValidator=dry-run 40 | 41 | // Validator 42 | const globOptions = { ignore: '**/node_modules/**/*' }; 43 | const keep = (filename) => !filename.includes('node_modules/'); 44 | const readFolder = (folder) => globSync(slash(folder + '**/*.html'), globOptions); 45 | const getAllPaths = () => files.map(file => globSync(slash(file), globOptions)).flat(); 46 | const expandFolder = (file) => fs.lstatSync(file).isDirectory() ? readFolder(file + '/') : file; 47 | const getFilenames = () => getAllPaths().map(expandFolder).flat().filter(keep).sort(); 48 | const list = files.length ? getFilenames() : readFolder(''); 49 | const excludes = cli.flagMap.exclude?.split(',') ?? []; 50 | const filenames = list.filter(name => !excludes.find(exclude => name.includes(exclude))); 51 | const error = 52 | cli.invalidFlag ? cli.invalidFlagMsg : 53 | !filenames.length ? 'No files to validate.' : 54 | cli.flagOn.trim && !trim ? 'Value of "trim" must be a positive whole number.' : 55 | null; 56 | if (error) 57 | throw new Error('[w3c-html-validator] ' + error); 58 | if (dryRunMode) 59 | w3cHtmlValidator.dryRunNotice(); 60 | if (filenames.length > 1 && !cli.flagOn.quiet) 61 | w3cHtmlValidator.summary(filenames.length); 62 | const reporterOptions = { 63 | continueOnFail: cli.flagOn.continue, 64 | quiet: cli.flagOn.quiet, 65 | maxMessageLen: trim, 66 | }; 67 | const getIgnoreMessages = () => { 68 | const toArray = (text) => text.replace(/\r/g, '').split('\n').map(line => line.trim()); 69 | const notComment = (line) => line.length > 1 && !line.startsWith('#'); 70 | const readLines = (file) => toArray(fs.readFileSync(file).toString()).filter(notComment); 71 | const rawLines = ignoreConfig ? readLines(ignoreConfig) : []; 72 | if (ignore) 73 | rawLines.push(ignore); 74 | const isRegex = /^\/.*\/$/; //starts and ends with a slash indicating it's a regex 75 | return rawLines.map(line => isRegex.test(line) ? new RegExp(line.slice(1, -1)) : line); 76 | }; 77 | const baseOptions = { ignoreMessages: getIgnoreMessages(), dryRun: dryRunMode }; 78 | const options = (filename) => ({ filename: filename, ...baseOptions }); 79 | const handleResults = (results) => w3cHtmlValidator.reporter(results, reporterOptions); 80 | const getReport = (filename) => w3cHtmlValidator.validate(options(filename)).then(handleResults); 81 | const processFile = (filename, i) => globalThis.setTimeout(() => getReport(filename), i * delay); 82 | filenames.forEach(processFile); 83 | -------------------------------------------------------------------------------- /dist/w3c-html-validator.d.ts: -------------------------------------------------------------------------------- 1 | //! w3c-html-validator v1.8.3 ~~ https://github.com/center-key/w3c-html-validator ~~ MIT License 2 | 3 | export type ValidatorSettings = { 4 | html: string; 5 | filename: string; 6 | website: string; 7 | checkUrl: string; 8 | ignoreLevel: 'info' | 'warning'; 9 | ignoreMessages: (string | RegExp)[]; 10 | output: ValidatorResultsOutput; 11 | dryRun: boolean; 12 | }; 13 | export type ValidatorResultsMessage = { 14 | type: 'info' | 'error' | 'non-document-error' | 'network-error'; 15 | subType?: 'warning' | 'fatal' | 'io' | 'schema' | 'internal'; 16 | message: string; 17 | extract?: string; 18 | lastLine: number; 19 | firstColumn: number; 20 | lastColumn: number; 21 | hiliteStart: number; 22 | hiliteLength: number; 23 | }; 24 | export type ValidatorResultsMessageType = ValidatorResultsMessage['type']; 25 | export type ValidatorResultsMessageSubType = ValidatorResultsMessage['subType']; 26 | export type ValidatorResults = { 27 | validates: boolean; 28 | mode: 'html' | 'filename' | 'website'; 29 | title: string; 30 | html: string | null; 31 | filename: string | null; 32 | website: string | null; 33 | output: 'json' | 'html'; 34 | status: number; 35 | messages: ValidatorResultsMessage[] | null; 36 | display: string | null; 37 | dryRun: boolean; 38 | }; 39 | export type ValidatorResultsOutput = ValidatorResults['output']; 40 | export type ReporterSettings = { 41 | continueOnFail: boolean; 42 | maxMessageLen: number | null; 43 | quiet: boolean; 44 | title: string | null; 45 | }; 46 | declare const w3cHtmlValidator: { 47 | version: string; 48 | validate(options: Partial): Promise; 49 | dryRunNotice(): void; 50 | summary(numFiles: number): void; 51 | reporter(results: ValidatorResults, options?: Partial): ValidatorResults; 52 | }; 53 | export { w3cHtmlValidator }; 54 | -------------------------------------------------------------------------------- /dist/w3c-html-validator.js: -------------------------------------------------------------------------------- 1 | //! w3c-html-validator v1.8.3 ~~ https://github.com/center-key/w3c-html-validator ~~ MIT License 2 | 3 | import chalk from 'chalk'; 4 | import fs from 'fs'; 5 | import log from 'fancy-log'; 6 | import request from 'superagent'; 7 | import slash from 'slash'; 8 | const w3cHtmlValidator = { 9 | version: '1.8.3', 10 | validate(options) { 11 | const defaults = { 12 | checkUrl: 'https://validator.w3.org/nu/', 13 | dryRun: false, 14 | ignoreLevel: null, 15 | ignoreMessages: [], 16 | output: 'json', 17 | }; 18 | const settings = { ...defaults, ...options }; 19 | if (!settings.html && !settings.filename && !settings.website) 20 | throw new Error('[w3c-html-validator] Must specify the "html", "filename", or "website" option.'); 21 | if (![null, 'info', 'warning'].includes(settings.ignoreLevel)) 22 | throw new Error(`[w3c-html-validator] Invalid ignoreLevel option: ${settings.ignoreLevel}`); 23 | if (settings.output !== 'json' && settings.output !== 'html') 24 | throw new Error('[w3c-html-validator] Option "output" must be "json" or "html".'); 25 | const filename = settings.filename ? slash(settings.filename) : null; 26 | const mode = settings.html ? 'html' : filename ? 'filename' : 'website'; 27 | const readFile = (filename) => fs.readFileSync(filename, 'utf-8').replace(/\r/g, ''); 28 | const inputHtml = settings.html ?? (filename ? readFile(filename) : null); 29 | const makePostRequest = () => request.post(settings.checkUrl) 30 | .set('Content-Type', 'text/html; encoding=utf-8') 31 | .send(inputHtml); 32 | const makeGetRequest = () => request.get(settings.checkUrl) 33 | .query({ doc: settings.website }); 34 | const w3cRequest = inputHtml ? makePostRequest() : makeGetRequest(); 35 | w3cRequest.set('User-Agent', 'W3C HTML Validator ~ github.com/center-key/w3c-html-validator'); 36 | w3cRequest.query({ out: settings.output }); 37 | const json = settings.output === 'json'; 38 | const success = '

'; 39 | const titleLookup = { 40 | html: `HTML String (characters: ${inputHtml?.length})`, 41 | filename: filename, 42 | website: settings.website, 43 | }; 44 | const filterMessages = (response) => { 45 | const aboveInfo = (subType) => settings.ignoreLevel === 'info' && !!subType; 46 | const aboveIgnoreLevel = (message) => !settings.ignoreLevel || message.type !== 'info' || aboveInfo(message.subType); 47 | const matchesSkipPattern = (title) => settings.ignoreMessages.some(pattern => typeof pattern === 'string' ? title.includes(pattern) : pattern.test(title)); 48 | const isImportant = (message) => aboveIgnoreLevel(message) && !matchesSkipPattern(message.message); 49 | if (json) 50 | response.body.messages = response.body.messages?.filter(isImportant) ?? []; 51 | return response; 52 | }; 53 | const toValidatorResults = (response) => ({ 54 | validates: json ? !response.body.messages.length : !!response.text?.includes(success), 55 | mode: mode, 56 | title: titleLookup[mode], 57 | html: inputHtml, 58 | filename: filename, 59 | website: settings.website || null, 60 | output: settings.output, 61 | status: response.statusCode || -1, 62 | messages: json ? response.body.messages : null, 63 | display: json ? null : response.text, 64 | dryRun: settings.dryRun, 65 | }); 66 | const handleError = (reason) => { 67 | const errRes = reason.response ?? {}; 68 | const getMsg = () => [errRes.status, errRes.res.statusMessage, errRes.request.url]; 69 | const message = reason.response ? getMsg() : [reason.errno, reason.message]; 70 | errRes.body = { messages: [{ type: 'network-error', message: message.join(' ') }] }; 71 | return toValidatorResults(errRes); 72 | }; 73 | const pseudoResponse = { 74 | statusCode: 200, 75 | body: { messages: [] }, 76 | text: 'Validation bypassed.', 77 | }; 78 | const pseudoRequest = () => new Promise(resolve => resolve(pseudoResponse)); 79 | const validation = settings.dryRun ? pseudoRequest() : w3cRequest; 80 | return validation.then(filterMessages).then(toValidatorResults).catch(handleError); 81 | }, 82 | dryRunNotice() { 83 | log(chalk.gray('w3c-html-validator'), chalk.yellowBright('dry run mode:'), chalk.whiteBright('validation being bypassed')); 84 | }, 85 | summary(numFiles) { 86 | log(chalk.gray('w3c-html-validator'), chalk.magenta('files: ' + String(numFiles))); 87 | }, 88 | reporter(results, options) { 89 | const defaults = { 90 | continueOnFail: false, 91 | maxMessageLen: null, 92 | quiet: false, 93 | title: null, 94 | }; 95 | const settings = { ...defaults, ...options }; 96 | if (typeof results?.validates !== 'boolean') 97 | throw new Error('[w3c-html-validator] Invalid results for reporter(): ' + String(results)); 98 | const messages = results.messages ?? []; 99 | const title = settings.title ?? results.title; 100 | const status = results.validates ? chalk.green.bold('✔ pass') : chalk.red.bold('✘ fail'); 101 | const count = results.validates ? '' : `(messages: ${messages.length})`; 102 | if (!results.validates || !settings.quiet) 103 | log(chalk.gray('w3c-html-validator'), status, chalk.blue.bold(title), chalk.white(count)); 104 | const typeColorMap = { 105 | error: chalk.red.bold, 106 | warning: chalk.yellow.bold, 107 | info: chalk.white.bold, 108 | }; 109 | const logMessage = (message) => { 110 | const type = message.subType ?? message.type; 111 | const typeColor = typeColorMap[type] ?? chalk.redBright.bold; 112 | const location = `line ${message.lastLine}, column ${message.firstColumn}:`; 113 | const lineText = message.extract?.replace(/\n/g, '\\n'); 114 | const maxLen = settings.maxMessageLen ?? undefined; 115 | log(typeColor('HTML ' + type + ':'), message.message.substring(0, maxLen)); 116 | if (message.lastLine) 117 | log(chalk.white(location), chalk.magenta(lineText)); 118 | }; 119 | messages.forEach(logMessage); 120 | const failDetails = () => { 121 | const toString = (message) => `${message.subType ?? message.type} line ${message.lastLine} column ${message.firstColumn}`; 122 | const fileDetails = () => `${results.filename} -- ${results.messages.map(toString).join(', ')}`; 123 | return !results.filename ? results.messages[0].message : fileDetails(); 124 | }; 125 | if (!settings.continueOnFail && !results.validates) 126 | throw new Error('[w3c-html-validator] Failed: ' + failDetails()); 127 | return results; 128 | }, 129 | }; 130 | export { w3cHtmlValidator }; 131 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default [ 7 | eslint.configs.recommended, 8 | ...tseslint.configs.strictTypeChecked, 9 | { ignores: ['**/*.js'] }, 10 | { 11 | languageOptions: { parserOptions: { projectService: true } }, 12 | rules: { 13 | '@typescript-eslint/no-confusing-void-expression': 'off', //prefer minimal arrow functions 14 | '@typescript-eslint/no-floating-promises': 'off', //annimations may be fire-and-forget 15 | '@typescript-eslint/no-misused-promises': 'off', //annimations may be fire-and-forget 16 | '@typescript-eslint/no-non-null-assertion': 'off', //ts cannot always know value exists 17 | '@typescript-eslint/restrict-template-expressions': 'off', //numbers in templates are natural 18 | '@typescript-eslint/unbound-method': 'off', //safer to not use 'this' 19 | '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', //clarity over theoretical exceptions 20 | }, 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /examples.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | //////////////////////// 3 | // W3C HTML Validator // 4 | // Examples // 5 | //////////////////////// 6 | 7 | // Command to run: 8 | // $ node examples.js 9 | 10 | import { w3cHtmlValidator } from './dist/w3c-html-validator.js'; 11 | 12 | // Formatted output 13 | const options = { continueOnFail: true, maxMessageLen: 80 }; 14 | const customReporter = (results) => w3cHtmlValidator.reporter(results, options); 15 | w3cHtmlValidator.validate({ website: 'https://pretty-print-json.js.org/' }).then(w3cHtmlValidator.reporter); 16 | w3cHtmlValidator.validate({ filename: 'spec/html/valid.html' }).then(w3cHtmlValidator.reporter); 17 | w3cHtmlValidator.validate({ filename: 'spec/html/invalid.html' }).then(customReporter); 18 | 19 | // JSON output 20 | const sleep = (data) => new Promise(resolve => setTimeout(() => resolve(data), 2000)); 21 | const log = (results) => console.log('\nValidatorResults:', results); 22 | w3cHtmlValidator.validate({ filename: 'spec/html/invalid.html' }).then(sleep).then(log); 23 | -------------------------------------------------------------------------------- /examples.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/center-key/w3c-html-validator/9d0f1f1f4769f659b30677f2c655de88d056cfa4/examples.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "w3c-html-validator", 3 | "version": "1.8.3", 4 | "description": "Check the markup validity of HTML files using the W3C validator", 5 | "license": "MIT", 6 | "type": "module", 7 | "module": "dist/w3c-html-validator.js", 8 | "types": "dist/w3c-html-validator.d.ts", 9 | "exports": "./dist/w3c-html-validator.js", 10 | "files": [ 11 | "dist" 12 | ], 13 | "bin": { 14 | "html-validator": "bin/cli.js", 15 | "w3c-html-validator": "bin/cli.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/center-key/w3c-html-validator.git" 20 | }, 21 | "homepage": "https://github.com/center-key/w3c-html-validator", 22 | "bugs": "https://github.com/center-key/w3c-html-validator/issues", 23 | "docs": "https://github.com/center-key/w3c-html-validator#readme", 24 | "author": "Thomas Davis ", 25 | "keywords": [ 26 | "html", 27 | "html5", 28 | "validator", 29 | "w3c" 30 | ], 31 | "jshintConfig": { 32 | "esversion": 11, 33 | "strict": "implied", 34 | "eqeqeq": true, 35 | "undef": true, 36 | "unused": true, 37 | "varstmt": true, 38 | "node": true, 39 | "mocha": true 40 | }, 41 | "runScriptsConfig": { 42 | "clean": [ 43 | "rimraf build dist" 44 | ], 45 | "lint": [ 46 | "jshint . --exclude-path .gitignore", 47 | "eslint --max-warnings 0" 48 | ], 49 | "build": [ 50 | "tsc", 51 | "add-dist-header build dist" 52 | ] 53 | }, 54 | "scripts": { 55 | "pretest": "run-scripts clean lint build", 56 | "test": "mocha spec/*.spec.js --timeout 7000", 57 | "examples": "node examples.js" 58 | }, 59 | "dependencies": { 60 | "chalk": "~5.4", 61 | "cli-argv-util": "~1.2", 62 | "fancy-log": "~2.0", 63 | "glob": "~11.0", 64 | "slash": "~5.1", 65 | "superagent": "~10.1" 66 | }, 67 | "devDependencies": { 68 | "@eslint/js": "~9.21", 69 | "@types/fancy-log": "~2.0", 70 | "@types/node": "~22.13", 71 | "@types/superagent": "~8.1", 72 | "add-dist-header": "~1.4", 73 | "assert-deep-strict-equal": "~1.2", 74 | "copy-file-util": "~1.2", 75 | "copy-folder-util": "~1.1", 76 | "eslint": "~9.21", 77 | "jshint": "~2.13", 78 | "merge-stream": "~2.0", 79 | "mocha": "~11.1", 80 | "rimraf": "~6.0", 81 | "run-scripts-util": "~1.3", 82 | "typescript": "~5.8", 83 | "typescript-eslint": "~8.26" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /spec/html/invalid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Invalid HTML 6 | 7 | 8 |

Specification Page

9 |
10 |

A section without a heading

11 |
12 |
Inside Out
13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/html/valid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Valid HTML 6 | 7 | 8 |

Specification Page

9 | 10 | 11 | -------------------------------------------------------------------------------- /spec/ignore-config.txt: -------------------------------------------------------------------------------- 1 | # Ignore Config for w3c-html-validator 2 | 3 | /^Duplicate ID/ 4 | /^Element .blockquote. not allowed/ 5 | /^Element .style. not allowed/ 6 | -------------------------------------------------------------------------------- /spec/mocha.spec.js: -------------------------------------------------------------------------------- 1 | // W3C HTML Validator 2 | // Mocha Specification Suite 3 | 4 | // Imports 5 | import { assertDeepStrictEqual } from 'assert-deep-strict-equal'; 6 | import { cliArgvUtil } from 'cli-argv-util'; 7 | import assert from 'assert'; 8 | import fs from 'fs'; 9 | 10 | // Setup 11 | import { w3cHtmlValidator } from '../dist/w3c-html-validator.js'; 12 | const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); 13 | const validHtml = fs.readFileSync('spec/html/valid.html', 'utf-8').replace(/\r/g, ''); 14 | const invalidHtml = fs.readFileSync('spec/html/invalid.html', 'utf-8').replace(/\r/g, ''); 15 | 16 | //////////////////////////////////////////////////////////////////////////////// 17 | describe('The "dist" folder', () => { 18 | 19 | it('contains the correct files', () => { 20 | const actual = fs.readdirSync('dist').sort(); 21 | const expected = [ 22 | 'w3c-html-validator.d.ts', 23 | 'w3c-html-validator.js', 24 | ]; 25 | assertDeepStrictEqual(actual, expected); 26 | }); 27 | 28 | }); 29 | 30 | //////////////////////////////////////////////////////////////////////////////// 31 | describe('Library version number', () => { 32 | 33 | it('follows semantic version formatting', () => { 34 | const data = w3cHtmlValidator.version; 35 | const semVerPattern = /\d+[.]\d+[.]\d+/; 36 | const actual = { version: data, valid: semVerPattern.test(data) }; 37 | const expected = { version: data, valid: true }; 38 | assertDeepStrictEqual(actual, expected); 39 | }); 40 | 41 | }); 42 | 43 | //////////////////////////////////////////////////////////////////////////////// 44 | describe('Library module', () => { 45 | 46 | it('is an object', () => { 47 | const actual = { constructor: w3cHtmlValidator.constructor.name }; 48 | const expected = { constructor: 'Object' }; 49 | assertDeepStrictEqual(actual, expected); 50 | }); 51 | 52 | it('has functions named validate(), dryRunNotice() summary(), and reporter()', () => { 53 | const module = w3cHtmlValidator; 54 | const actual = Object.keys(module).sort().map(key => [key, typeof module[key]]); 55 | const expected = [ 56 | ['dryRunNotice', 'function'], 57 | ['reporter', 'function'], 58 | ['summary', 'function'], 59 | ['validate', 'function'], 60 | ['version', 'string'], 61 | ]; 62 | assertDeepStrictEqual(actual, expected); 63 | }); 64 | 65 | }); 66 | 67 | //////////////////////////////////////////////////////////////////////////////// 68 | describe('Pretty-Print JSON website', () => { 69 | 70 | it('validates', (done) => { 71 | const handleData = (data) => { 72 | const actual = data; 73 | const expected = { 74 | validates: true, 75 | mode: 'website', 76 | title: 'https://pretty-print-json.js.org/', 77 | html: null, 78 | filename: null, 79 | website: 'https://pretty-print-json.js.org/', 80 | output: 'json', 81 | status: 200, 82 | messages: [], 83 | display: null, 84 | dryRun: false, 85 | }; 86 | assertDeepStrictEqual(actual, expected, done); 87 | }; 88 | w3cHtmlValidator.validate({ website: 'https://pretty-print-json.js.org/' }).then(handleData); 89 | }); 90 | 91 | }); 92 | 93 | //////////////////////////////////////////////////////////////////////////////// 94 | describe('Valid HTML string', () => { 95 | 96 | it('passes validator with JSON output', (done) => { 97 | const handleData = (data) => { 98 | const actual = data; 99 | const expected = { 100 | validates: true, 101 | mode: 'html', 102 | title: 'HTML String (characters: 153)', 103 | html: validHtml, 104 | filename: null, 105 | website: null, 106 | output: 'json', 107 | status: 200, 108 | messages: [], 109 | display: null, 110 | dryRun: false, 111 | }; 112 | assertDeepStrictEqual(actual, expected, done); 113 | }; 114 | w3cHtmlValidator.validate({ html: validHtml, output: 'json' }).then(handleData); 115 | }); 116 | 117 | it('passes validator with HTML output', (done) => { 118 | const handleData = (data) => { 119 | const actual = data; 120 | delete actual.display; 121 | const expected = { 122 | validates: true, 123 | mode: 'html', 124 | title: 'HTML String (characters: 153)', 125 | html: validHtml, 126 | filename: null, 127 | website: null, 128 | output: 'html', 129 | status: 200, 130 | messages: null, 131 | dryRun: false, 132 | }; 133 | assertDeepStrictEqual(actual, expected, done); 134 | }; 135 | w3cHtmlValidator.validate({ html: validHtml, output: 'html' }).then(handleData); 136 | }); 137 | 138 | }); 139 | 140 | //////////////////////////////////////////////////////////////////////////////// 141 | describe('Invalid HTML string', () => { 142 | 143 | it('fails validator with JSON output', (done) => { 144 | const message = { 145 | heading: 'Section lacks heading. Consider using “h2”-“h6” elements to add identifying headings to all sections, or else use a “div” element instead for any cases where no heading is needed.', 146 | child: 'Element “blockquote” not allowed as child of element “span” in this context. (Suppressing further errors from this subtree.)', 147 | }; 148 | const handleData = (data) => { 149 | const actual = data; 150 | const expected = { 151 | validates: false, 152 | mode: 'html', 153 | title: 'HTML String (characters: 275)', 154 | html: invalidHtml, 155 | filename: null, 156 | website: null, 157 | output: 'json', 158 | status: 200, 159 | messages: [ 160 | { 161 | type: 'info', 162 | subType: 'warning', 163 | message: message.heading, 164 | extract: 'e\n
\n ', 165 | lastLine: 9, 166 | firstColumn: 4, 167 | lastColumn: 12, 168 | hiliteStart: 10, 169 | hiliteLength: 9, 170 | }, 171 | { 172 | type: 'error', 173 | message: message.child, 174 | extract: '\n
Inside', 175 | lastLine: 12, 176 | firstColumn: 10, 177 | lastColumn: 21, 178 | hiliteStart: 10, 179 | hiliteLength: 12, 180 | }, 181 | ], 182 | display: null, 183 | dryRun: false, 184 | }; 185 | assertDeepStrictEqual(actual, expected, done); 186 | }; 187 | w3cHtmlValidator.validate({ html: invalidHtml, output: 'json' }).then(handleData); 188 | }); 189 | 190 | it('fails validator with HTML output', (done) => { 191 | const handleData = (data) => { 192 | const actual = data; 193 | delete actual.display; 194 | const expected = { 195 | validates: false, 196 | mode: 'html', 197 | title: 'HTML String (characters: 275)', 198 | html: invalidHtml, 199 | filename: null, 200 | website: null, 201 | output: 'html', 202 | status: 200, 203 | messages: null, 204 | dryRun: false, 205 | }; 206 | assertDeepStrictEqual(actual, expected, done); 207 | }; 208 | w3cHtmlValidator.validate({ html: invalidHtml, output: 'html' }).then(handleData); 209 | }); 210 | 211 | }); 212 | 213 | //////////////////////////////////////////////////////////////////////////////// 214 | describe('HTML file', () => { 215 | 216 | it('that is valid passes validation', (done) => { 217 | const handleData = (data) => { 218 | const actual = data; 219 | const expected = { 220 | validates: true, 221 | mode: 'filename', 222 | title: 'spec/html/valid.html', 223 | html: validHtml, 224 | filename: 'spec/html/valid.html', 225 | website: null, 226 | output: 'json', 227 | status: 200, 228 | messages: [], 229 | display: null, 230 | dryRun: false, 231 | }; 232 | assertDeepStrictEqual(actual, expected, done); 233 | }; 234 | w3cHtmlValidator.validate({ filename: 'spec/html/valid.html' }).then(handleData); 235 | }); 236 | 237 | it('that is invalid fails validation', (done) => { 238 | const handleData = (data) => { 239 | const actual = { validates: data.validates }; 240 | const expected = { validates: false }; 241 | assertDeepStrictEqual(actual, expected, done); 242 | }; 243 | w3cHtmlValidator.validate({ filename: 'spec/html/invalid.html' }).then(handleData); 244 | }); 245 | 246 | }); 247 | 248 | //////////////////////////////////////////////////////////////////////////////// 249 | describe('Option ignoreLevel set to "warning"', () => { 250 | 251 | it('skips warning messages', (done) => { 252 | const handleData = (data) => { 253 | const actual = { 254 | validates: data.validates, 255 | messages: data.messages.map(message => message.type), 256 | }; 257 | const expected = { 258 | validates: false, 259 | messages: ['error'], 260 | }; 261 | assertDeepStrictEqual(actual, expected, done); 262 | }; 263 | const options = { filename: 'spec/html/invalid.html', ignoreLevel: 'warning' }; 264 | w3cHtmlValidator.validate(options).then(handleData); 265 | }); 266 | 267 | }); 268 | 269 | //////////////////////////////////////////////////////////////////////////////// 270 | describe('Option ignoreMessages', () => { 271 | // Example validation messgaes: 272 | // warning: 'Section lacks heading. Consider using “h2”-“h6” elements to add identifying headings to all sections.', 273 | // error: 'Element “blockquote” not allowed as child of element “span” in this context. (Suppressing further errors from this subtree.)', 274 | 275 | it('as a substring can skip "Section lacks heading" messages', (done) => { 276 | const handleData = (data) => { 277 | const actual = { 278 | validates: data.validates, 279 | messages: data.messages.map(message => message.type), 280 | }; 281 | const expected = { 282 | validates: false, 283 | messages: ['error'], 284 | }; 285 | assertDeepStrictEqual(actual, expected, done); 286 | }; 287 | const options = { 288 | filename: 'spec/html/invalid.html', 289 | ignoreMessages: ['Section lacks heading'], 290 | }; 291 | w3cHtmlValidator.validate(options).then(handleData); 292 | }); 293 | 294 | it('can skip messages matching a regular expression', (done) => { 295 | const handleData = (data) => { 296 | const actual = { 297 | validates: data.validates, 298 | messages: data.messages.map(message => message.type), 299 | }; 300 | const expected = { 301 | validates: false, 302 | messages: ['info'], 303 | }; 304 | assertDeepStrictEqual(actual, expected, done); 305 | }; 306 | const options = { 307 | filename: 'spec/html/invalid.html', 308 | ignoreMessages: [/^Element .blockquote. not allowed/], 309 | }; 310 | w3cHtmlValidator.validate(options).then(handleData); 311 | }); 312 | 313 | }); 314 | 315 | //////////////////////////////////////////////////////////////////////////////// 316 | describe('Correct error is thrown', () => { 317 | 318 | it('when no input is specified', () => { 319 | const options = {}; 320 | const makeInvalidCall = () => w3cHtmlValidator.validate(options); 321 | const exception = { message: '[w3c-html-validator] Must specify the "html", "filename", or "website" option.' }; 322 | assert.throws(makeInvalidCall, exception); 323 | }); 324 | 325 | it('when "ignoreLevel" option is bogus', () => { 326 | const options = { html: validHtml, ignoreLevel: 'bogus' }; 327 | const makeInvalidCall = () => w3cHtmlValidator.validate(options); 328 | const exception = { message: '[w3c-html-validator] Invalid ignoreLevel option: bogus' }; 329 | assert.throws(makeInvalidCall, exception); 330 | }); 331 | 332 | it('when "output" option is bogus', () => { 333 | const options = { html: validHtml, output: 'bogus' }; 334 | const makeInvalidCall = () => w3cHtmlValidator.validate(options); 335 | const exception = { message: '[w3c-html-validator] Option "output" must be "json" or "html".' }; 336 | assert.throws(makeInvalidCall, exception); 337 | }); 338 | 339 | }); 340 | 341 | //////////////////////////////////////////////////////////////////////////////// 342 | describe('Network request failure', () => { 343 | 344 | it('for service unavailable (HTTP status 503) is handled gracefully', (done) => { 345 | const handleData = (data) => { 346 | const actual = data; 347 | const expected = { 348 | validates: false, 349 | mode: 'html', 350 | title: 'HTML String (characters: 153)', 351 | html: validHtml, 352 | filename: null, 353 | website: null, 354 | output: 'json', 355 | status: 503, 356 | messages: [{ 357 | type: 'network-error', 358 | message: '503 Service Unavailable https://centerkey.com/rest/status/503/?out=json', 359 | }], 360 | display: null, 361 | dryRun: false, 362 | }; 363 | assertDeepStrictEqual(actual, expected, done); 364 | }; 365 | const options = { 366 | html: validHtml, 367 | checkUrl: 'https://centerkey.com/rest/status/503/', 368 | output: 'json', 369 | }; 370 | w3cHtmlValidator.validate(options).then(handleData); 371 | }); 372 | 373 | }); 374 | 375 | //////////////////////////////////////////////////////////////////////////////// 376 | describe('The reporter() function', () => { 377 | 378 | it('passes through valid results', (done) => { 379 | const handleData = (data) => { 380 | const actual = data; 381 | const expected = { 382 | validates: true, 383 | mode: 'filename', 384 | title: 'spec/html/valid.html', 385 | html: validHtml, 386 | filename: 'spec/html/valid.html', 387 | website: null, 388 | output: 'json', 389 | status: 200, 390 | messages: [], 391 | display: null, 392 | dryRun: false, 393 | }; 394 | assertDeepStrictEqual(actual, expected, done); 395 | }; 396 | w3cHtmlValidator.validate({ filename: 'spec/html/valid.html' }) 397 | .then(w3cHtmlValidator.reporter) 398 | .then(handleData); 399 | }); 400 | 401 | it('throws the correct error when validation fails', () => { 402 | const options = { filename: 'spec/html/invalid.html' }; 403 | const fail = () => w3cHtmlValidator.validate(options).then(w3cHtmlValidator.reporter); 404 | const expected = { 405 | name: 'Error', 406 | message: '[w3c-html-validator] Failed: spec/html/invalid.html -- warning line 9 column 4, error line 12 column 10', 407 | }; 408 | return assert.rejects(fail, expected); 409 | }); 410 | 411 | }); 412 | 413 | //////////////////////////////////////////////////////////////////////////////// 414 | describe('Executing the CLI', () => { 415 | const run = (posix) => cliArgvUtil.run(pkg, posix); 416 | 417 | it('to check a valid HTML file correctly outputs a "pass" message', () => { 418 | const actual = run('html-validator spec/html/valid.html --note=cli'); 419 | const expected = null; 420 | assertDeepStrictEqual(actual, expected); 421 | }); 422 | 423 | it('with a glob selects the correct files to validate', () => { 424 | const actual = run('html-validator "spec/**/valid.html" --note=glob'); 425 | const expected = null; 426 | assertDeepStrictEqual(actual, expected); 427 | }); 428 | 429 | it('skips validation message matching --ignore and --ignore-config regex patterns', () => { 430 | const actual = run('html-validator spec/html "--ignore=/^Section lacks heading/" --ignore-config=spec/ignore-config.txt'); 431 | const expected = null; 432 | assertDeepStrictEqual(actual, expected); 433 | }); 434 | 435 | }); 436 | -------------------------------------------------------------------------------- /src/w3c-html-validator.ts: -------------------------------------------------------------------------------- 1 | // W3C HTML Validator ~ MIT License 2 | 3 | // Imports 4 | import chalk, { ChalkInstance } from 'chalk'; 5 | import fs from 'fs'; 6 | import log from 'fancy-log'; 7 | import request from 'superagent'; 8 | import slash from 'slash'; 9 | 10 | // Type Declarations 11 | export type ValidatorSettings = { 12 | html: string, //example: 'Home'' 13 | filename: string, //example: 'docs/index.html' 14 | website: string, //example: 'https://pretty-print-json.js.org/' 15 | checkUrl: string, //default: 'https://validator.w3.org/nu/' 16 | ignoreLevel: 'info' | 'warning', //skip unwanted validation messages ('warning' also skips 'info') 17 | ignoreMessages: (string | RegExp)[], //patterns to skip unwanted validation messages 18 | output: ValidatorResultsOutput, //'json' or 'html' 19 | dryRun: boolean, //bypass validation (for usage while building your CI) 20 | }; 21 | export type ValidatorResultsMessage = { 22 | // type subType 23 | // -------------------- -------------------------------------------------- 24 | // 'info' 'warning' | undefined (informative) 25 | // 'error' 'fatal' | undefined (spec violation) 26 | // 'non-document-error' 'io' | 'schema' | 'internal' | undefined (external) 27 | // 'network-error' undefined (network request failure) 28 | type: 'info' | 'error' | 'non-document-error' | 'network-error', 29 | subType?: 'warning' | 'fatal' | 'io' | 'schema' | 'internal', 30 | message: string, //example: 'Section lacks heading.' 31 | extract?: string, //example: '
Hi
' 32 | lastLine: number, 33 | firstColumn: number, 34 | lastColumn: number, 35 | hiliteStart: number, 36 | hiliteLength: number, 37 | }; 38 | export type ValidatorResultsMessageType = ValidatorResultsMessage['type']; 39 | export type ValidatorResultsMessageSubType = ValidatorResultsMessage['subType']; 40 | export type ValidatorResults = { 41 | validates: boolean, 42 | mode: 'html' | 'filename' | 'website', 43 | title: string, 44 | html: string | null, 45 | filename: string | null, 46 | website: string | null, 47 | output: 'json' | 'html', 48 | status: number, 49 | messages: ValidatorResultsMessage[] | null, 50 | display: string | null, 51 | dryRun: boolean, 52 | }; 53 | export type ValidatorResultsOutput = ValidatorResults['output']; 54 | export type ReporterSettings = { 55 | continueOnFail: boolean, //report messages but do not throw an error if validation failed 56 | maxMessageLen: number | null, //trim validation messages to not exceed a maximum length 57 | quiet: boolean, //suppress messages for successful validations 58 | title: string | null, //override display title (useful for naming HTML string inputs) 59 | }; 60 | 61 | // W3C HTML Validator 62 | const w3cHtmlValidator = { 63 | 64 | version: '{{package.version}}', 65 | 66 | validate(options: Partial): Promise { 67 | const defaults = { 68 | checkUrl: 'https://validator.w3.org/nu/', 69 | dryRun: false, 70 | ignoreLevel: null, 71 | ignoreMessages: [], 72 | output: 'json', 73 | }; 74 | const settings = { ...defaults, ...options }; 75 | if (!settings.html && !settings.filename && !settings.website) 76 | throw new Error('[w3c-html-validator] Must specify the "html", "filename", or "website" option.'); 77 | if (![null, 'info', 'warning'].includes(settings.ignoreLevel)) 78 | throw new Error(`[w3c-html-validator] Invalid ignoreLevel option: ${settings.ignoreLevel}`); 79 | if (settings.output !== 'json' && settings.output !== 'html') 80 | throw new Error('[w3c-html-validator] Option "output" must be "json" or "html".'); 81 | const filename = settings.filename ? slash(settings.filename) : null; 82 | const mode = settings.html ? 'html' : filename ? 'filename' : 'website'; 83 | const readFile = (filename: string) => fs.readFileSync(filename, 'utf-8').replace(/\r/g, ''); 84 | const inputHtml = settings.html ?? (filename ? readFile(filename) : null); 85 | const makePostRequest = () => request.post(settings.checkUrl) 86 | .set('Content-Type', 'text/html; encoding=utf-8') 87 | .send(inputHtml); 88 | const makeGetRequest = () => request.get(settings.checkUrl) 89 | .query({ doc: settings.website }); 90 | const w3cRequest = inputHtml ? makePostRequest() : makeGetRequest(); 91 | w3cRequest.set('User-Agent', 'W3C HTML Validator ~ github.com/center-key/w3c-html-validator'); 92 | w3cRequest.query({ out: settings.output }); 93 | const json = settings.output === 'json'; 94 | const success = '

'; 95 | const titleLookup = { 96 | html: `HTML String (characters: ${inputHtml?.length})`, 97 | filename: filename, 98 | website: settings.website, 99 | }; 100 | const filterMessages = (response: request.Response): request.Response => { 101 | const aboveInfo = (subType: ValidatorResultsMessageSubType): boolean => 102 | settings.ignoreLevel === 'info' && !!subType; 103 | const aboveIgnoreLevel = (message: ValidatorResultsMessage): boolean => 104 | !settings.ignoreLevel || message.type !== 'info' || aboveInfo(message.subType); 105 | const matchesSkipPattern = (title: string): boolean => 106 | settings.ignoreMessages.some(pattern => 107 | typeof pattern === 'string' ? title.includes(pattern) : pattern.test(title)); 108 | const isImportant = (message: ValidatorResultsMessage): boolean => 109 | aboveIgnoreLevel(message) && !matchesSkipPattern(message.message); 110 | if (json) 111 | response.body.messages = response.body.messages?.filter(isImportant) ?? []; //eslint-disable-line 112 | return response; 113 | }; 114 | const toValidatorResults = (response: request.Response): ValidatorResults => ({ 115 | validates: json ? !response.body.messages.length : !!response.text?.includes(success), //eslint-disable-line 116 | mode: mode, 117 | title: titleLookup[mode], 118 | html: inputHtml, 119 | filename: filename, 120 | website: settings.website || null, 121 | output: settings.output, 122 | status: response.statusCode || -1, 123 | messages: json ? response.body.messages : null, //eslint-disable-line 124 | display: json ? null : response.text, 125 | dryRun: settings.dryRun, 126 | }); 127 | type ReasonResponse = { request: { url: string }, res: { statusMessage: string }}; 128 | type ReasonError = Error & { errno: number, response: request.Response & ReasonResponse }; 129 | const handleError = (reason: ReasonError): ValidatorResults => { 130 | const errRes = reason.response ?? {}; //eslint-disable-line 131 | const getMsg = () => [errRes.status, errRes.res.statusMessage, errRes.request.url]; 132 | const message = reason.response ? getMsg() : [reason.errno, reason.message]; //eslint-disable-line 133 | errRes.body = { messages: [{ type: 'network-error', message: message.join(' ') }] }; 134 | return toValidatorResults(errRes); 135 | }; 136 | const pseudoResponse = { 137 | statusCode: 200, 138 | body: { messages: [] }, 139 | text: 'Validation bypassed.', 140 | }; 141 | const pseudoRequest = (): Promise => 142 | new Promise(resolve => resolve(pseudoResponse)); 143 | const validation = settings.dryRun ? pseudoRequest() : w3cRequest; 144 | return validation.then(filterMessages).then(toValidatorResults).catch(handleError); 145 | }, 146 | 147 | dryRunNotice() { 148 | log(chalk.gray('w3c-html-validator'), 149 | chalk.yellowBright('dry run mode:'), chalk.whiteBright('validation being bypassed')); 150 | }, 151 | 152 | summary(numFiles: number) { 153 | log(chalk.gray('w3c-html-validator'), chalk.magenta('files: ' + String(numFiles))); 154 | }, 155 | 156 | reporter(results: ValidatorResults, options?: Partial): ValidatorResults { 157 | const defaults = { 158 | continueOnFail: false, 159 | maxMessageLen: null, 160 | quiet: false, 161 | title: null, 162 | }; 163 | const settings = { ...defaults, ...options }; 164 | if (typeof results?.validates !== 'boolean') //eslint-disable-line 165 | throw new Error('[w3c-html-validator] Invalid results for reporter(): ' + String(results)); 166 | const messages = results.messages ?? []; 167 | const title = settings.title ?? results.title; 168 | const status = results.validates ? chalk.green.bold('✔ pass') : chalk.red.bold('✘ fail'); 169 | const count = results.validates ? '' : `(messages: ${messages.length})`; 170 | if (!results.validates || !settings.quiet) 171 | log(chalk.gray('w3c-html-validator'), status, chalk.blue.bold(title), chalk.white(count)); 172 | const typeColorMap = <{ [messageType: string]: ChalkInstance }>{ 173 | error: chalk.red.bold, 174 | warning: chalk.yellow.bold, 175 | info: chalk.white.bold, 176 | }; 177 | const logMessage = (message: ValidatorResultsMessage) => { 178 | const type = message.subType ?? message.type; 179 | const typeColor = typeColorMap[type] ?? chalk.redBright.bold; 180 | const location = `line ${message.lastLine}, column ${message.firstColumn}:`; 181 | const lineText = message.extract?.replace(/\n/g, '\\n'); 182 | const maxLen = settings.maxMessageLen ?? undefined; 183 | log(typeColor('HTML ' + type + ':'), message.message.substring(0, maxLen)); 184 | if (message.lastLine) 185 | log(chalk.white(location), chalk.magenta(lineText)); 186 | }; 187 | messages.forEach(logMessage); 188 | const failDetails = () => { 189 | // Example: 'spec/html/invalid.html -- warning line 9 column 4, error line 12 column 10' 190 | const toString = (message: ValidatorResultsMessage) => 191 | `${message.subType ?? message.type} line ${message.lastLine} column ${message.firstColumn}`; 192 | const fileDetails = () => 193 | `${results.filename} -- ${results.messages!.map(toString).join(', ')}`; 194 | return !results.filename ? results.messages![0]!.message : fileDetails(); 195 | }; 196 | if (!settings.continueOnFail && !results.validates) 197 | throw new Error('[w3c-html-validator] Failed: ' + failDetails()); 198 | return results; 199 | }, 200 | 201 | }; 202 | 203 | export { w3cHtmlValidator }; 204 | -------------------------------------------------------------------------------- /task-runner.sh.command: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ###################### 3 | # Task Runner # 4 | # w3c-html-validator # 5 | ###################### 6 | 7 | # To make this file runnable: 8 | # $ chmod +x *.sh.command 9 | 10 | banner="w3c-html-validator" 11 | projectHome=$(cd $(dirname $0); pwd) 12 | 13 | setupTools() { 14 | # Check for Node.js installation and download project dependencies 15 | cd $projectHome 16 | echo 17 | echo $banner 18 | echo $(echo $banner | sed s/./=/g) 19 | pwd 20 | test -d .git || { echo "Project must be in a git repository."; exit; } 21 | git restore dist/* &>/dev/null 22 | git pull --ff-only 23 | echo 24 | echo "Node.js:" 25 | which node || { echo "Need to install Node.js: https://nodejs.org"; exit; } 26 | node --version 27 | npm install --no-fund 28 | npm update --no-fund 29 | npm outdated 30 | echo 31 | } 32 | 33 | releaseInstructions() { 34 | cd $projectHome 35 | org=$(grep git+https package.json | awk -F'/' '{print $4}') 36 | name=$(grep '"name":' package.json | awk -F'"' '{print $4}') 37 | package=https://raw.githubusercontent.com/$org/$name/main/package.json 38 | version=v$(grep '"version"' package.json | awk -F'"' '{print $4}') 39 | pushed=v$(curl --silent $package | grep '"version":' | awk -F'"' '{print $4}') 40 | minorVersion=$(echo ${pushed:1} | awk -F"." '{ print $1 "." $2 }') 41 | released=$(git tag | tail -1) 42 | published=v$(npm view $name version) 43 | test $? -ne 0 && echo "NOTE: Ignore error if package is not yet published." 44 | echo "Local changes:" 45 | git status --short 46 | echo 47 | echo "Recent releases:" 48 | git tag | tail -5 49 | echo 50 | echo "Release progress:" 51 | echo " $version (local) --> $pushed (pushed) --> $released (released) --> $published (published)" 52 | echo 53 | test "$version" ">" "$released" && mode="NOT released" || mode="RELEASED" 54 | echo "Current version is: $mode" 55 | echo 56 | nextActionBump() { 57 | echo "When ready to do the next release:" 58 | echo 59 | echo " === Increment version ===" 60 | echo " Edit package.json to bump $version to next version number" 61 | echo " $projectHome/package.json" 62 | } 63 | nextActionCommitTagPub() { 64 | echo "Verify all tests pass and then finalize the release:" 65 | echo 66 | echo " === Commit and push ===" 67 | echo " Check in all changed files with the message:" 68 | echo " Release $version" 69 | echo 70 | echo " === Tag and publish ===" 71 | echo " cd $projectHome" 72 | echo " git tag --annotate --message 'Release' $version" 73 | echo " git remote --verbose" 74 | echo " git push origin --tags" 75 | echo " npm publish" 76 | } 77 | test "$version" ">" "$released" && nextActionCommitTagPub || nextActionBump 78 | echo 79 | } 80 | 81 | buildProject() { 82 | cd $projectHome 83 | echo "Build:" 84 | npm test 85 | echo "For sample validation output and results, run:" 86 | echo " node examples.js" 87 | echo 88 | } 89 | 90 | setupTools 91 | releaseInstructions 92 | buildProject 93 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "outDir": "build", 9 | "newLine": "lf", 10 | "removeComments": true, 11 | "strict": true, 12 | "allowSyntheticDefaultImports": true, 13 | "alwaysStrict": true, 14 | "exactOptionalPropertyTypes": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitAny": true, 18 | "noImplicitReturns": true, 19 | "noImplicitThis": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------