@dukebot/astro-html-validator 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -2
- package/bin/cli.mjs +20 -1
- package/package.json +1 -1
- package/src/index.mjs +90 -3
- package/src/utils/jsonld.mjs +102 -0
- package/src/utils/links.mjs +89 -0
- package/src/utils/meta.mjs +122 -0
- package/src/validator.mjs +27 -81
- package/src/validators/jsonld.mjs +77 -60
- package/src/validators/links.mjs +32 -72
- package/src/validators/meta.mjs +27 -120
- /package/src/{utils.mjs → utils/common.mjs} +0 -0
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ astro-html-validator [selector] [options]
|
|
|
46
46
|
|
|
47
47
|
Options:
|
|
48
48
|
--dir <path> Path to the dist directory (default: <cwd>/dist)
|
|
49
|
+
--links-absolute-prefixes <list>
|
|
50
|
+
Comma-separated absolute URL prefixes treated as local routes
|
|
49
51
|
--quiet Disable summary output
|
|
50
52
|
--help Show help
|
|
51
53
|
```
|
|
@@ -61,8 +63,15 @@ import { Validator } from '@dukebot/astro-html-validator';
|
|
|
61
63
|
const validator = new Validator({
|
|
62
64
|
dirPath: path.resolve(process.cwd(), 'dist'),
|
|
63
65
|
config: {
|
|
64
|
-
jsonld: {
|
|
65
|
-
|
|
66
|
+
jsonld: {
|
|
67
|
+
requireHtmlLang: true,
|
|
68
|
+
requireInLanguage: true,
|
|
69
|
+
disallowEmptyInLanguage: true,
|
|
70
|
+
requireLangMatch: true,
|
|
71
|
+
},
|
|
72
|
+
links: {
|
|
73
|
+
absoluteUrlPrefixes: ['https://example.com', 'https://www.example.com'],
|
|
74
|
+
},
|
|
66
75
|
meta: {
|
|
67
76
|
metaTitleMinLength: 30,
|
|
68
77
|
metaTitleMaxLength: 60,
|
|
@@ -77,6 +86,34 @@ const results = await validator.run({ selector: 'all' });
|
|
|
77
86
|
console.log(results);
|
|
78
87
|
```
|
|
79
88
|
|
|
89
|
+
### Internal links: absolute-to-local prefix mapping
|
|
90
|
+
|
|
91
|
+
The `links` validator can treat some absolute URLs as local internal routes.
|
|
92
|
+
|
|
93
|
+
If `absoluteUrlPrefixes` contains your site domains, links like:
|
|
94
|
+
|
|
95
|
+
- `https://example.com/about`
|
|
96
|
+
- `https://www.example.com/contact?utm=x#team`
|
|
97
|
+
|
|
98
|
+
are normalized to local paths (`/about`, `/contact`) and validated against `dist`.
|
|
99
|
+
|
|
100
|
+
### Architecture (current)
|
|
101
|
+
|
|
102
|
+
- `src/index.mjs` exports the coordinator class (`Validator`) that orchestrates all checks.
|
|
103
|
+
- `src/validator.mjs` now contains the **base validator class** used via inheritance by concrete validators.
|
|
104
|
+
- Each concrete validator (`jsonld`, `links`, `meta`) encapsulates its own config and page-level validation.
|
|
105
|
+
|
|
106
|
+
### JSON-LD language consistency options
|
|
107
|
+
|
|
108
|
+
`config.jsonld` now supports optional checks to validate language consistency between `<html lang="...">` and JSON-LD `inLanguage` values:
|
|
109
|
+
|
|
110
|
+
- `requireHtmlLang` (default: `false`)
|
|
111
|
+
- `requireInLanguage` (default: `false`)
|
|
112
|
+
- `disallowEmptyInLanguage` (default: `false`)
|
|
113
|
+
- `requireLangMatch` (default: `false`)
|
|
114
|
+
|
|
115
|
+
When enabled, warnings are reported through the normal validator output (`[WARN] /route -> ...`) so existing integrations remain backward-compatible.
|
|
116
|
+
|
|
80
117
|
---
|
|
81
118
|
|
|
82
119
|
## Suggested scripts for your Astro project
|
|
@@ -98,6 +135,7 @@ console.log(results);
|
|
|
98
135
|
Quick steps:
|
|
99
136
|
|
|
100
137
|
1. Update `name`, `author`, and `version` in `package.json`.
|
|
138
|
+
- For this release, use a **major bump** (breaking changes accepted).
|
|
101
139
|
2. Sign in:
|
|
102
140
|
|
|
103
141
|
```bash
|
|
@@ -110,6 +148,13 @@ Quick steps:
|
|
|
110
148
|
npm publish --access public
|
|
111
149
|
```
|
|
112
150
|
|
|
151
|
+
### Breaking change note
|
|
152
|
+
|
|
153
|
+
`@dukebot/astro-html-validator/validator` now points to the **base validator class** (`src/validator.mjs`) instead of the previous coordinator implementation.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
113
158
|
|
|
114
159
|
|
|
115
160
|
|
package/bin/cli.mjs
CHANGED
|
@@ -18,6 +18,8 @@ Selector:
|
|
|
18
18
|
|
|
19
19
|
Options:
|
|
20
20
|
--dir <path> Path to the dist directory (default: ./dist)
|
|
21
|
+
--links-absolute-prefixes <list>
|
|
22
|
+
Comma-separated absolute URL prefixes treated as local
|
|
21
23
|
--quiet Disable printed summary output
|
|
22
24
|
--help Show help
|
|
23
25
|
|
|
@@ -25,6 +27,7 @@ Examples:
|
|
|
25
27
|
astro-html-validator
|
|
26
28
|
astro-html-validator meta
|
|
27
29
|
astro-html-validator links --dir ./dist
|
|
30
|
+
astro-html-validator links --links-absolute-prefixes https://example.com,https://www.example.com
|
|
28
31
|
astro-html-validator jsonld,meta
|
|
29
32
|
`);
|
|
30
33
|
}
|
|
@@ -36,6 +39,7 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
36
39
|
const options = {
|
|
37
40
|
selector: 'all',
|
|
38
41
|
dirPath: path.resolve(process.cwd(), 'dist'),
|
|
42
|
+
linksAbsolutePrefixes: [],
|
|
39
43
|
print: true,
|
|
40
44
|
help: false,
|
|
41
45
|
};
|
|
@@ -67,6 +71,17 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
67
71
|
continue;
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
if (arg === '--links-absolute-prefixes') {
|
|
75
|
+
const next = argv[index + 1];
|
|
76
|
+
if (!next) throw new Error('Missing value for --links-absolute-prefixes');
|
|
77
|
+
options.linksAbsolutePrefixes = next
|
|
78
|
+
.split(',')
|
|
79
|
+
.map((item) => item.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
index += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
throw new Error(`Unknown argument: ${arg}`);
|
|
71
86
|
}
|
|
72
87
|
|
|
@@ -88,7 +103,11 @@ async function main() {
|
|
|
88
103
|
|
|
89
104
|
const validator = new Validator({
|
|
90
105
|
dirPath: parsed.dirPath,
|
|
91
|
-
config: {
|
|
106
|
+
config: {
|
|
107
|
+
links: {
|
|
108
|
+
absoluteUrlPrefixes: parsed.linksAbsolutePrefixes,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
92
111
|
print: parsed.print,
|
|
93
112
|
});
|
|
94
113
|
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -1,4 +1,91 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { JsonldValidator } from './validators/jsonld.mjs';
|
|
2
|
+
import { LinksValidator } from './validators/links.mjs';
|
|
3
|
+
import { MetaValidator } from './validators/meta.mjs';
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Coordinates all available validators and prints optional summaries.
|
|
7
|
+
*/
|
|
8
|
+
export class HtmlValidator {
|
|
9
|
+
/**
|
|
10
|
+
* Builds a validator coordinator with directory, per-validator config, and output mode.
|
|
11
|
+
*/
|
|
12
|
+
constructor({ dirPath, config = {}, print = true } = {}) {
|
|
13
|
+
this.dirPath = dirPath;
|
|
14
|
+
|
|
15
|
+
this.validators = {
|
|
16
|
+
jsonld: new JsonldValidator(config.jsonld),
|
|
17
|
+
links: new LinksValidator(config.links),
|
|
18
|
+
meta: new MetaValidator(config.meta),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
this.print = print;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a selector string into a unique list of validator names.
|
|
26
|
+
*/
|
|
27
|
+
_selectValidators(selector = 'all') {
|
|
28
|
+
const clean = selector.trim().toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (clean === 'all') return Object.keys(this.validators);
|
|
31
|
+
|
|
32
|
+
const selected = clean
|
|
33
|
+
.split(',')
|
|
34
|
+
.map((item) => item.trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
|
|
37
|
+
const invalid = selected.filter((name) => !this.validators[name]);
|
|
38
|
+
if (invalid.length > 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unknown validators: ${invalid.join(', ')}. ` +
|
|
41
|
+
`Valid options: all, ${Object.keys(this.validators).join(', ')}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [...new Set(selected)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prints a consistent summary for one validator result.
|
|
50
|
+
*/
|
|
51
|
+
_printResultSummary(result) {
|
|
52
|
+
console.log(`\n=== ${result.label} ===`);
|
|
53
|
+
console.log(`Checked ${result.checkedPages} HTML pages.`);
|
|
54
|
+
|
|
55
|
+
if (result.warnings.length === 0) {
|
|
56
|
+
console.log('✅ No warnings.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`⚠️ Warnings found: ${result.warnings.length}`);
|
|
61
|
+
for (const warning of result.warnings) console.log(warning);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Runs one validator by name.
|
|
66
|
+
*/
|
|
67
|
+
async runValidator(name) {
|
|
68
|
+
const validator = this.validators[name];
|
|
69
|
+
const result = await validator.validate(this.dirPath);
|
|
70
|
+
if (this.print) this._printResultSummary(result);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Runs selected validators sequentially.
|
|
76
|
+
*/
|
|
77
|
+
async run({ selector = 'all' } = {}) {
|
|
78
|
+
const results = [];
|
|
79
|
+
const selectedNames = this._selectValidators(selector);
|
|
80
|
+
|
|
81
|
+
for (const name of selectedNames) {
|
|
82
|
+
const result = await this.runValidator(name);
|
|
83
|
+
results.push(result);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { HtmlValidator as Validator };
|
|
91
|
+
export default HtmlValidator;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts the language code declared on the <html> element.
|
|
3
|
+
*/
|
|
4
|
+
export function extractHtmlLang(html) {
|
|
5
|
+
const match = html.match(/<html[^>]*\blang=["']([^"']+)["']/i);
|
|
6
|
+
return match?.[1]?.trim() ?? '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recursively collects all inLanguage values from a JSON-LD node tree.
|
|
11
|
+
*/
|
|
12
|
+
export function collectInLanguageValues(node, out = []) {
|
|
13
|
+
if (Array.isArray(node)) {
|
|
14
|
+
for (const item of node) collectInLanguageValues(item, out);
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!node || typeof node !== 'object') return out;
|
|
19
|
+
|
|
20
|
+
if (Object.hasOwn(node, 'inLanguage')) {
|
|
21
|
+
out.push(node.inLanguage);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const value of Object.values(node)) {
|
|
25
|
+
collectInLanguageValues(value, out);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks whether any inLanguage value is empty, null, or undefined.
|
|
33
|
+
*/
|
|
34
|
+
export function hasEmptyInLanguage(values) {
|
|
35
|
+
return values.some((value) => {
|
|
36
|
+
if (value == null) return true;
|
|
37
|
+
if (typeof value === 'string') return value.trim().length === 0;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.some((item) => {
|
|
40
|
+
if (item == null) return true;
|
|
41
|
+
if (typeof item !== 'string') return false;
|
|
42
|
+
return item.trim().length === 0;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns true when at least one inLanguage value matches the HTML lang.
|
|
51
|
+
*/
|
|
52
|
+
export function hasHtmlLang(values, htmlLang) {
|
|
53
|
+
const normalizedHtmlLang = htmlLang.trim().toLowerCase();
|
|
54
|
+
|
|
55
|
+
return values.some((value) => {
|
|
56
|
+
if (typeof value === 'string') {
|
|
57
|
+
return value.trim().toLowerCase() === normalizedHtmlLang;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return value.some(
|
|
62
|
+
(item) => typeof item === 'string' && item.trim().toLowerCase() === normalizedHtmlLang
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return false;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extracts and parses JSON-LD script blocks from a page.
|
|
72
|
+
*/
|
|
73
|
+
export function getJsonLdBlocks(html) {
|
|
74
|
+
const regex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
75
|
+
const blocks = [];
|
|
76
|
+
let match;
|
|
77
|
+
|
|
78
|
+
while ((match = regex.exec(html)) !== null) {
|
|
79
|
+
const raw = match[1]?.trim();
|
|
80
|
+
if (!raw) continue;
|
|
81
|
+
try {
|
|
82
|
+
blocks.push(JSON.parse(raw));
|
|
83
|
+
} catch {
|
|
84
|
+
blocks.push({ __parseError: true, __raw: raw });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return blocks;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Flattens top-level JSON-LD nodes and @graph nodes into one list.
|
|
93
|
+
*/
|
|
94
|
+
export function getGraphNodes(blocks) {
|
|
95
|
+
const nodes = [];
|
|
96
|
+
for (const block of blocks) {
|
|
97
|
+
if (block.__parseError) continue;
|
|
98
|
+
if (Array.isArray(block['@graph'])) nodes.push(...block['@graph']);
|
|
99
|
+
else nodes.push(block);
|
|
100
|
+
}
|
|
101
|
+
return nodes;
|
|
102
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists } from './common.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes configured absolute URL prefixes.
|
|
6
|
+
*/
|
|
7
|
+
function normalizeAbsolutePrefixes(absoluteUrlPrefixes = []) {
|
|
8
|
+
if (!absoluteUrlPrefixes) return [];
|
|
9
|
+
|
|
10
|
+
const values = Array.isArray(absoluteUrlPrefixes)
|
|
11
|
+
? absoluteUrlPrefixes
|
|
12
|
+
: String(absoluteUrlPrefixes)
|
|
13
|
+
.split(',')
|
|
14
|
+
.map((item) => item.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
|
|
17
|
+
return [...new Set(values.map((value) => value.replace(/\/+$/, '')))];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Converts a matching absolute URL into a local root-relative URL.
|
|
22
|
+
*/
|
|
23
|
+
function toLocalPathFromAbsolute(rawUrl, absolutePrefixes) {
|
|
24
|
+
for (const prefix of absolutePrefixes) {
|
|
25
|
+
if (rawUrl === prefix) return '/';
|
|
26
|
+
if (rawUrl.startsWith(`${prefix}/`)) return rawUrl.slice(prefix.length);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extracts local (root-relative) URLs from href/src attributes.
|
|
34
|
+
*/
|
|
35
|
+
export function extractInternalUrls(html, { absoluteUrlPrefixes = [] } = {}) {
|
|
36
|
+
const urls = new Set();
|
|
37
|
+
const regex = /(?:href|src)=["']([^"']+)["']/gi;
|
|
38
|
+
const absolutePrefixes = normalizeAbsolutePrefixes(absoluteUrlPrefixes);
|
|
39
|
+
|
|
40
|
+
let match;
|
|
41
|
+
while ((match = regex.exec(html)) !== null) {
|
|
42
|
+
const raw = match[1]?.trim();
|
|
43
|
+
if (!raw) continue;
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
raw.startsWith('//') ||
|
|
47
|
+
raw.startsWith('#') ||
|
|
48
|
+
raw.startsWith('mailto:') ||
|
|
49
|
+
raw.startsWith('tel:') ||
|
|
50
|
+
raw.startsWith('javascript:') ||
|
|
51
|
+
raw.startsWith('data:')
|
|
52
|
+
) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clean = raw.split(/[?#]/)[0];
|
|
57
|
+
if (!clean) continue;
|
|
58
|
+
|
|
59
|
+
if (clean.startsWith('/')) {
|
|
60
|
+
if (clean) urls.add(clean);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (clean.startsWith('http://') || clean.startsWith('https://')) {
|
|
65
|
+
const localPath = toLocalPathFromAbsolute(clean, absolutePrefixes);
|
|
66
|
+
if (localPath) urls.add(localPath);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...urls];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Checks whether an internal URL resolves to an HTML file in dist.
|
|
75
|
+
*/
|
|
76
|
+
export async function internalUrlExists(dirPath, urlPath) {
|
|
77
|
+
if (urlPath === '/') return pathExists(path.join(dirPath, 'index.html'));
|
|
78
|
+
|
|
79
|
+
const asFile = path.join(dirPath, urlPath.replace(/^\//, ''));
|
|
80
|
+
if (await pathExists(asFile)) return true;
|
|
81
|
+
|
|
82
|
+
const asIndex = path.join(dirPath, urlPath.replace(/^\//, ''), 'index.html');
|
|
83
|
+
if (await pathExists(asIndex)) return true;
|
|
84
|
+
|
|
85
|
+
const asHtml = path.join(dirPath, `${urlPath.replace(/^\//, '')}.html`);
|
|
86
|
+
if (await pathExists(asHtml)) return true;
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { getAttr } from './common.mjs';
|
|
2
|
+
|
|
3
|
+
// Core metadata tags expected on every page.
|
|
4
|
+
export const REQUIRED_META_CHECKS = [
|
|
5
|
+
{ label: 'meta title', check: getTitleContent },
|
|
6
|
+
{ label: 'meta description', check: (html) => hasMeta(html, 'description') },
|
|
7
|
+
{ label: 'canonical', check: hasCanonical },
|
|
8
|
+
{ label: 'meta robots', check: (html) => hasMeta(html, 'robots') },
|
|
9
|
+
{ label: 'og:title', check: (html) => hasMeta(html, 'og:title', true) },
|
|
10
|
+
{ label: 'og:description', check: (html) => hasMeta(html, 'og:description', true) },
|
|
11
|
+
{ label: 'og:url', check: (html) => hasMeta(html, 'og:url', true) },
|
|
12
|
+
{ label: 'og:type', check: (html) => hasMeta(html, 'og:type', true) },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns whether a meta tag exists with the expected key and non-empty content.
|
|
17
|
+
*/
|
|
18
|
+
export function hasMeta(html, name, isProperty = false) {
|
|
19
|
+
const tags = html.match(/<meta\b[^>]*>/gi) || [];
|
|
20
|
+
return tags.some((tag) => {
|
|
21
|
+
const key = isProperty ? getAttr(tag, 'property') : getAttr(tag, 'name');
|
|
22
|
+
if (!key || key !== name) return false;
|
|
23
|
+
const content = getAttr(tag, 'content');
|
|
24
|
+
return !!content;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the `content` value for a meta tag by name/property.
|
|
30
|
+
*/
|
|
31
|
+
export function getMetaContent(html, name, isProperty = false) {
|
|
32
|
+
const tags = html.match(/<meta\b[^>]*>/gi) || [];
|
|
33
|
+
for (const tag of tags) {
|
|
34
|
+
const key = isProperty ? getAttr(tag, 'property') : getAttr(tag, 'name');
|
|
35
|
+
if (!key || key !== name) continue;
|
|
36
|
+
const content = getAttr(tag, 'content');
|
|
37
|
+
if (content) return content;
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks that a canonical link tag exists with a non-empty href.
|
|
44
|
+
*/
|
|
45
|
+
export function hasCanonical(html) {
|
|
46
|
+
const links = html.match(/<link\b[^>]*>/gi) || [];
|
|
47
|
+
return links.some((tag) => {
|
|
48
|
+
const rel = getAttr(tag, 'rel');
|
|
49
|
+
if (!rel || rel.toLowerCase() !== 'canonical') return false;
|
|
50
|
+
const href = getAttr(tag, 'href');
|
|
51
|
+
return !!href;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extracts the document title text.
|
|
57
|
+
*/
|
|
58
|
+
export function getTitleContent(html) {
|
|
59
|
+
const match = html.match(/<title>([^<]+)<\/title>/i);
|
|
60
|
+
return match?.[1]?.trim() ?? '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validates optional length ranges for title/description fields.
|
|
65
|
+
*/
|
|
66
|
+
export function validateLengthRange({ value, min = 1, max = Infinity, fieldLabel }) {
|
|
67
|
+
if (!value) return;
|
|
68
|
+
if (value.length >= min && value.length <= max) return;
|
|
69
|
+
return `Recommended ${fieldLabel} length is ${min}-${max}. Current: ${value.length}.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Evaluates all mandatory metadata checks for a page.
|
|
74
|
+
*/
|
|
75
|
+
export function validateRequiredMeta(html) {
|
|
76
|
+
const warnings = [];
|
|
77
|
+
|
|
78
|
+
for (const item of REQUIRED_META_CHECKS) {
|
|
79
|
+
if (!item.check(html)) {
|
|
80
|
+
warnings.push(`Missing ${item.label}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return warnings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Runs required and optional SEO metadata checks for one HTML string.
|
|
89
|
+
*/
|
|
90
|
+
export function validateHtmlMeta(
|
|
91
|
+
html,
|
|
92
|
+
{
|
|
93
|
+
metaTitleMinLength,
|
|
94
|
+
metaTitleMaxLength,
|
|
95
|
+
metaDescriptionMinLength,
|
|
96
|
+
metaDescriptionMaxLength,
|
|
97
|
+
} = {}
|
|
98
|
+
) {
|
|
99
|
+
const warnings = [];
|
|
100
|
+
|
|
101
|
+
warnings.push(...validateRequiredMeta(html));
|
|
102
|
+
|
|
103
|
+
warnings.push(
|
|
104
|
+
validateLengthRange({
|
|
105
|
+
value: getTitleContent(html),
|
|
106
|
+
min: metaTitleMinLength,
|
|
107
|
+
max: metaTitleMaxLength,
|
|
108
|
+
fieldLabel: 'meta title',
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
warnings.push(
|
|
113
|
+
validateLengthRange({
|
|
114
|
+
value: getMetaContent(html, 'description'),
|
|
115
|
+
min: metaDescriptionMinLength,
|
|
116
|
+
max: metaDescriptionMaxLength,
|
|
117
|
+
fieldLabel: 'meta description',
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return warnings.filter(Boolean);
|
|
122
|
+
}
|
package/src/validator.mjs
CHANGED
|
@@ -1,98 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { validateLinks } from './validators/links.mjs';
|
|
3
|
-
import { validateMeta } from './validators/meta.mjs';
|
|
1
|
+
import { runHtmlValidation } from './utils/common.mjs';
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
4
|
+
* Base validator with shared execution flow for HTML page-by-page checks.
|
|
7
5
|
*/
|
|
8
6
|
export class Validator {
|
|
9
|
-
constructor({ dirPath, config = {}, print = true } = {}) {
|
|
10
|
-
this.dirPath = dirPath;
|
|
11
|
-
|
|
12
|
-
this.validators = {
|
|
13
|
-
jsonld: {
|
|
14
|
-
label: 'JSON-LD',
|
|
15
|
-
run: validateJsonld,
|
|
16
|
-
config: config.jsonld,
|
|
17
|
-
},
|
|
18
|
-
links: {
|
|
19
|
-
label: 'Internal links',
|
|
20
|
-
run: validateLinks,
|
|
21
|
-
config: config.links,
|
|
22
|
-
},
|
|
23
|
-
meta: {
|
|
24
|
-
label: 'SEO metadata',
|
|
25
|
-
run: validateMeta,
|
|
26
|
-
config: config.meta,
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
this.print = print;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Resolves a selector string into a unique list of validator names.
|
|
35
|
-
*/
|
|
36
|
-
selectValidators(selector = 'all') {
|
|
37
|
-
const clean = selector.trim().toLowerCase();
|
|
38
|
-
|
|
39
|
-
if (clean === 'all') return Object.keys(this.validators);
|
|
40
|
-
|
|
41
|
-
const selected = clean
|
|
42
|
-
.split(',')
|
|
43
|
-
.map((item) => item.trim())
|
|
44
|
-
.filter(Boolean);
|
|
45
|
-
|
|
46
|
-
const invalid = selected.filter((name) => !this.validators[name]);
|
|
47
|
-
if (invalid.length > 0) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`Unknown validators: ${invalid.join(', ')}. ` +
|
|
50
|
-
`Valid options: all, ${Object.keys(this.validators).join(', ')}`
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return [...new Set(selected)];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
7
|
/**
|
|
58
|
-
*
|
|
8
|
+
* Initializes shared validator metadata and config.
|
|
59
9
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (result.warnings.length === 0) {
|
|
65
|
-
console.log('✅ No warnings.');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
10
|
+
constructor({ name, label, config = {} } = {}) {
|
|
11
|
+
if (!name) throw new Error('Validator name is required.');
|
|
12
|
+
if (!label) throw new Error('Validator label is required.');
|
|
68
13
|
|
|
69
|
-
|
|
70
|
-
|
|
14
|
+
this.name = name;
|
|
15
|
+
this.label = label;
|
|
16
|
+
this.config = config;
|
|
71
17
|
}
|
|
72
18
|
|
|
73
19
|
/**
|
|
74
|
-
* Runs
|
|
20
|
+
* Runs full validation over all HTML pages.
|
|
75
21
|
*/
|
|
76
|
-
async
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
22
|
+
async validate(dirPath) {
|
|
23
|
+
const { checkedPages, warnings } = await runHtmlValidation({
|
|
24
|
+
dirPath,
|
|
25
|
+
validatePage: (pageContext) => this.validatePage({ ...pageContext, dirPath }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: this.name,
|
|
30
|
+
label: this.label,
|
|
31
|
+
checkedPages,
|
|
32
|
+
warnings,
|
|
33
|
+
};
|
|
82
34
|
}
|
|
83
35
|
|
|
84
36
|
/**
|
|
85
|
-
*
|
|
37
|
+
* Validates one page. Child classes must override this.
|
|
86
38
|
*/
|
|
87
|
-
async
|
|
88
|
-
|
|
89
|
-
const selectedNames = this.selectValidators(selector);
|
|
90
|
-
|
|
91
|
-
for (const name of selectedNames) {
|
|
92
|
-
const result = await this.runValidator(name);
|
|
93
|
-
results.push(result);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return results;
|
|
39
|
+
async validatePage() {
|
|
40
|
+
throw new Error('validatePage() must be implemented by child validators.');
|
|
97
41
|
}
|
|
98
42
|
}
|
|
43
|
+
|
|
44
|
+
export default Validator;
|
|
@@ -1,74 +1,91 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Validator } from '../validator.mjs';
|
|
2
|
+
import {
|
|
3
|
+
collectInLanguageValues,
|
|
4
|
+
extractHtmlLang,
|
|
5
|
+
getGraphNodes,
|
|
6
|
+
getJsonLdBlocks,
|
|
7
|
+
hasEmptyInLanguage,
|
|
8
|
+
hasHtmlLang,
|
|
9
|
+
} from '../utils/jsonld.mjs';
|
|
2
10
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
export class JsonldValidator extends Validator {
|
|
12
|
+
/**
|
|
13
|
+
* Stores JSON-LD language consistency options for this validator instance.
|
|
14
|
+
*/
|
|
15
|
+
constructor({
|
|
16
|
+
requireHtmlLang = false,
|
|
17
|
+
requireInLanguage = false,
|
|
18
|
+
disallowEmptyInLanguage = false,
|
|
19
|
+
requireLangMatch = false,
|
|
20
|
+
} = {}) {
|
|
21
|
+
super({
|
|
22
|
+
name: 'jsonld',
|
|
23
|
+
label: 'JSON-LD',
|
|
24
|
+
config: {
|
|
25
|
+
requireHtmlLang,
|
|
26
|
+
requireInLanguage,
|
|
27
|
+
disallowEmptyInLanguage,
|
|
28
|
+
requireLangMatch,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Applies language-related JSON-LD checks for one parsed HTML page.
|
|
35
|
+
*/
|
|
36
|
+
validateJsonLdLanguage({ html, nodes }) {
|
|
37
|
+
const warnings = [];
|
|
38
|
+
const htmlLang = extractHtmlLang(html);
|
|
39
|
+
const inLanguageValues = collectInLanguageValues(nodes);
|
|
10
40
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
blocks.push(JSON.parse(raw));
|
|
16
|
-
} catch {
|
|
17
|
-
blocks.push({ __parseError: true, __raw: raw });
|
|
41
|
+
if (this.config.requireHtmlLang && !htmlLang) {
|
|
42
|
+
warnings.push('Missing <html lang="..."> value to validate JSON-LD language consistency.');
|
|
43
|
+
return warnings;
|
|
18
44
|
}
|
|
19
|
-
}
|
|
20
45
|
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
if (this.config.requireInLanguage && inLanguageValues.length === 0) {
|
|
47
|
+
warnings.push('No inLanguage property was found in JSON-LD.');
|
|
48
|
+
return warnings;
|
|
49
|
+
}
|
|
23
50
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (Array.isArray(block['@graph'])) nodes.push(...block['@graph']);
|
|
32
|
-
else nodes.push(block);
|
|
33
|
-
}
|
|
34
|
-
return nodes;
|
|
35
|
-
}
|
|
51
|
+
if (this.config.disallowEmptyInLanguage && hasEmptyInLanguage(inLanguageValues)) {
|
|
52
|
+
warnings.push('Found empty or null inLanguage value(s) in JSON-LD.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (this.config.requireLangMatch && htmlLang && !hasHtmlLang(inLanguageValues, htmlLang)) {
|
|
56
|
+
warnings.push(`No JSON-LD inLanguage matches <html lang="${htmlLang}">.`);
|
|
57
|
+
}
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*/
|
|
40
|
-
export async function validateJsonld(dirPath) {
|
|
41
|
-
const { checkedPages, warnings } = await runHtmlValidation({
|
|
42
|
-
dirPath,
|
|
43
|
-
validatePage: ({ html }) => {
|
|
44
|
-
const pageWarnings = [];
|
|
45
|
-
const blocks = getJsonLdBlocks(html);
|
|
59
|
+
return warnings;
|
|
60
|
+
}
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Validates JSON-LD structure and optional language consistency for one page.
|
|
64
|
+
*/
|
|
65
|
+
validatePage({ html }) {
|
|
66
|
+
const pageWarnings = [];
|
|
67
|
+
const blocks = getJsonLdBlocks(html);
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
if (blocks.length === 0) {
|
|
70
|
+
pageWarnings.push('No JSON-LD block was found.');
|
|
71
|
+
return pageWarnings;
|
|
72
|
+
}
|
|
56
73
|
|
|
57
|
-
|
|
74
|
+
if (blocks.some((b) => b.__parseError)) {
|
|
75
|
+
pageWarnings.push('At least one JSON-LD block has invalid JSON.');
|
|
76
|
+
return pageWarnings;
|
|
77
|
+
}
|
|
58
78
|
|
|
59
|
-
|
|
60
|
-
pageWarnings.push('JSON-LD exists but has no nodes in @graph.');
|
|
61
|
-
return pageWarnings;
|
|
62
|
-
}
|
|
79
|
+
const nodes = getGraphNodes(blocks);
|
|
63
80
|
|
|
81
|
+
if (nodes.length === 0) {
|
|
82
|
+
pageWarnings.push('JSON-LD exists but has no nodes in @graph.');
|
|
64
83
|
return pageWarnings;
|
|
65
|
-
}
|
|
66
|
-
});
|
|
84
|
+
}
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
checkedPages,
|
|
72
|
-
warnings,
|
|
73
|
-
};
|
|
86
|
+
pageWarnings.push(...this.validateJsonLdLanguage({ html, nodes }));
|
|
87
|
+
return pageWarnings;
|
|
88
|
+
}
|
|
74
89
|
}
|
|
90
|
+
|
|
91
|
+
export default JsonldValidator
|
package/src/validators/links.mjs
CHANGED
|
@@ -1,81 +1,41 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { Validator } from '../validator.mjs';
|
|
2
|
+
import { extractInternalUrls, internalUrlExists } from '../utils/links.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Reports broken internal links for each generated HTML page.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
export class LinksValidator extends Validator {
|
|
8
|
+
/**
|
|
9
|
+
* Initializes link validator configuration (reserved for future rules).
|
|
10
|
+
*/
|
|
11
|
+
constructor({
|
|
12
|
+
absoluteUrlPrefixes = [],
|
|
13
|
+
} = {}) {
|
|
14
|
+
super({
|
|
15
|
+
name: 'links',
|
|
16
|
+
label: 'Internal links',
|
|
17
|
+
config: {
|
|
18
|
+
absoluteUrlPrefixes,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
) {
|
|
26
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Validates internal link targets for one HTML page.
|
|
25
|
+
*/
|
|
26
|
+
async validatePage({ html, dirPath }) {
|
|
27
|
+
const pageWarnings = [];
|
|
28
|
+
const urls = extractInternalUrls(html, {
|
|
29
|
+
absoluteUrlPrefixes: this.config.absoluteUrlPrefixes,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
for (const url of urls) {
|
|
33
|
+
const exists = await internalUrlExists(dirPath, url);
|
|
34
|
+
if (!exists) pageWarnings.push(`Internal link not found: ${url}`);
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
const clean = raw.split('#')[0].split('?')[0];
|
|
31
|
-
if (clean) urls.add(clean);
|
|
32
|
-
}
|
|
37
|
+
return pageWarnings;
|
|
33
38
|
}
|
|
34
|
-
|
|
35
|
-
return [...urls];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Checks whether an internal URL resolves to an HTML file in dist.
|
|
40
|
-
*/
|
|
41
|
-
async function internalUrlExists(dirPath, urlPath) {
|
|
42
|
-
if (urlPath === '/') return pathExists(path.join(dirPath, 'index.html'));
|
|
43
|
-
|
|
44
|
-
const asFile = path.join(dirPath, urlPath.replace(/^\//, ''));
|
|
45
|
-
if (await pathExists(asFile)) return true;
|
|
46
|
-
|
|
47
|
-
const asIndex = path.join(dirPath, urlPath.replace(/^\//, ''), 'index.html');
|
|
48
|
-
if (await pathExists(asIndex)) return true;
|
|
49
|
-
|
|
50
|
-
const asHtml = path.join(dirPath, `${urlPath.replace(/^\//, '')}.html`);
|
|
51
|
-
if (await pathExists(asHtml)) return true;
|
|
52
|
-
|
|
53
|
-
return false;
|
|
54
39
|
}
|
|
55
40
|
|
|
56
|
-
|
|
57
|
-
* Reports broken internal links for each generated HTML page.
|
|
58
|
-
*/
|
|
59
|
-
export async function validateLinks(dirPath) {
|
|
60
|
-
const { checkedPages, warnings } = await runHtmlValidation({
|
|
61
|
-
dirPath,
|
|
62
|
-
validatePage: async ({ html }) => {
|
|
63
|
-
const pageWarnings = [];
|
|
64
|
-
const urls = extractInternalUrls(html);
|
|
65
|
-
|
|
66
|
-
for (const url of urls) {
|
|
67
|
-
const exists = await internalUrlExists(dirPath, url);
|
|
68
|
-
if (!exists) pageWarnings.push(`Internal link not found: ${url}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return pageWarnings;
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
name: 'links',
|
|
77
|
-
label: 'Internal links',
|
|
78
|
-
checkedPages,
|
|
79
|
-
warnings,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
41
|
+
export default LinksValidator
|
package/src/validators/meta.mjs
CHANGED
|
@@ -1,130 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
// Core metadata tags expected on every page.
|
|
4
|
-
const REQUIRED_META_CHECKS = [
|
|
5
|
-
{ label: 'meta title', check: getTitleContent },
|
|
6
|
-
{ label: 'meta description', check: (html) => hasMeta(html, 'description') },
|
|
7
|
-
{ label: 'canonical', check: hasCanonical },
|
|
8
|
-
{ label: 'meta robots', check: (html) => hasMeta(html, 'robots') },
|
|
9
|
-
{ label: 'og:title', check: (html) => hasMeta(html, 'og:title', true) },
|
|
10
|
-
{ label: 'og:description', check: (html) => hasMeta(html, 'og:description', true) },
|
|
11
|
-
{ label: 'og:url', check: (html) => hasMeta(html, 'og:url', true) },
|
|
12
|
-
{ label: 'og:type', check: (html) => hasMeta(html, 'og:type', true) },
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
function hasMeta(html, name, isProperty = false) {
|
|
16
|
-
const tags = html.match(/<meta\b[^>]*>/gi) || [];
|
|
17
|
-
return tags.some((tag) => {
|
|
18
|
-
const key = isProperty ? getAttr(tag, 'property') : getAttr(tag, 'name');
|
|
19
|
-
if (!key || key !== name) return false;
|
|
20
|
-
const content = getAttr(tag, 'content');
|
|
21
|
-
return !!content;
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Returns the `content` value for a meta tag by name/property.
|
|
27
|
-
*/
|
|
28
|
-
function getMetaContent(html, name, isProperty = false) {
|
|
29
|
-
const tags = html.match(/<meta\b[^>]*>/gi) || [];
|
|
30
|
-
for (const tag of tags) {
|
|
31
|
-
const key = isProperty ? getAttr(tag, 'property') : getAttr(tag, 'name');
|
|
32
|
-
if (!key || key !== name) continue;
|
|
33
|
-
const content = getAttr(tag, 'content');
|
|
34
|
-
if (content) return content;
|
|
35
|
-
}
|
|
36
|
-
return '';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Checks that a canonical link tag exists with a non-empty href.
|
|
41
|
-
*/
|
|
42
|
-
function hasCanonical(html) {
|
|
43
|
-
const links = html.match(/<link\b[^>]*>/gi) || [];
|
|
44
|
-
return links.some((tag) => {
|
|
45
|
-
const rel = getAttr(tag, 'rel');
|
|
46
|
-
if (!rel || rel.toLowerCase() !== 'canonical') return false;
|
|
47
|
-
const href = getAttr(tag, 'href');
|
|
48
|
-
return !!href;
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function getTitleContent(html) {
|
|
53
|
-
const match = html.match(/<title>([^<]+)<\/title>/i);
|
|
54
|
-
return match?.[1]?.trim() ?? '';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Validates optional length ranges for title/description fields.
|
|
59
|
-
*/
|
|
60
|
-
function validateLengthRange({ value, min = 1, max = Infinity, fieldLabel }) {
|
|
61
|
-
if (!value) return;
|
|
62
|
-
if (value.length >= min && value.length <= max) return;
|
|
63
|
-
return `Recommended ${fieldLabel} length is ${min}-${max}. Current: ${value.length}.`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function validateRequiredMeta(html) {
|
|
67
|
-
const warnings = [];
|
|
68
|
-
|
|
69
|
-
for (const item of REQUIRED_META_CHECKS) {
|
|
70
|
-
if (!item.check(html)) {
|
|
71
|
-
warnings.push(`Missing ${item.label}.`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return warnings;
|
|
76
|
-
}
|
|
1
|
+
import { Validator } from '../validator.mjs';
|
|
2
|
+
import { validateHtmlMeta } from '../utils/meta.mjs';
|
|
77
3
|
|
|
78
4
|
/**
|
|
79
|
-
*
|
|
5
|
+
* Validates required SEO metadata and optional length recommendations.
|
|
80
6
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
7
|
+
export class MetaValidator extends Validator {
|
|
8
|
+
/**
|
|
9
|
+
* Stores metadata validation thresholds for this validator instance.
|
|
10
|
+
*/
|
|
11
|
+
constructor({
|
|
84
12
|
metaTitleMinLength,
|
|
85
13
|
metaTitleMaxLength,
|
|
86
14
|
metaDescriptionMinLength,
|
|
87
15
|
metaDescriptionMaxLength,
|
|
88
|
-
} = {}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
})
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
warnings.push(
|
|
104
|
-
validateLengthRange({
|
|
105
|
-
value: getMetaContent(html, 'description'),
|
|
106
|
-
min: metaDescriptionMinLength,
|
|
107
|
-
max: metaDescriptionMaxLength,
|
|
108
|
-
fieldLabel: 'meta description',
|
|
109
|
-
})
|
|
110
|
-
);
|
|
16
|
+
} = {}) {
|
|
17
|
+
super({
|
|
18
|
+
name: 'meta',
|
|
19
|
+
label: 'SEO metadata',
|
|
20
|
+
config: {
|
|
21
|
+
metaTitleMinLength,
|
|
22
|
+
metaTitleMaxLength,
|
|
23
|
+
metaDescriptionMinLength,
|
|
24
|
+
metaDescriptionMaxLength,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
111
28
|
|
|
112
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Validates metadata rules for one HTML page.
|
|
31
|
+
*/
|
|
32
|
+
validatePage({ html }) {
|
|
33
|
+
return validateHtmlMeta(html, this.config);
|
|
34
|
+
}
|
|
113
35
|
}
|
|
114
36
|
|
|
115
|
-
|
|
116
|
-
* Validates SEO metadata for every HTML page in dist.
|
|
117
|
-
*/
|
|
118
|
-
export async function validateMeta(dirPath, options) {
|
|
119
|
-
const { checkedPages, warnings } = await runHtmlValidation({
|
|
120
|
-
dirPath,
|
|
121
|
-
validatePage: ({ html }) => validateHtmlMeta(html, options),
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
name: 'meta',
|
|
126
|
-
label: 'SEO metadata',
|
|
127
|
-
checkedPages,
|
|
128
|
-
warnings,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
37
|
+
export default MetaValidator
|
|
File without changes
|