Nice Checkers is an HTML-validate plugin with 11 rules for SEO, security, accessibility, and URLs.
The npm package is @fulldecent/nice-checkers-plugin. It publishes ESM and CommonJS builds and TypeScript types. It runs in Node.js while a site is built. Some rules call other sites with curl. A fetch() implementation is blocked by html-validate issue 317.
engines allows Node.js 22.16 and newer, which is the oldest Node.js supported by html-validate 10. Tests run on the Node.js 22, 24, and 26 release lines. Lint checks Prettier and markdownlint. Local development uses the Node.js version in .node-version.
GitHub Pages template is a site that uses this plugin, with Actions and Pages deployment.
These instructions assume Nice Checkers is part of a web test suite running Node.js 22.16 or newer and HTML-validate.
Install Nice Checkers as a dev dependency. It is used to test the site.
Yarn:
yarn add -D @fulldecent/nice-checkers-pluginnpm:
npm install -D @fulldecent/nice-checkers-pluginThis example assumes you are using the .htmlvalidate.mjs configuration flavor. HTML-validate also supports other configuration flavors.
import { defineConfig } from "html-validate";
+ import NiceCheckersPlugin from "@fulldecent/nice-checkers-plugin"
export default defineConfig({
- "extends": ["htmlvalidate:recommended"]
+ "plugins": [NiceCheckersPlugin],
+ "extends": ["htmlvalidate:recommended", "nice-checkers-plugin:recommended"]
});All rules are enabled by default when you extend from nice-checkers-plugin:recommended. Find introductions and configuration options for each rule below.
Ensures that all alternate language links (<link rel="alternate" hreflang="...">) use fully qualified URLs with protocol (https://). This follows Google's best practices for international and multilingual websites.
According to Google's documentation on localized versions, alternate language links must use fully qualified URLs:
"The value of the hreflang attribute identifies the language (in ISO 639-1 format) and optionally a region (in ISO 3166-1 Alpha 2 format) of an alternate URL. The href attribute contains the full URL of the alternate version."
Using relative or protocol-relative URLs can cause search engines to misinterpret or ignore your international content signals.
- <!-- Incorrect: relative path -->
- <link rel="alternate" hreflang="es" href="/es/page" />
- <link rel="alternate" hreflang="fr" href="../fr/page.html" />
+ <!-- Correct: fully qualified URL -->
+ <link rel="alternate" hreflang="es" href="https://example.com/es/page" />
+ <link rel="alternate" hreflang="fr" href="https://example.fr/page" />{
"rules": {
"nice-checkers/alternate-language-url": "error"
}
}This rule has no configurable options.
Ensures that each HTML document contains a single canonical link element pointing to the preferred URL for that page. This rule helps with SEO by preventing duplicate content issues and clarifies the primary URL for search engines.
Also this rule enforces that your public URL does not end with a file extension (e.g. .html) or an index (/index). Each character in your URL is valuable real estate and you should not expose such implementation details in your URL.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My first website about horses</title>
+ <link rel="canonical" href="https://example.com/horses" />
</head>
<body>
This page is missing a required canonical link element in the head.
</body>
</html>{
"rules": {
"nice-checkers/canonical-link": "error"
}
}This rule has no configurable options.
Validates that all external links are live and accessible. This rule helps maintain website quality by catching broken external links before they go live, improving user experience and SEO.
Note: This rule automatically skips validation of:
<link rel="canonical">- Canonical URLs point to the site itself and may not be published yet during development/preview<link rel="alternate">- Alternate language URLs also point to the site itself and may not exist during development
This allows you to validate your HTML before publishing, even when the canonical and alternate URLs reference the final production URLs.
- <a href="https://wrong-subdomain.example.com">This link is broken</a>
+ <a href="https://example.com/nonexistent-page">This link works</a>{
"rules": {
"nice-checkers/external-links": [
"error",
{
"proxyUrl": "",
"skipRegexes": ["://example.com", "://localhost"],
"cacheExpiryFoundSeconds": 2592000,
"cacheExpiryNotFoundSeconds": 259200,
"timeoutSeconds": 5,
"cacheDatabasePath": "cache/external-links.csv",
"userAgent": "Mozilla/5.0 (compatible; html-validate-nice-checkers)"
}
]
}
}| Option | Type | Default | Description |
|---|---|---|---|
proxyUrl |
string |
"" |
Proxy URL to use for HTTP requests |
skipRegexes |
string[] |
[] |
Array of regex patterns for URLs to skip checking |
cacheExpiryFoundSeconds |
number |
2592000 |
Cache duration for successful checks (default: 30 days) |
cacheExpiryNotFoundSeconds |
number |
259200 |
Cache duration for failed checks (default: 3 days) |
timeoutSeconds |
number |
5 |
Request timeout in seconds |
cacheDatabasePath |
string |
"cache/external-links.csv" |
Path to the CSV cache database file |
userAgent |
string |
"Mozilla/5.0 (compatible; html-validate-nice-checkers)" |
User agent string for HTTP requests |
manuallyReviewedPath |
string |
"" |
Path to CSV file with manually reviewed URLs (see below) |
manuallyReviewedExpirySeconds |
number |
31536000 |
Expiry time for manually reviewed URLs (default: 365 days) |
urlRewrites |
{ pattern: string, replacement: string }[] |
[] |
Regex rewrite rules mapping absolute https:// URLs to local paths. Matched URLs are checked on disk instead of over the network. |
alternativeExtensions |
string[] |
[".html"] |
Extensions to try when a rewritten local path has no extension. |
indexFile |
string |
"index.html" |
Filename to look for when a rewritten local path resolves to a directory. |
When a URL is rewritten to a local path, the rule checks the path as-is, then with each alternativeExtensions suffix, then as a directory with indexFile — matching Eleventy's pretty-URL output format (e.g. page.html or page/index.html).
Some websites resist automated checking (anti-scraping, rate limiting, etc.). You can maintain a CSV file of manually reviewed URLs that should be treated as valid:
CSV format:
url,last_approved_timestamp
https://anti-scraping-site.example.com/page,1764877136
https://example.com/manually-verified,1764877136- The first line must be the header:
url,last_approved_timestamp url: The exact URL to approve (must match exactly, including protocol and path)last_approved_timestamp: Unix timestamp (seconds since epoch) when you last verified the URL
URLs in this file are approved if:
- The URL matches exactly
- Current time < (last_approved_timestamp + manuallyReviewedExpirySeconds)
This allows time-limited manual approvals that automatically expire, ensuring you periodically re-verify that URLs still exist.
Reports insecure HTTP links that are accessible via HTTPS, encouraging the use of secure connections. This rule promotes security best practices by identifying opportunities to upgrade to HTTPS.
- <a href="http://example.com/page">Should use HTTPS</a>
- <img src="http://cdn.example.com/image.webp" alt="Image" />
+ <a href="https://example.com/page">Uses HTTPS</a>
+ <img src="https://cdn.example.com/image.webp" alt="Image" />{
"rules": {
"nice-checkers/https-links": [
"warn",
{
"cacheExpiryFoundSeconds": 2592000,
"cacheExpiryNotFoundSeconds": 259200,
"timeoutSeconds": 10,
"cacheDatabasePath": "cache/https-availability.csv"
}
]
}
}| Option | Type | Default | Description |
|---|---|---|---|
cacheExpiryFoundSeconds |
number |
2592000 |
Cache duration for successful HTTPS checks (default: 30 days) |
cacheExpiryNotFoundSeconds |
number |
259200 |
Cache duration for failed HTTPS checks (default: 3 days) |
timeoutSeconds |
number |
10 |
Request timeout in seconds |
cacheDatabasePath |
string |
"cache/https-availability.csv" |
Path to the CSV cache database file |
Validates that all internal links point to existing files in your project. This rule prevents broken internal navigation and missing resource references.
Case-sensitive checking: This rule performs case-sensitive file matching even on case-insensitive file systems (like macOS default). A link to /abc.webp will fail if the actual file is /AbC.webp, ensuring your code works correctly on Linux servers where case matters.
- <a href="/nonexistent-page">Broken internal link</a>
- <img src="../images/missing.webp" alt="Missing image" />
- <a href="/Logo.png">Wrong case (actual file: logo.png)</a>
+ <a href="/about">Working internal link</a>
+ <img src="../images/logo.webp" alt="Company logo" />
+ <a href="/logo.png">Correct case</a>{
"rules": {
"nice-checkers/internal-links": [
"error",
{
"webRoot": "./build",
"alternativeExtensions": [".html", ".php"],
"indexFile": "index.html"
}
]
}
}| Option | Type | Default | Description |
|---|---|---|---|
webRoot |
string |
"./build" |
Root directory for resolving absolute links |
alternativeExtensions |
string[] |
[".html"] |
Extensions to check for extensionless links |
indexFile |
string |
"index.html" |
Default file to look for in directory links |
Ensures that package assets loaded from CDNs (like jsDelivr) are using the latest version and have proper SRI attributes. This rule promotes security and ensures you're using up-to-date packages.
- <!-- Outdated package without SRI -->
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.min.js"></script>
+ <!-- Latest package with SRI -->
+ <script
+ src="https://cdn.jsdelivr.net/npm/bootstrap@.../dist/js/bootstrap.min.js"
+ integrity="sha384-..."
+ crossorigin="anonymous"
+ ></script>{
"rules": {
"nice-checkers/latest-packages": [
"warn",
{
"cacheExpirySeconds": 172800,
"timeoutSeconds": 10,
"cacheDatabasePath": "cache/latest-packages.csv",
"skipUrlPatterns": ["googletagmanager.com"]
}
]
}
}| Option | Type | Default | Description |
|---|---|---|---|
cacheExpirySeconds |
number |
172800 |
Cache duration for package version checks (default: 2 days) |
timeoutSeconds |
number |
10 |
Request timeout in seconds |
cacheDatabasePath |
string |
"cache/latest-packages.csv" |
Path to the CSV cache database file |
skipUrlPatterns |
string[] |
[] |
Array of URL patterns to skip checking |
Requires page source to match all mustMatch regexes and none of the mustNotMatch regexes. This rule is off by default because it requires user-provided patterns.
Use this to enforce that specific content or HTML elements are present on every page, or to forbid certain words or patterns. All patterns are evaluated with the s (dotAll) flag enabled, so they can match across multiple lines.
When configuring via JSON, patterns must be given as strings. When configuring via JavaScript or TypeScript, each entry may be either a string or a RegExp instance. For RegExp inputs, all existing user flags are preserved and s is added if not already present.
For example, you might require a specific footer script on every page:
{
"rules": {
"nice-checkers/match-regex": [
"error",
{
"mustMatch": [
"<script src=\"/assets/global/site\\.js\\?[0-9a-f]+\" async></script>\\s*</body>"
],
"mustNotMatch": ["naughty"]
}
]
}
}{
"rules": {
"nice-checkers/match-regex": [
"error",
{
"mustMatch": [],
"mustNotMatch": []
}
]
}
}| Option | Type | Default | Description |
|---|---|---|---|
mustMatch |
(string | RegExp)[] |
[] |
Patterns that the page source must match |
mustNotMatch |
(string | RegExp)[] |
[] |
Patterns that the page source must not match |
Enforces that mailto: links contain specific parameters to improve user experience. This rule ensures email links provide helpful context to users.
- <a href="mailto:contact@example.com">Send email</a>
+ <a href="mailto:contact@example.com?subject=Website%20Inquiry&body=Hello,%20I%20would%20like%20to...">Send email</a>{
"rules": {
"nice-checkers/mailto-awesome": [
"error",
{
"requiredParameters": ["subject", "body"]
}
]
}
}| Option | Type | Default | Description |
|---|---|---|---|
requiredParameters |
string[] |
[] |
Array of parameters that must be present (e.g., ["subject", "body", "cc"]) |
If you are still using jQuery after 2022, please try to open your favorite chatbot and ask how to replace it with vanilla JavaScript. Your page will run faster. And it is very possible that your chatbot can do this entire operation in one go without interactive back-and-forth.
- <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
- <script src="../js/jquery.min.js"></script>{
"rules": {
"nice-checkers/no-jquery": "error"
}
}This rule has no configurable options.
This rule enforces best practices for alternate language links (<link rel="alternate" hreflang="...">) in the <head> of HTML documents, as recommended by authoritative and established sources:
Note that these sources we reference have a conflict. One says that you may use relative URLs and the other says you must use fully qualified URLs. To be conservative, we require fully qualified URLs.
Activation: this checker is only active if one or more <link rel="alternate" hreflang="..."> elements exist in the document <head>.
Checks performed:
-
Self-link requirement:
- There must be at least one
<link rel="alternate" hreflang="...">whosehrefexactly matches the canonical URL of the page. - The
hreflangof this self-link must match the page's<html lang="...">attribute, if set. - The canonical URL must exist (enforced by another checker).
- There must be at least one
-
Fully qualified URLs:
- Every alternate language link must use a fully qualified URL (must include a scheme, e.g.,
https://).
- Every alternate language link must use a fully qualified URL (must include a scheme, e.g.,
-
Reciprocal linking:
- Every alternate language page linked out to must reciprocate by linking back to the current page's canonical URL via its own
<link rel="alternate" hreflang="...">. - The
hreflangof the reciprocal link on the remote page must match the<html lang="...">of the current page (if set). - This is enforced by fetching the remote page and verifying its
<head>contains the correct reciprocal link.
- Every alternate language page linked out to must reciprocate by linking back to the current page's canonical URL via its own
Example:
- The English page must link to itself and to the French page.
- The French page (
https://example.com/page-fr) must link back to the English canonical page, and thehreflangmust match the English page’s<html lang="en">.
References:
| Option | Type | Default | Description |
|---|---|---|---|
urlRewrites |
{ pattern: string, replacement: string }[] |
[] |
Regex rewrite rules applied to each alternate URL before reciprocal validation. Useful for local fixtures. |
Validates <script type="application/ld+json"> structured data against the bundled Schema.org vocabulary. This rule catches typos and mistakes in class names, property names, and property values before search engines silently ignore your structured data.
<script type="application/ld+json">
{
"@context": "https://schema.org",
- "@type": "MyTotallyFakeClass",
+ "@type": "WebSite",
"name": "My Awesome Site",
"url": "https://example.com/"
}
</script>{
"rules": {
"nice-checkers/schema-org-json-ld": "error"
}
}This rule has no configurable options.
{
"rules": {
"nice-checkers/alternate-language-links": [
"error",
{
"urlRewrites": [
{
"pattern": "^https://example\\.invalid",
"replacement": "./build"
}
]
}
]
}
}With this configuration, an alternate URL like https://example.invalid/some-page is validated against ./build/some-page.html or ./build/some-page/index.html without network access.
nice-checkers-plugin:recommended explicitly turns off the following built-in html-validate rules because they conflict with common HTML minification tools.
HTML minifiers such as @minify-html/node strip the type attribute from <button> and <input> elements when it equals the HTML-spec default value (e.g. type="submit" on <button>, type="text" on <input>). This is valid per the spec and reduces page size, but it causes false-positive warnings from these two core rules when validating minified output.
import { minify } from '@minify-html/node'
const html = '<button type="submit">Go</button><input type="text" name="q">'
const minified = minify(Buffer.from(html), {
/* options */
})
// → "<button>Go</button><input name=q>"
// type="submit" and type="text" are stripped as they are HTML defaultsExtending nice-checkers-plugin:recommended disables both rules so that projects validating minified output do not need a workaround.
If you author your HTML by hand (i.e. you are not validating minified output) and want to enforce these rules, you can re-enable them explicitly in your own configuration:
{
"extends": ["htmlvalidate:recommended", "nice-checkers-plugin:recommended"],
"rules": {
"no-implicit-button-type": "error",
"no-implicit-input-type": "error"
}
}See issue #23 for the full discussion.
Clone the repo:
git clone https://github.com/fulldecent/html-validate-nice-checkers.git ~/Developer/html-validate-nice-checkers
cd ~/Developer/html-validate-nice-checkersUse Node and yarn. The Node version is pinned in .node-version, and the Yarn version is pinned in package.json. Quick start with fnm:
fnm install
fnm use
corepack enable
yarn install
yarn testFormat files the lint workflow checks:
yarn formatyarn buildbuilds the packageyarn build:watchbuilds the package in watch modeyarn testruns the tests onceyarn test:watchruns the tests in watch modeyarn test:coverageruns the tests and generates a coverage reportyarn lintruns TypeScript type checkingyarn check:packagechecks the built package with publint and arethetypeswrongyarn formatformats files with Prettier and markdownlint
Changes are ready to push when yarn format && yarn lint && yarn test passes.
yarn format and the lint workflow both run npx prettier@latest. The editor's Prettier extension can be a different version, so yarn format is the one that matches CI.
When running yarn test to test Nice Checkers itself, you may see two warnings about missing "root" paths. These come from the mock HTTP server (@jaredwray/mockhttp) which is only used in our test suite. The warnings are harmless and do not affect test results. We consider this an error in the upstream mock HTTP server package. These warnings do not appear for downstream users who install Nice Checkers to validate their own websites.
Package versions use Semantic Versioning.
- Finish the changes that belong in the release.
- Bump
peerDependencieswhen a newly supported html-validate version requires it. - Run
yarn && yarn format && yarn lint && yarn build && yarn test && yarn check:package. - Bump
versionin package.json in a commit by itself. - Create a GitHub release for that version. publish.yml publishes the package to npm.
Do this every month or so and please send a PR here if you see updates available:
- Identify external Actions in .github/workflows scripts and look for available new versions. Review and then update to the new version if it is safe. GitHub-supported Actions (i.e. under the actions/ organization) may require only cursory review.
- Review the Node.js version in
.node-version. Update it when a newer version is appropriate.fnm installreads that file. This local pin is separate from the versions the package supports. - Review the supported Node.js versions against the Node.js release schedule. This package supports the Current, Active LTS, and Maintenance LTS release lines, the same as html-validate. It does not support a Node.js version that the oldest html-validate in
peerDependencieshas dropped from itsengines.engines.nodein package.json is the floor, and tsdown compiles to that floor. When a release line reaches end-of-life, or apeerDependenciesbump raises html-validate's floor, raiseengines.nodeand update the Node.js versions in test.yml in the same commit. - Review the Yarn version in
package.json(packageManager). Update it withyarn set version stable && yarnwhen a newer stable version is appropriate. Yarn's install instructions document that command. - Review direct dependencies with
yarn upgrade-interactive. - Check whether TypeScript issue #63769 is fixed and whether Vite still warns about Plug'n'Play (Vite pull request #21906). When neither blocks Plug'n'Play, remove
nodeLinkerfrom.yarnrc.yml. - Download the Schema.org vocabulary from https://schema.org/docs/developers.html and save it as
src/vendor/schemaorg-current-https.json. Schema.org does not publish that file as an npm package, so the update is manual.
- We use title case for titles and proper nouns; not for headings and things. This includes our README above as well as our workflow rules and other configuration files. If you have a different policy, then please implement it throughout.
- This project uses the MIT license, the same license as node.js-template.
- We would prefer if fnm supported build attestations since it is installed as a binary (issue #1588).
- Node.js ignore rules are inlined from Node.gitignore. This project also ignores
/cache, the fixture files that tests rewrite, andpackage-lock.json.package-lock.jsonis ignored because dependencies are locked withyarn.lock. .yarnrc.ymlsetsenableScriptsto true (Yarn 4.14 defaults to false) andnpmMinimalAgeGateto 0 (Yarn 4.12 defaults to one day).approvedGitRepositoriesis"**", which approves every git dependency. Yarn: Security. It also setsnodeLinker: pnpminstead of the default, Plug'n'Play, because TypeScript 7 and Vite do not support Plug'n'Play. The comments in that file link to both upstream issues.- Prettier options are in .prettierrc. node.js-template has no application source and therefore no Prettier config. Formatting still uses
npx prettier@latest, the same command as that template's lint workflow. .prettierignoreignores*.md, the same as the template. It also ignorestests/fixturesandsrc/vendor. Fixture HTML is the exact input forrequired-reports.json, which records line, column, and byte offset.src/vendor/schemaorg-current-https.jsonis a file downloaded from Schema.org.- markdownlint disables MD013, the same as the template, and sets MD024
siblings_only. Each rule section repeats the headings "Configuration" and "Configuration options".siblings_onlyallows that because each heading sits under a different rule. - test.yml runs
yarn lint,yarn test, andyarn build. Then it checks the package with publint and arethetypeswrong, packs it, and loads it into html-validate from ESM and from CommonJS. The CommonJS test uses html-validate'scjsResolver, which loads plugins withrequire(). The test script in node.js-template istrue, which is enough for a package with no behavior of its own. This job runs on Node.js 22, 24, and 26, the Maintenance LTS, Active LTS, and Current release lines..node-versionstays at 24. - tsdown.config.ts builds
dist/fromsrc/index.ts:.jsand.d.tsforimport,.cjsand.d.ctsforrequire, matchingexportsin package.json.tsc --noEmittype checks and does not publish. The build is not minified so that people can debug the rules. html-validate is a peer dependency and is not bundled. CommonJS is kept for html-validate'scjsResolver, so the build turns off tsdown'slegacyCjswarning. - publish.yml publishes to npm when a GitHub release is published. node.js-template sets
"private": trueand is not an npm package. - This project is built based on best practices documented in node.js-template.
- This project is built based on best practices documented in project-template, release 1.0.0.