@nuasite/checks 0.16.0
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/dist/types/check-runner.d.ts +16 -0
- package/dist/types/check-runner.d.ts.map +1 -0
- package/dist/types/checks/accessibility/aria-landmarks-check.d.ts +3 -0
- package/dist/types/checks/accessibility/aria-landmarks-check.d.ts.map +1 -0
- package/dist/types/checks/accessibility/form-label-check.d.ts +3 -0
- package/dist/types/checks/accessibility/form-label-check.d.ts.map +1 -0
- package/dist/types/checks/accessibility/index.d.ts +6 -0
- package/dist/types/checks/accessibility/index.d.ts.map +1 -0
- package/dist/types/checks/accessibility/lang-attribute-check.d.ts +3 -0
- package/dist/types/checks/accessibility/lang-attribute-check.d.ts.map +1 -0
- package/dist/types/checks/accessibility/link-text-check.d.ts +3 -0
- package/dist/types/checks/accessibility/link-text-check.d.ts.map +1 -0
- package/dist/types/checks/accessibility/tabindex-check.d.ts +3 -0
- package/dist/types/checks/accessibility/tabindex-check.d.ts.map +1 -0
- package/dist/types/checks/geo/agents-md-check.d.ts.map +1 -0
- package/dist/types/checks/geo/content-quality-check.d.ts +4 -0
- package/dist/types/checks/geo/content-quality-check.d.ts.map +1 -0
- package/dist/types/checks/geo/index.d.ts +3 -0
- package/dist/types/checks/geo/index.d.ts.map +1 -0
- package/dist/types/checks/geo/llms-txt-check.d.ts +3 -0
- package/dist/types/checks/geo/llms-txt-check.d.ts.map +1 -0
- package/dist/types/checks/performance/html-size-check.d.ts +3 -0
- package/dist/types/checks/performance/html-size-check.d.ts.map +1 -0
- package/dist/types/checks/performance/image-optimization-check.d.ts +4 -0
- package/dist/types/checks/performance/image-optimization-check.d.ts.map +1 -0
- package/dist/types/checks/performance/index.d.ts +7 -0
- package/dist/types/checks/performance/index.d.ts.map +1 -0
- package/dist/types/checks/performance/inline-size-check.d.ts +3 -0
- package/dist/types/checks/performance/inline-size-check.d.ts.map +1 -0
- package/dist/types/checks/performance/lazy-loading-check.d.ts +3 -0
- package/dist/types/checks/performance/lazy-loading-check.d.ts.map +1 -0
- package/dist/types/checks/performance/render-blocking-check.d.ts +3 -0
- package/dist/types/checks/performance/render-blocking-check.d.ts.map +1 -0
- package/dist/types/checks/performance/total-requests-check.d.ts +3 -0
- package/dist/types/checks/performance/total-requests-check.d.ts.map +1 -0
- package/dist/types/checks/seo/broken-internal-links-check.d.ts +3 -0
- package/dist/types/checks/seo/broken-internal-links-check.d.ts.map +1 -0
- package/dist/types/checks/seo/canonical-check.d.ts +5 -0
- package/dist/types/checks/seo/canonical-check.d.ts.map +1 -0
- package/dist/types/checks/seo/description-check.d.ts +4 -0
- package/dist/types/checks/seo/description-check.d.ts.map +1 -0
- package/dist/types/checks/seo/heading-hierarchy-check.d.ts +5 -0
- package/dist/types/checks/seo/heading-hierarchy-check.d.ts.map +1 -0
- package/dist/types/checks/seo/image-alt-check.d.ts +3 -0
- package/dist/types/checks/seo/image-alt-check.d.ts.map +1 -0
- package/dist/types/checks/seo/image-alt-quality-check.d.ts +3 -0
- package/dist/types/checks/seo/image-alt-quality-check.d.ts.map +1 -0
- package/dist/types/checks/seo/index.d.ts +15 -0
- package/dist/types/checks/seo/index.d.ts.map +1 -0
- package/dist/types/checks/seo/json-ld-check.d.ts +3 -0
- package/dist/types/checks/seo/json-ld-check.d.ts.map +1 -0
- package/dist/types/checks/seo/meta-duplicates-check.d.ts +3 -0
- package/dist/types/checks/seo/meta-duplicates-check.d.ts.map +1 -0
- package/dist/types/checks/seo/noindex-check.d.ts +3 -0
- package/dist/types/checks/seo/noindex-check.d.ts.map +1 -0
- package/dist/types/checks/seo/open-graph-check.d.ts +5 -0
- package/dist/types/checks/seo/open-graph-check.d.ts.map +1 -0
- package/dist/types/checks/seo/sitemap-robots-check.d.ts +4 -0
- package/dist/types/checks/seo/sitemap-robots-check.d.ts.map +1 -0
- package/dist/types/checks/seo/title-check.d.ts +5 -0
- package/dist/types/checks/seo/title-check.d.ts.map +1 -0
- package/dist/types/checks/seo/twitter-card-check.d.ts +3 -0
- package/dist/types/checks/seo/twitter-card-check.d.ts.map +1 -0
- package/dist/types/checks/seo/viewport-check.d.ts +3 -0
- package/dist/types/checks/seo/viewport-check.d.ts.map +1 -0
- package/dist/types/checks-integration.d.ts +4 -0
- package/dist/types/checks-integration.d.ts.map +1 -0
- package/dist/types/config.d.ts +3 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/html-analyzer.d.ts +11 -0
- package/dist/types/html-analyzer.d.ts.map +1 -0
- package/dist/types/i18n/poor-texts.d.ts +14 -0
- package/dist/types/i18n/poor-texts.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/register.d.ts +4 -0
- package/dist/types/register.d.ts.map +1 -0
- package/dist/types/report.d.ts +5 -0
- package/dist/types/report.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/dist/types/types.d.ts +220 -0
- package/dist/types/types.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/check-runner.ts +92 -0
- package/src/checks/accessibility/aria-landmarks-check.ts +23 -0
- package/src/checks/accessibility/form-label-check.ts +29 -0
- package/src/checks/accessibility/index.ts +5 -0
- package/src/checks/accessibility/lang-attribute-check.ts +22 -0
- package/src/checks/accessibility/link-text-check.ts +30 -0
- package/src/checks/accessibility/tabindex-check.ts +28 -0
- package/src/checks/geo/content-quality-check.ts +48 -0
- package/src/checks/geo/index.ts +2 -0
- package/src/checks/geo/llms-txt-check.ts +37 -0
- package/src/checks/performance/html-size-check.ts +26 -0
- package/src/checks/performance/image-optimization-check.ts +82 -0
- package/src/checks/performance/index.ts +6 -0
- package/src/checks/performance/inline-size-check.ts +29 -0
- package/src/checks/performance/lazy-loading-check.ts +29 -0
- package/src/checks/performance/render-blocking-check.ts +27 -0
- package/src/checks/performance/total-requests-check.ts +27 -0
- package/src/checks/seo/broken-internal-links-check.ts +49 -0
- package/src/checks/seo/canonical-check.ts +77 -0
- package/src/checks/seo/description-check.ts +55 -0
- package/src/checks/seo/heading-hierarchy-check.ts +76 -0
- package/src/checks/seo/image-alt-check.ts +28 -0
- package/src/checks/seo/image-alt-quality-check.ts +31 -0
- package/src/checks/seo/index.ts +14 -0
- package/src/checks/seo/json-ld-check.ts +26 -0
- package/src/checks/seo/meta-duplicates-check.ts +44 -0
- package/src/checks/seo/noindex-check.ts +22 -0
- package/src/checks/seo/open-graph-check.ts +55 -0
- package/src/checks/seo/sitemap-robots-check.ts +55 -0
- package/src/checks/seo/title-check.ts +63 -0
- package/src/checks/seo/twitter-card-check.ts +22 -0
- package/src/checks/seo/viewport-check.ts +22 -0
- package/src/checks-integration.ts +126 -0
- package/src/config.ts +27 -0
- package/src/html-analyzer.ts +325 -0
- package/src/i18n/poor-texts.ts +66 -0
- package/src/index.ts +22 -0
- package/src/register.ts +110 -0
- package/src/report.ts +78 -0
- package/src/tsconfig.json +6 -0
- package/src/types.ts +244 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
4
|
+
|
|
5
|
+
function isExternalOrDataUrl(src: string): boolean {
|
|
6
|
+
return src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createImageFormatCheck(allowedFormats: string[]): Check {
|
|
10
|
+
return {
|
|
11
|
+
kind: 'page',
|
|
12
|
+
id: 'performance/image-format',
|
|
13
|
+
name: 'Image Format',
|
|
14
|
+
domain: 'performance',
|
|
15
|
+
defaultSeverity: 'info',
|
|
16
|
+
description: `Images should use modern formats: ${allowedFormats.join(', ')}`,
|
|
17
|
+
essential: false,
|
|
18
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
19
|
+
const results: CheckIssue[] = []
|
|
20
|
+
for (const img of ctx.pageData.images) {
|
|
21
|
+
if (isExternalOrDataUrl(img.src)) continue
|
|
22
|
+
const ext = path.extname(img.src).toLowerCase().replace('.', '')
|
|
23
|
+
if (ext && !allowedFormats.includes(ext)) {
|
|
24
|
+
results.push({
|
|
25
|
+
message: `Image "${img.src}" uses .${ext} format instead of a modern format`,
|
|
26
|
+
suggestion: `Convert to ${allowedFormats.join(' or ')} for better compression`,
|
|
27
|
+
line: img.line,
|
|
28
|
+
actual: ext,
|
|
29
|
+
expected: allowedFormats.join(', '),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return results
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createImageSizeCheck(maxSize: number): Check {
|
|
39
|
+
const sizeCache = new Map<string, number | null>()
|
|
40
|
+
return {
|
|
41
|
+
kind: 'page',
|
|
42
|
+
id: 'performance/image-size',
|
|
43
|
+
name: 'Image File Size',
|
|
44
|
+
domain: 'performance',
|
|
45
|
+
defaultSeverity: 'warning',
|
|
46
|
+
description: `Image files should be under ${(maxSize / 1024).toFixed(0)} KB`,
|
|
47
|
+
essential: false,
|
|
48
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
49
|
+
const results: CheckIssue[] = []
|
|
50
|
+
for (const img of ctx.pageData.images) {
|
|
51
|
+
if (isExternalOrDataUrl(img.src)) continue
|
|
52
|
+
const imgPath = img.src.startsWith('/')
|
|
53
|
+
? path.join(ctx.distDir, img.src)
|
|
54
|
+
: path.resolve(path.dirname(ctx.filePath), img.src)
|
|
55
|
+
let size: number | null
|
|
56
|
+
if (sizeCache.has(imgPath)) {
|
|
57
|
+
size = sizeCache.get(imgPath)!
|
|
58
|
+
} else {
|
|
59
|
+
try {
|
|
60
|
+
size = statSync(imgPath).size
|
|
61
|
+
} catch {
|
|
62
|
+
size = null
|
|
63
|
+
}
|
|
64
|
+
sizeCache.set(imgPath, size)
|
|
65
|
+
}
|
|
66
|
+
if (size === null) continue
|
|
67
|
+
if (size > maxSize) {
|
|
68
|
+
const actualKB = (size / 1024).toFixed(1)
|
|
69
|
+
const maxKB = (maxSize / 1024).toFixed(1)
|
|
70
|
+
results.push({
|
|
71
|
+
message: `Image "${img.src}" is ${actualKB} KB (max: ${maxKB} KB)`,
|
|
72
|
+
suggestion: 'Compress the image or use a smaller resolution',
|
|
73
|
+
line: img.line,
|
|
74
|
+
actual: `${actualKB} KB`,
|
|
75
|
+
expected: `<= ${maxKB} KB`,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return results
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createHtmlSizeCheck } from './html-size-check'
|
|
2
|
+
export { createImageFormatCheck, createImageSizeCheck } from './image-optimization-check'
|
|
3
|
+
export { createInlineSizeCheck } from './inline-size-check'
|
|
4
|
+
export { createLazyLoadingCheck } from './lazy-loading-check'
|
|
5
|
+
export { createRenderBlockingScriptCheck } from './render-blocking-check'
|
|
6
|
+
export { createTotalRequestsCheck } from './total-requests-check'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createInlineSizeCheck(maxSize: number): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'performance/inline-size',
|
|
7
|
+
name: 'Inline Resource Size',
|
|
8
|
+
domain: 'performance',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: `Inline scripts and styles should total under ${(maxSize / 1024).toFixed(0)} KB`,
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const totalInline = ctx.pageData.inlineScriptBytes + ctx.pageData.inlineStyleBytes
|
|
14
|
+
if (totalInline > maxSize) {
|
|
15
|
+
const actualKB = (totalInline / 1024).toFixed(1)
|
|
16
|
+
const maxKB = (maxSize / 1024).toFixed(1)
|
|
17
|
+
const scriptKB = (ctx.pageData.inlineScriptBytes / 1024).toFixed(1)
|
|
18
|
+
const styleKB = (ctx.pageData.inlineStyleBytes / 1024).toFixed(1)
|
|
19
|
+
return [{
|
|
20
|
+
message: `Inline resources total ${actualKB} KB (scripts: ${scriptKB} KB, styles: ${styleKB} KB, max: ${maxKB} KB)`,
|
|
21
|
+
suggestion: 'Move large inline scripts and styles to external files for better caching',
|
|
22
|
+
actual: `${actualKB} KB`,
|
|
23
|
+
expected: `<= ${maxKB} KB`,
|
|
24
|
+
}]
|
|
25
|
+
}
|
|
26
|
+
return []
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createLazyLoadingCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'performance/lazy-loading',
|
|
7
|
+
name: 'Lazy Loading',
|
|
8
|
+
domain: 'performance',
|
|
9
|
+
defaultSeverity: 'info',
|
|
10
|
+
description: 'Below-the-fold images should use loading="lazy"',
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const results: CheckIssue[] = []
|
|
14
|
+
const images = ctx.pageData.images
|
|
15
|
+
for (let i = 2; i < images.length; i++) {
|
|
16
|
+
const img = images[i]!
|
|
17
|
+
if (img.loading === 'eager') continue
|
|
18
|
+
if (img.loading !== 'lazy') {
|
|
19
|
+
results.push({
|
|
20
|
+
message: `Image "${img.src}" (position ${i + 1}) is missing loading="lazy"`,
|
|
21
|
+
suggestion: 'Add loading="lazy" to below-the-fold images to improve initial page load',
|
|
22
|
+
line: img.line,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return results
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createRenderBlockingScriptCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'performance/render-blocking-script',
|
|
7
|
+
name: 'Render-Blocking Scripts',
|
|
8
|
+
domain: 'performance',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: 'Scripts with src should use async, defer, or type="module" to avoid blocking rendering',
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const results: CheckIssue[] = []
|
|
14
|
+
for (const script of ctx.pageData.scripts) {
|
|
15
|
+
if (!script.src) continue
|
|
16
|
+
if (script.isAsync || script.isDefer) continue
|
|
17
|
+
if (script.type === 'module') continue
|
|
18
|
+
results.push({
|
|
19
|
+
message: `Script "${script.src}" is render-blocking`,
|
|
20
|
+
suggestion: 'Add async, defer, or type="module" to the script tag',
|
|
21
|
+
line: script.line,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
return results
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createTotalRequestsCheck(maxRequests: number): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'performance/total-requests',
|
|
7
|
+
name: 'Total External Requests',
|
|
8
|
+
domain: 'performance',
|
|
9
|
+
defaultSeverity: 'info',
|
|
10
|
+
description: `Page should load fewer than ${maxRequests} external resources`,
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const externalScripts = ctx.pageData.scripts.filter(s => s.src).length
|
|
14
|
+
const externalStyles = ctx.pageData.stylesheets.length
|
|
15
|
+
const total = externalScripts + externalStyles
|
|
16
|
+
if (total > maxRequests) {
|
|
17
|
+
return [{
|
|
18
|
+
message: `Page loads ${total} external resources (${externalScripts} scripts, ${externalStyles} stylesheets, max: ${maxRequests})`,
|
|
19
|
+
suggestion: 'Bundle scripts and stylesheets to reduce the number of HTTP requests',
|
|
20
|
+
actual: `${total} requests`,
|
|
21
|
+
expected: `<= ${maxRequests} requests`,
|
|
22
|
+
}]
|
|
23
|
+
}
|
|
24
|
+
return []
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SiteCheck, SiteCheckContext, SiteCheckIssue } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createBrokenInternalLinksCheck(): SiteCheck {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'site',
|
|
6
|
+
id: 'seo/broken-internal-links',
|
|
7
|
+
name: 'Internal Links Valid',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: 'Internal links should point to existing pages',
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: SiteCheckContext): SiteCheckIssue[] {
|
|
13
|
+
const knownPaths = new Set<string>()
|
|
14
|
+
for (const p of ctx.pages.keys()) {
|
|
15
|
+
knownPaths.add(p)
|
|
16
|
+
const withSlash = p.endsWith('/') ? p : `${p}/`
|
|
17
|
+
const withoutSlash = p.endsWith('/') ? p.slice(0, -1) || '/' : p
|
|
18
|
+
knownPaths.add(withSlash)
|
|
19
|
+
knownPaths.add(withoutSlash)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const results: SiteCheckIssue[] = []
|
|
23
|
+
for (const [pagePath, pageData] of ctx.pages) {
|
|
24
|
+
for (const link of pageData.links) {
|
|
25
|
+
if (!link.href) continue
|
|
26
|
+
if (link.href.startsWith('http://') || link.href.startsWith('https://') || link.href.startsWith('//')) continue
|
|
27
|
+
if (link.href.startsWith('#') || link.href.startsWith('mailto:') || link.href.startsWith('tel:') || link.href.startsWith('javascript:')) continue
|
|
28
|
+
|
|
29
|
+
let targetPath = link.href.split('#')[0]!.split('?')[0]!
|
|
30
|
+
if (!targetPath.startsWith('/')) {
|
|
31
|
+
const base = pagePath.endsWith('/') ? pagePath : pagePath.substring(0, pagePath.lastIndexOf('/') + 1)
|
|
32
|
+
targetPath = base + targetPath
|
|
33
|
+
}
|
|
34
|
+
targetPath = targetPath.replace(/\/+/g, '/')
|
|
35
|
+
|
|
36
|
+
if (!knownPaths.has(targetPath)) {
|
|
37
|
+
results.push({
|
|
38
|
+
message: `Internal link to "${link.href}" does not match any known page`,
|
|
39
|
+
suggestion: 'Fix the link href or create the target page',
|
|
40
|
+
pagePath,
|
|
41
|
+
line: link.line,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return results
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createCanonicalMissingCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'seo/canonical-missing',
|
|
7
|
+
name: 'Canonical URL Present',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: 'Every page should have a canonical URL',
|
|
11
|
+
essential: true,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
if (!ctx.pageData.canonical) {
|
|
14
|
+
return [{ message: 'Page is missing a canonical URL', suggestion: 'Add <link rel="canonical" href="..."> inside <head>' }]
|
|
15
|
+
}
|
|
16
|
+
return []
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createCanonicalMismatchCheck(): Check {
|
|
22
|
+
return {
|
|
23
|
+
kind: 'page',
|
|
24
|
+
id: 'seo/canonical-mismatch',
|
|
25
|
+
name: 'Canonical URL Matches Page',
|
|
26
|
+
domain: 'seo',
|
|
27
|
+
defaultSeverity: 'warning',
|
|
28
|
+
description: 'Canonical URL should point to the current page',
|
|
29
|
+
essential: true,
|
|
30
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
31
|
+
if (!ctx.pageData.canonical) return []
|
|
32
|
+
const { href, line } = ctx.pageData.canonical
|
|
33
|
+
try {
|
|
34
|
+
const canonicalPath = new URL(href).pathname.replace(/\/+$/, '') || '/'
|
|
35
|
+
const pagePath = ctx.pagePath.replace(/\/+$/, '') || '/'
|
|
36
|
+
if (canonicalPath !== pagePath) {
|
|
37
|
+
return [{
|
|
38
|
+
message: 'Canonical URL does not match the page path',
|
|
39
|
+
suggestion: `Update the canonical URL to point to this page's own URL`,
|
|
40
|
+
line,
|
|
41
|
+
actual: href,
|
|
42
|
+
expected: ctx.pagePath,
|
|
43
|
+
}]
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Invalid URL — handled by canonical-invalid check
|
|
47
|
+
}
|
|
48
|
+
return []
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createCanonicalInvalidCheck(): Check {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'page',
|
|
56
|
+
id: 'seo/canonical-invalid',
|
|
57
|
+
name: 'Canonical URL Valid',
|
|
58
|
+
domain: 'seo',
|
|
59
|
+
defaultSeverity: 'error',
|
|
60
|
+
description: 'Canonical URL must be a valid absolute URL',
|
|
61
|
+
essential: true,
|
|
62
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
63
|
+
if (!ctx.pageData.canonical) return []
|
|
64
|
+
const { href, line } = ctx.pageData.canonical
|
|
65
|
+
if (!href.startsWith('http://') && !href.startsWith('https://')) {
|
|
66
|
+
return [{
|
|
67
|
+
message: 'Canonical URL is not a valid absolute URL',
|
|
68
|
+
suggestion: 'Use an absolute URL starting with http:// or https://',
|
|
69
|
+
line,
|
|
70
|
+
actual: href,
|
|
71
|
+
expected: 'Absolute URL (http:// or https://)',
|
|
72
|
+
}]
|
|
73
|
+
}
|
|
74
|
+
return []
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createDescriptionMissingCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'seo/description-missing',
|
|
7
|
+
name: 'Meta Description Present',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: 'Every page should have a meta description',
|
|
11
|
+
essential: true,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
if (!ctx.pageData.metaDescription) {
|
|
14
|
+
return [{ message: 'Page is missing a meta description', suggestion: 'Add <meta name="description" content="..."> inside <head>' }]
|
|
15
|
+
}
|
|
16
|
+
return []
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createDescriptionLengthCheck(minLength: number, maxLength: number): Check {
|
|
22
|
+
return {
|
|
23
|
+
kind: 'page',
|
|
24
|
+
id: 'seo/description-length',
|
|
25
|
+
name: 'Meta Description Length',
|
|
26
|
+
domain: 'seo',
|
|
27
|
+
defaultSeverity: 'warning',
|
|
28
|
+
description: `Meta description should be ${minLength}-${maxLength} characters`,
|
|
29
|
+
essential: true,
|
|
30
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
31
|
+
if (!ctx.pageData.metaDescription) return []
|
|
32
|
+
const { content, line } = ctx.pageData.metaDescription
|
|
33
|
+
const results: CheckIssue[] = []
|
|
34
|
+
|
|
35
|
+
if (content.length > maxLength) {
|
|
36
|
+
results.push({
|
|
37
|
+
message: `Meta description is ${content.length} characters (max: ${maxLength})`,
|
|
38
|
+
suggestion: `Shorten to under ${maxLength} characters`,
|
|
39
|
+
line,
|
|
40
|
+
actual: `${content.length} characters`,
|
|
41
|
+
expected: `${minLength}-${maxLength} characters`,
|
|
42
|
+
})
|
|
43
|
+
} else if (content.length < minLength) {
|
|
44
|
+
results.push({
|
|
45
|
+
message: `Meta description is ${content.length} characters (min: ${minLength})`,
|
|
46
|
+
suggestion: `Expand to at least ${minLength} characters for better SEO`,
|
|
47
|
+
line,
|
|
48
|
+
actual: `${content.length} characters`,
|
|
49
|
+
expected: `${minLength}-${maxLength} characters`,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
return results
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createMultipleH1Check(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'seo/multiple-h1',
|
|
7
|
+
name: 'Single H1',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: 'Each page should have only one <h1> element',
|
|
11
|
+
essential: true,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const h1s = ctx.pageData.headings.filter(h => h.level === 1)
|
|
14
|
+
if (h1s.length > 1) {
|
|
15
|
+
const second = h1s[1]!
|
|
16
|
+
return [{
|
|
17
|
+
message: `Page has ${h1s.length} <h1> elements (expected 1)`,
|
|
18
|
+
suggestion: 'Use only one <h1> per page; use <h2>-<h6> for sub-headings',
|
|
19
|
+
line: second.line,
|
|
20
|
+
actual: `${h1s.length} h1 elements`,
|
|
21
|
+
expected: '1 h1 element',
|
|
22
|
+
}]
|
|
23
|
+
}
|
|
24
|
+
return []
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createNoH1Check(): Check {
|
|
30
|
+
return {
|
|
31
|
+
kind: 'page',
|
|
32
|
+
id: 'seo/no-h1',
|
|
33
|
+
name: 'H1 Present',
|
|
34
|
+
domain: 'seo',
|
|
35
|
+
defaultSeverity: 'warning',
|
|
36
|
+
description: 'Each page should have an <h1> element',
|
|
37
|
+
essential: true,
|
|
38
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
39
|
+
const h1s = ctx.pageData.headings.filter(h => h.level === 1)
|
|
40
|
+
if (h1s.length === 0) {
|
|
41
|
+
return [{ message: 'Page is missing an <h1> element', suggestion: 'Add an <h1> heading to identify the main topic of the page' }]
|
|
42
|
+
}
|
|
43
|
+
return []
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createHeadingSkipCheck(): Check {
|
|
49
|
+
return {
|
|
50
|
+
kind: 'page',
|
|
51
|
+
id: 'seo/heading-skip',
|
|
52
|
+
name: 'Heading Hierarchy',
|
|
53
|
+
domain: 'seo',
|
|
54
|
+
defaultSeverity: 'info',
|
|
55
|
+
description: 'Heading levels should not be skipped (e.g. h1 followed by h3)',
|
|
56
|
+
essential: false,
|
|
57
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
58
|
+
const results: CheckIssue[] = []
|
|
59
|
+
const { headings } = ctx.pageData
|
|
60
|
+
for (let i = 1; i < headings.length; i++) {
|
|
61
|
+
const prev = headings[i - 1]!
|
|
62
|
+
const curr = headings[i]!
|
|
63
|
+
if (curr.level > prev.level + 1) {
|
|
64
|
+
results.push({
|
|
65
|
+
message: `Heading level skipped from <h${prev.level}> to <h${curr.level}>`,
|
|
66
|
+
suggestion: `Use <h${prev.level + 1}> instead of <h${curr.level}>, or add the missing intermediate heading`,
|
|
67
|
+
line: curr.line,
|
|
68
|
+
actual: `h${prev.level} → h${curr.level}`,
|
|
69
|
+
expected: `h${prev.level} → h${prev.level + 1}`,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return results
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createImageAltMissingCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'seo/image-alt-missing',
|
|
7
|
+
name: 'Image Alt Text',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'warning',
|
|
10
|
+
description: 'Images should have an alt attribute',
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const results: CheckIssue[] = []
|
|
14
|
+
for (const image of ctx.pageData.images) {
|
|
15
|
+
if (image.alt === undefined) {
|
|
16
|
+
results.push({
|
|
17
|
+
message: `Image is missing an alt attribute: ${image.src}`,
|
|
18
|
+
suggestion: 'Add an alt attribute describing the image, or alt="" if the image is decorative',
|
|
19
|
+
line: image.line,
|
|
20
|
+
actual: 'no alt attribute',
|
|
21
|
+
expected: 'alt="descriptive text" or alt=""',
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return results
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { buildPoorTextSet, poorImageAlts } from '../../i18n/poor-texts'
|
|
2
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
3
|
+
|
|
4
|
+
export function createImageAltQualityCheck(): Check {
|
|
5
|
+
return {
|
|
6
|
+
kind: 'page',
|
|
7
|
+
id: 'seo/image-alt-quality',
|
|
8
|
+
name: 'Image Alt Text Quality',
|
|
9
|
+
domain: 'seo',
|
|
10
|
+
defaultSeverity: 'info',
|
|
11
|
+
description: 'Image alt text should be descriptive, not generic',
|
|
12
|
+
essential: false,
|
|
13
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
14
|
+
const poorAlts = buildPoorTextSet(poorImageAlts, ctx.pageData.htmlLang)
|
|
15
|
+
const results: CheckIssue[] = []
|
|
16
|
+
for (const image of ctx.pageData.images) {
|
|
17
|
+
if (image.alt === undefined || image.alt === '') continue
|
|
18
|
+
const trimmed = image.alt.trim()
|
|
19
|
+
if (poorAlts.has(trimmed.toLowerCase())) {
|
|
20
|
+
results.push({
|
|
21
|
+
message: `Image has generic alt text "${trimmed}": ${image.src}`,
|
|
22
|
+
suggestion: 'Use descriptive alt text that explains the image content, e.g. "Team photo at annual retreat" instead of "photo"',
|
|
23
|
+
line: image.line,
|
|
24
|
+
actual: trimmed,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return results
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { createBrokenInternalLinksCheck } from './broken-internal-links-check'
|
|
2
|
+
export { createCanonicalInvalidCheck, createCanonicalMismatchCheck, createCanonicalMissingCheck } from './canonical-check'
|
|
3
|
+
export { createDescriptionLengthCheck, createDescriptionMissingCheck } from './description-check'
|
|
4
|
+
export { createHeadingSkipCheck, createMultipleH1Check, createNoH1Check } from './heading-hierarchy-check'
|
|
5
|
+
export { createImageAltMissingCheck } from './image-alt-check'
|
|
6
|
+
export { createImageAltQualityCheck } from './image-alt-quality-check'
|
|
7
|
+
export { createJsonLdInvalidCheck } from './json-ld-check'
|
|
8
|
+
export { createMetaDuplicateCheck } from './meta-duplicates-check'
|
|
9
|
+
export { createNoindexDetectedCheck } from './noindex-check'
|
|
10
|
+
export { createOgDescriptionCheck, createOgImageCheck, createOgTitleCheck } from './open-graph-check'
|
|
11
|
+
export { createRobotsTxtCheck, createSitemapXmlCheck } from './sitemap-robots-check'
|
|
12
|
+
export { createTitleEmptyCheck, createTitleLengthCheck, createTitleMissingCheck } from './title-check'
|
|
13
|
+
export { createTwitterCardCheck } from './twitter-card-check'
|
|
14
|
+
export { createViewportMissingCheck } from './viewport-check'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createJsonLdInvalidCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'seo/json-ld-invalid',
|
|
7
|
+
name: 'JSON-LD Valid',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'error',
|
|
10
|
+
description: 'JSON-LD structured data must be valid',
|
|
11
|
+
essential: true,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
const results: CheckIssue[] = []
|
|
14
|
+
for (const entry of ctx.pageData.jsonLd) {
|
|
15
|
+
if (!entry.valid) {
|
|
16
|
+
results.push({
|
|
17
|
+
message: entry.error ?? `Invalid JSON-LD for type "${entry.type}"`,
|
|
18
|
+
suggestion: 'Fix the JSON-LD structured data so it is valid JSON',
|
|
19
|
+
line: entry.line,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return results
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
const ALLOWED_DUPLICATES = new Set([
|
|
4
|
+
'property:og:image',
|
|
5
|
+
'property:og:image:width',
|
|
6
|
+
'property:og:image:height',
|
|
7
|
+
'property:og:image:alt',
|
|
8
|
+
'property:og:locale:alternate',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
export function createMetaDuplicateCheck(): Check {
|
|
12
|
+
return {
|
|
13
|
+
kind: 'page',
|
|
14
|
+
id: 'seo/meta-duplicate',
|
|
15
|
+
name: 'No Duplicate Meta Tags',
|
|
16
|
+
domain: 'seo',
|
|
17
|
+
defaultSeverity: 'warning',
|
|
18
|
+
description: 'Meta tags should not have duplicate name or property attributes',
|
|
19
|
+
essential: false,
|
|
20
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
21
|
+
const results: CheckIssue[] = []
|
|
22
|
+
const seen = new Map<string, number>()
|
|
23
|
+
|
|
24
|
+
for (const tag of ctx.pageData.metaTags) {
|
|
25
|
+
const key = tag.name ? `name:${tag.name}` : tag.property ? `property:${tag.property}` : null
|
|
26
|
+
if (!key) continue
|
|
27
|
+
if (ALLOWED_DUPLICATES.has(key)) continue
|
|
28
|
+
|
|
29
|
+
const prevLine = seen.get(key)
|
|
30
|
+
if (prevLine !== undefined) {
|
|
31
|
+
const label = tag.name ? `name="${tag.name}"` : `property="${tag.property}"`
|
|
32
|
+
results.push({
|
|
33
|
+
message: `Duplicate meta tag with ${label}`,
|
|
34
|
+
suggestion: `Remove the duplicate <meta ${label}> tag (first seen at line ${prevLine})`,
|
|
35
|
+
line: tag.line,
|
|
36
|
+
})
|
|
37
|
+
} else {
|
|
38
|
+
seen.set(key, tag.line)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return results
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Check, CheckIssue, PageCheckContext } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function createNoindexDetectedCheck(): Check {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'page',
|
|
6
|
+
id: 'seo/noindex-detected',
|
|
7
|
+
name: 'Noindex Detection',
|
|
8
|
+
domain: 'seo',
|
|
9
|
+
defaultSeverity: 'info',
|
|
10
|
+
description: 'Detects pages with noindex meta tag that will be excluded from search engines',
|
|
11
|
+
essential: false,
|
|
12
|
+
run(ctx: PageCheckContext): CheckIssue[] {
|
|
13
|
+
if (ctx.pageData.hasNoindex) {
|
|
14
|
+
return [{
|
|
15
|
+
message: 'Page has a noindex directive and will not appear in search results',
|
|
16
|
+
suggestion: 'Remove the noindex directive if this page should be indexed by search engines',
|
|
17
|
+
}]
|
|
18
|
+
}
|
|
19
|
+
return []
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|