recker 1.0.28 → 1.0.29
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/cli/tui/shell.d.ts +1 -0
- package/dist/cli/tui/shell.js +339 -5
- package/dist/scrape/index.d.ts +2 -0
- package/dist/scrape/index.js +1 -0
- package/dist/scrape/spider.d.ts +61 -0
- package/dist/scrape/spider.js +250 -0
- package/dist/seo/analyzer.js +27 -0
- package/dist/seo/index.d.ts +3 -1
- package/dist/seo/index.js +1 -0
- package/dist/seo/rules/accessibility.js +620 -54
- package/dist/seo/rules/best-practices.d.ts +2 -0
- package/dist/seo/rules/best-practices.js +188 -0
- package/dist/seo/rules/crawl.d.ts +2 -0
- package/dist/seo/rules/crawl.js +307 -0
- package/dist/seo/rules/cwv.d.ts +2 -0
- package/dist/seo/rules/cwv.js +337 -0
- package/dist/seo/rules/ecommerce.d.ts +2 -0
- package/dist/seo/rules/ecommerce.js +252 -0
- package/dist/seo/rules/i18n.d.ts +2 -0
- package/dist/seo/rules/i18n.js +222 -0
- package/dist/seo/rules/index.d.ts +32 -0
- package/dist/seo/rules/index.js +71 -0
- package/dist/seo/rules/internal-linking.d.ts +2 -0
- package/dist/seo/rules/internal-linking.js +375 -0
- package/dist/seo/rules/local.d.ts +2 -0
- package/dist/seo/rules/local.js +265 -0
- package/dist/seo/rules/pwa.d.ts +2 -0
- package/dist/seo/rules/pwa.js +302 -0
- package/dist/seo/rules/readability.d.ts +2 -0
- package/dist/seo/rules/readability.js +255 -0
- package/dist/seo/rules/security.js +406 -28
- package/dist/seo/rules/social.d.ts +2 -0
- package/dist/seo/rules/social.js +373 -0
- package/dist/seo/rules/types.d.ts +155 -0
- package/dist/seo/seo-spider.d.ts +47 -0
- package/dist/seo/seo-spider.js +362 -0
- package/dist/seo/types.d.ts +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const pwaRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'pwa-manifest-link',
|
|
5
|
+
name: 'Web App Manifest',
|
|
6
|
+
category: 'technical',
|
|
7
|
+
severity: 'info',
|
|
8
|
+
description: 'Pages should link to a web app manifest for PWA support',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (ctx.hasManifest === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
if (!ctx.hasManifest) {
|
|
13
|
+
return createResult({ id: 'pwa-manifest-link', name: 'Web App Manifest', category: 'technical', severity: 'info' }, 'info', 'No web app manifest linked', {
|
|
14
|
+
recommendation: 'Add a manifest.json for PWA installability',
|
|
15
|
+
evidence: {
|
|
16
|
+
expected: '<link rel="manifest" href="/manifest.json">',
|
|
17
|
+
impact: 'Required for "Add to Home Screen" and PWA features',
|
|
18
|
+
learnMore: 'https://web.dev/add-manifest/',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return createResult({ id: 'pwa-manifest-link', name: 'Web App Manifest', category: 'technical', severity: 'info' }, 'pass', `Manifest linked${ctx.manifestUrl ? `: ${ctx.manifestUrl}` : ''}`);
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'pwa-theme-color',
|
|
27
|
+
name: 'Theme Color',
|
|
28
|
+
category: 'mobile',
|
|
29
|
+
severity: 'info',
|
|
30
|
+
description: 'Pages should define a theme color for browser UI',
|
|
31
|
+
check: (ctx) => {
|
|
32
|
+
if (ctx.themeColor === undefined)
|
|
33
|
+
return null;
|
|
34
|
+
if (!ctx.themeColor) {
|
|
35
|
+
return createResult({ id: 'pwa-theme-color', name: 'Theme Color', category: 'mobile', severity: 'info' }, 'info', 'No theme-color meta tag', {
|
|
36
|
+
recommendation: 'Add theme-color for browser UI customization',
|
|
37
|
+
evidence: {
|
|
38
|
+
expected: '<meta name="theme-color" content="#4285f4">',
|
|
39
|
+
impact: 'Controls browser toolbar color on mobile devices',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return createResult({ id: 'pwa-theme-color', name: 'Theme Color', category: 'mobile', severity: 'info' }, 'pass', `Theme color: ${ctx.themeColor}`);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'pwa-apple-touch-icon',
|
|
48
|
+
name: 'Apple Touch Icon',
|
|
49
|
+
category: 'mobile',
|
|
50
|
+
severity: 'info',
|
|
51
|
+
description: 'iOS devices need apple-touch-icon for home screen',
|
|
52
|
+
check: (ctx) => {
|
|
53
|
+
if (ctx.hasAppleTouchIcon === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
if (!ctx.hasAppleTouchIcon) {
|
|
56
|
+
return createResult({ id: 'pwa-apple-touch-icon', name: 'Apple Touch Icon', category: 'mobile', severity: 'info' }, 'info', 'No apple-touch-icon found', {
|
|
57
|
+
recommendation: 'Add apple-touch-icon for iOS home screen',
|
|
58
|
+
evidence: {
|
|
59
|
+
expected: '<link rel="apple-touch-icon" href="/apple-touch-icon.png">',
|
|
60
|
+
impact: 'iOS uses this icon when user adds site to home screen',
|
|
61
|
+
example: 'Recommended size: 180x180 pixels',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return createResult({ id: 'pwa-apple-touch-icon', name: 'Apple Touch Icon', category: 'mobile', severity: 'info' }, 'pass', 'Apple touch icon present');
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'pwa-apple-mobile-capable',
|
|
70
|
+
name: 'Apple Mobile Web App',
|
|
71
|
+
category: 'mobile',
|
|
72
|
+
severity: 'info',
|
|
73
|
+
description: 'Enable standalone mode on iOS devices',
|
|
74
|
+
check: (ctx) => {
|
|
75
|
+
if (ctx.hasAppleMobileWebAppCapable === undefined)
|
|
76
|
+
return null;
|
|
77
|
+
if (!ctx.hasAppleMobileWebAppCapable) {
|
|
78
|
+
return createResult({ id: 'pwa-apple-mobile-capable', name: 'Apple Mobile Web App', category: 'mobile', severity: 'info' }, 'info', 'No apple-mobile-web-app-capable meta tag', {
|
|
79
|
+
recommendation: 'Add for full-screen iOS experience',
|
|
80
|
+
evidence: {
|
|
81
|
+
expected: '<meta name="apple-mobile-web-app-capable" content="yes">',
|
|
82
|
+
impact: 'Enables standalone app mode when launched from home screen',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return createResult({ id: 'pwa-apple-mobile-capable', name: 'Apple Mobile Web App', category: 'mobile', severity: 'info' }, 'pass', 'Apple mobile web app capable enabled');
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'pwa-apple-status-bar',
|
|
91
|
+
name: 'Apple Status Bar Style',
|
|
92
|
+
category: 'mobile',
|
|
93
|
+
severity: 'info',
|
|
94
|
+
description: 'Configure iOS status bar appearance',
|
|
95
|
+
check: (ctx) => {
|
|
96
|
+
if (!ctx.hasAppleMobileWebAppCapable)
|
|
97
|
+
return null;
|
|
98
|
+
if (ctx.appleStatusBarStyle === undefined)
|
|
99
|
+
return null;
|
|
100
|
+
if (!ctx.appleStatusBarStyle) {
|
|
101
|
+
return createResult({ id: 'pwa-apple-status-bar', name: 'Apple Status Bar Style', category: 'mobile', severity: 'info' }, 'info', 'No apple-mobile-web-app-status-bar-style defined', {
|
|
102
|
+
recommendation: 'Define status bar style for iOS',
|
|
103
|
+
evidence: {
|
|
104
|
+
expected: '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">',
|
|
105
|
+
example: 'Options: default, black, black-translucent',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return createResult({ id: 'pwa-apple-status-bar', name: 'Apple Status Bar Style', category: 'mobile', severity: 'info' }, 'pass', `Status bar style: ${ctx.appleStatusBarStyle}`);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'pwa-maskable-icon',
|
|
114
|
+
name: 'Maskable Icon',
|
|
115
|
+
category: 'mobile',
|
|
116
|
+
severity: 'info',
|
|
117
|
+
description: 'Manifest should include maskable icons for Android',
|
|
118
|
+
check: (ctx) => {
|
|
119
|
+
if (ctx.hasMaskableIcon === undefined)
|
|
120
|
+
return null;
|
|
121
|
+
if (!ctx.hasMaskableIcon) {
|
|
122
|
+
return createResult({ id: 'pwa-maskable-icon', name: 'Maskable Icon', category: 'mobile', severity: 'info' }, 'info', 'No maskable icon in manifest', {
|
|
123
|
+
recommendation: 'Add maskable icon for adaptive icons on Android',
|
|
124
|
+
evidence: {
|
|
125
|
+
example: `{
|
|
126
|
+
"icons": [{
|
|
127
|
+
"src": "/icon-maskable.png",
|
|
128
|
+
"sizes": "512x512",
|
|
129
|
+
"type": "image/png",
|
|
130
|
+
"purpose": "maskable"
|
|
131
|
+
}]
|
|
132
|
+
}`,
|
|
133
|
+
impact: 'Maskable icons adapt to different Android icon shapes',
|
|
134
|
+
learnMore: 'https://web.dev/maskable-icon/',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return createResult({ id: 'pwa-maskable-icon', name: 'Maskable Icon', category: 'mobile', severity: 'info' }, 'pass', 'Maskable icon defined');
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'pwa-start-url',
|
|
143
|
+
name: 'Start URL',
|
|
144
|
+
category: 'technical',
|
|
145
|
+
severity: 'info',
|
|
146
|
+
description: 'Manifest should define a start_url',
|
|
147
|
+
check: (ctx) => {
|
|
148
|
+
if (!ctx.hasManifest)
|
|
149
|
+
return null;
|
|
150
|
+
if (ctx.manifestStartUrl === undefined)
|
|
151
|
+
return null;
|
|
152
|
+
if (!ctx.manifestStartUrl) {
|
|
153
|
+
return createResult({ id: 'pwa-start-url', name: 'Start URL', category: 'technical', severity: 'info' }, 'info', 'Manifest missing start_url', {
|
|
154
|
+
recommendation: 'Define start_url in manifest for PWA launch',
|
|
155
|
+
evidence: {
|
|
156
|
+
expected: '"start_url": "/?source=pwa"',
|
|
157
|
+
impact: 'Controls which page opens when PWA is launched',
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return createResult({ id: 'pwa-start-url', name: 'Start URL', category: 'technical', severity: 'info' }, 'pass', `Start URL: ${ctx.manifestStartUrl}`);
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'pwa-display-mode',
|
|
166
|
+
name: 'Display Mode',
|
|
167
|
+
category: 'technical',
|
|
168
|
+
severity: 'info',
|
|
169
|
+
description: 'Manifest should define display mode',
|
|
170
|
+
check: (ctx) => {
|
|
171
|
+
if (!ctx.hasManifest)
|
|
172
|
+
return null;
|
|
173
|
+
if (ctx.manifestDisplay === undefined)
|
|
174
|
+
return null;
|
|
175
|
+
const display = ctx.manifestDisplay;
|
|
176
|
+
if (!display) {
|
|
177
|
+
return createResult({ id: 'pwa-display-mode', name: 'Display Mode', category: 'technical', severity: 'info' }, 'info', 'Manifest missing display mode', {
|
|
178
|
+
recommendation: 'Set display mode for app-like experience',
|
|
179
|
+
evidence: {
|
|
180
|
+
expected: '"display": "standalone"',
|
|
181
|
+
example: 'Options: fullscreen, standalone, minimal-ui, browser',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const goodModes = ['standalone', 'fullscreen', 'minimal-ui'];
|
|
186
|
+
if (!goodModes.includes(display)) {
|
|
187
|
+
return createResult({ id: 'pwa-display-mode', name: 'Display Mode', category: 'technical', severity: 'info' }, 'info', `Display mode: ${display} (browser-like)`, {
|
|
188
|
+
recommendation: 'Consider standalone or fullscreen for app-like experience',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return createResult({ id: 'pwa-display-mode', name: 'Display Mode', category: 'technical', severity: 'info' }, 'pass', `Display mode: ${display}`);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'pwa-scope',
|
|
196
|
+
name: 'Navigation Scope',
|
|
197
|
+
category: 'technical',
|
|
198
|
+
severity: 'info',
|
|
199
|
+
description: 'Manifest should define navigation scope',
|
|
200
|
+
check: (ctx) => {
|
|
201
|
+
if (!ctx.hasManifest)
|
|
202
|
+
return null;
|
|
203
|
+
if (ctx.manifestScope === undefined)
|
|
204
|
+
return null;
|
|
205
|
+
if (!ctx.manifestScope) {
|
|
206
|
+
return createResult({ id: 'pwa-scope', name: 'Navigation Scope', category: 'technical', severity: 'info' }, 'info', 'Manifest missing scope', {
|
|
207
|
+
recommendation: 'Define scope to control PWA navigation boundaries',
|
|
208
|
+
evidence: {
|
|
209
|
+
expected: '"scope": "/"',
|
|
210
|
+
impact: 'Limits which URLs are part of the app experience',
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return createResult({ id: 'pwa-scope', name: 'Navigation Scope', category: 'technical', severity: 'info' }, 'pass', `Scope: ${ctx.manifestScope}`);
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'pwa-icons-sizes',
|
|
219
|
+
name: 'Icon Sizes',
|
|
220
|
+
category: 'mobile',
|
|
221
|
+
severity: 'info',
|
|
222
|
+
description: 'Manifest should include multiple icon sizes',
|
|
223
|
+
check: (ctx) => {
|
|
224
|
+
if (!ctx.hasManifest)
|
|
225
|
+
return null;
|
|
226
|
+
if (ctx.manifestIconSizes === undefined)
|
|
227
|
+
return null;
|
|
228
|
+
const sizes = ctx.manifestIconSizes;
|
|
229
|
+
const requiredSizes = [192, 512];
|
|
230
|
+
const missingSizes = requiredSizes.filter(s => !sizes.includes(s));
|
|
231
|
+
if (missingSizes.length > 0) {
|
|
232
|
+
return createResult({ id: 'pwa-icons-sizes', name: 'Icon Sizes', category: 'mobile', severity: 'info' }, 'info', `Missing icon sizes: ${missingSizes.join(', ')}px`, {
|
|
233
|
+
recommendation: 'Add icons in required sizes',
|
|
234
|
+
evidence: {
|
|
235
|
+
found: sizes.length > 0 ? sizes.map(s => `${s}px`).join(', ') : 'No icons',
|
|
236
|
+
expected: '192x192 and 512x512 minimum',
|
|
237
|
+
impact: 'Required for Chrome "Add to Home Screen" prompt',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return createResult({ id: 'pwa-icons-sizes', name: 'Icon Sizes', category: 'mobile', severity: 'info' }, 'pass', `Icon sizes: ${sizes.map(s => `${s}px`).join(', ')}`);
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'pwa-short-name',
|
|
246
|
+
name: 'Short Name',
|
|
247
|
+
category: 'technical',
|
|
248
|
+
severity: 'info',
|
|
249
|
+
description: 'Manifest should have a short_name for home screen',
|
|
250
|
+
check: (ctx) => {
|
|
251
|
+
if (!ctx.hasManifest)
|
|
252
|
+
return null;
|
|
253
|
+
if (ctx.manifestShortName === undefined && ctx.manifestName === undefined)
|
|
254
|
+
return null;
|
|
255
|
+
const shortName = ctx.manifestShortName;
|
|
256
|
+
const name = ctx.manifestName;
|
|
257
|
+
if (!shortName && !name) {
|
|
258
|
+
return createResult({ id: 'pwa-short-name', name: 'Short Name', category: 'technical', severity: 'info' }, 'info', 'Manifest missing name and short_name', {
|
|
259
|
+
recommendation: 'Add short_name (max 12 chars) for home screen label',
|
|
260
|
+
evidence: {
|
|
261
|
+
expected: '"short_name": "MyApp"',
|
|
262
|
+
impact: 'Used as app label on home screen',
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (shortName && shortName.length > 12) {
|
|
267
|
+
return createResult({ id: 'pwa-short-name', name: 'Short Name', category: 'technical', severity: 'info' }, 'info', `Short name too long: ${shortName.length} chars`, {
|
|
268
|
+
recommendation: 'Keep short_name under 12 characters',
|
|
269
|
+
evidence: {
|
|
270
|
+
found: shortName,
|
|
271
|
+
expected: 'Max 12 characters',
|
|
272
|
+
impact: 'May be truncated on home screen',
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return createResult({ id: 'pwa-short-name', name: 'Short Name', category: 'technical', severity: 'info' }, 'pass', `App name: ${shortName || name}`);
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'pwa-background-color',
|
|
281
|
+
name: 'Background Color',
|
|
282
|
+
category: 'mobile',
|
|
283
|
+
severity: 'info',
|
|
284
|
+
description: 'Manifest should define background_color for splash screen',
|
|
285
|
+
check: (ctx) => {
|
|
286
|
+
if (!ctx.hasManifest)
|
|
287
|
+
return null;
|
|
288
|
+
if (ctx.manifestBackgroundColor === undefined)
|
|
289
|
+
return null;
|
|
290
|
+
if (!ctx.manifestBackgroundColor) {
|
|
291
|
+
return createResult({ id: 'pwa-background-color', name: 'Background Color', category: 'mobile', severity: 'info' }, 'info', 'Manifest missing background_color', {
|
|
292
|
+
recommendation: 'Define background_color for splash screen',
|
|
293
|
+
evidence: {
|
|
294
|
+
expected: '"background_color": "#ffffff"',
|
|
295
|
+
impact: 'Shown as splash screen background during app launch',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return createResult({ id: 'pwa-background-color', name: 'Background Color', category: 'mobile', severity: 'info' }, 'pass', `Background color: ${ctx.manifestBackgroundColor}`);
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
];
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const readabilityRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'readability-flesch-score',
|
|
5
|
+
name: 'Flesch Reading Ease',
|
|
6
|
+
category: 'content',
|
|
7
|
+
severity: 'info',
|
|
8
|
+
description: 'Content should be easy to read for the target audience',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (ctx.fleschReadingEase === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
const score = ctx.fleschReadingEase;
|
|
13
|
+
let level;
|
|
14
|
+
let status;
|
|
15
|
+
if (score >= 60) {
|
|
16
|
+
level = score >= 80 ? 'Easy' : score >= 70 ? 'Fairly Easy' : 'Standard';
|
|
17
|
+
status = 'pass';
|
|
18
|
+
}
|
|
19
|
+
else if (score >= 50) {
|
|
20
|
+
level = 'Fairly Difficult';
|
|
21
|
+
status = 'info';
|
|
22
|
+
}
|
|
23
|
+
else if (score >= 30) {
|
|
24
|
+
level = 'Difficult';
|
|
25
|
+
status = 'warn';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
level = 'Very Difficult';
|
|
29
|
+
status = 'warn';
|
|
30
|
+
}
|
|
31
|
+
if (status === 'warn') {
|
|
32
|
+
return createResult({ id: 'readability-flesch-score', name: 'Flesch Reading Ease', category: 'content', severity: 'info' }, status, `Flesch score: ${Math.round(score)} (${level})`, {
|
|
33
|
+
recommendation: 'Simplify content for better readability',
|
|
34
|
+
evidence: {
|
|
35
|
+
found: Math.round(score),
|
|
36
|
+
expected: '60-70 for general web content',
|
|
37
|
+
impact: 'Difficult content has higher bounce rates',
|
|
38
|
+
learnMore: 'https://en.wikipedia.org/wiki/Flesch%E2%80%93Kincaid_readability_tests',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return createResult({ id: 'readability-flesch-score', name: 'Flesch Reading Ease', category: 'content', severity: 'info' }, status, `Flesch score: ${Math.round(score)} (${level})`);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'readability-sentence-length',
|
|
47
|
+
name: 'Sentence Length',
|
|
48
|
+
category: 'content',
|
|
49
|
+
severity: 'info',
|
|
50
|
+
description: 'Sentences should be concise for better readability',
|
|
51
|
+
check: (ctx) => {
|
|
52
|
+
if (ctx.avgSentenceLength === undefined)
|
|
53
|
+
return null;
|
|
54
|
+
const avgLength = ctx.avgSentenceLength;
|
|
55
|
+
if (avgLength > 30) {
|
|
56
|
+
return createResult({ id: 'readability-sentence-length', name: 'Sentence Length', category: 'content', severity: 'info' }, 'warn', `Average sentence length: ${Math.round(avgLength)} words (too long)`, {
|
|
57
|
+
recommendation: 'Break long sentences into shorter ones',
|
|
58
|
+
evidence: {
|
|
59
|
+
found: Math.round(avgLength),
|
|
60
|
+
expected: '15-20 words per sentence ideal, max 25',
|
|
61
|
+
impact: 'Long sentences are harder to understand on mobile',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (avgLength > 25) {
|
|
66
|
+
return createResult({ id: 'readability-sentence-length', name: 'Sentence Length', category: 'content', severity: 'info' }, 'info', `Average sentence length: ${Math.round(avgLength)} words (slightly long)`, {
|
|
67
|
+
recommendation: 'Consider shortening some sentences',
|
|
68
|
+
evidence: {
|
|
69
|
+
found: Math.round(avgLength),
|
|
70
|
+
expected: '15-20 words per sentence',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return createResult({ id: 'readability-sentence-length', name: 'Sentence Length', category: 'content', severity: 'info' }, 'pass', `Average sentence length: ${Math.round(avgLength)} words`);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'readability-paragraph-length',
|
|
79
|
+
name: 'Paragraph Length',
|
|
80
|
+
category: 'content',
|
|
81
|
+
severity: 'info',
|
|
82
|
+
description: 'Paragraphs should be short for web readability',
|
|
83
|
+
check: (ctx) => {
|
|
84
|
+
if (ctx.avgParagraphLength === undefined)
|
|
85
|
+
return null;
|
|
86
|
+
const avgLength = ctx.avgParagraphLength;
|
|
87
|
+
if (avgLength > 150) {
|
|
88
|
+
return createResult({ id: 'readability-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'info' }, 'warn', `Average paragraph length: ${Math.round(avgLength)} words (too long)`, {
|
|
89
|
+
recommendation: 'Break paragraphs into smaller chunks',
|
|
90
|
+
evidence: {
|
|
91
|
+
found: Math.round(avgLength),
|
|
92
|
+
expected: '40-80 words per paragraph for web',
|
|
93
|
+
impact: 'Wall of text reduces engagement and increases bounce rate',
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (avgLength > 100) {
|
|
98
|
+
return createResult({ id: 'readability-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'info' }, 'info', `Average paragraph length: ${Math.round(avgLength)} words`, {
|
|
99
|
+
recommendation: 'Consider breaking longer paragraphs',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return createResult({ id: 'readability-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'info' }, 'pass', `Average paragraph length: ${Math.round(avgLength)} words`);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'readability-passive-voice',
|
|
107
|
+
name: 'Passive Voice',
|
|
108
|
+
category: 'content',
|
|
109
|
+
severity: 'info',
|
|
110
|
+
description: 'Limit passive voice for clearer writing',
|
|
111
|
+
check: (ctx) => {
|
|
112
|
+
if (ctx.passiveVoicePercentage === undefined)
|
|
113
|
+
return null;
|
|
114
|
+
const percentage = ctx.passiveVoicePercentage;
|
|
115
|
+
if (percentage > 20) {
|
|
116
|
+
return createResult({ id: 'readability-passive-voice', name: 'Passive Voice', category: 'content', severity: 'info' }, 'warn', `Passive voice: ${Math.round(percentage)}% of sentences`, {
|
|
117
|
+
recommendation: 'Convert passive sentences to active voice',
|
|
118
|
+
evidence: {
|
|
119
|
+
found: `${Math.round(percentage)}%`,
|
|
120
|
+
expected: 'Less than 10% passive voice',
|
|
121
|
+
example: 'Instead of "The button was clicked by the user" → "The user clicked the button"',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (percentage > 15) {
|
|
126
|
+
return createResult({ id: 'readability-passive-voice', name: 'Passive Voice', category: 'content', severity: 'info' }, 'info', `Passive voice: ${Math.round(percentage)}% of sentences`, {
|
|
127
|
+
recommendation: 'Consider reducing passive voice usage',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'readability-transition-words',
|
|
135
|
+
name: 'Transition Words',
|
|
136
|
+
category: 'content',
|
|
137
|
+
severity: 'info',
|
|
138
|
+
description: 'Use transition words for better flow',
|
|
139
|
+
check: (ctx) => {
|
|
140
|
+
if (ctx.transitionWordPercentage === undefined)
|
|
141
|
+
return null;
|
|
142
|
+
const percentage = ctx.transitionWordPercentage;
|
|
143
|
+
if (percentage < 20) {
|
|
144
|
+
return createResult({ id: 'readability-transition-words', name: 'Transition Words', category: 'content', severity: 'info' }, 'info', `Transition words: ${Math.round(percentage)}% of sentences`, {
|
|
145
|
+
recommendation: 'Add transition words for better content flow',
|
|
146
|
+
evidence: {
|
|
147
|
+
found: `${Math.round(percentage)}%`,
|
|
148
|
+
expected: 'At least 30% of sentences',
|
|
149
|
+
example: 'Words like: however, therefore, additionally, furthermore, for example',
|
|
150
|
+
impact: 'Transition words improve comprehension and engagement',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return createResult({ id: 'readability-transition-words', name: 'Transition Words', category: 'content', severity: 'info' }, 'pass', `Transition words: ${Math.round(percentage)}% of sentences`);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'readability-subheading-distribution',
|
|
159
|
+
name: 'Subheading Distribution',
|
|
160
|
+
category: 'content',
|
|
161
|
+
severity: 'info',
|
|
162
|
+
description: 'Break content with subheadings every 300 words',
|
|
163
|
+
check: (ctx) => {
|
|
164
|
+
if (!ctx.wordCount || !ctx.h2Count)
|
|
165
|
+
return null;
|
|
166
|
+
const wordsPerSubheading = ctx.wordCount / (ctx.h2Count + 1);
|
|
167
|
+
if (ctx.wordCount > 500 && wordsPerSubheading > 400) {
|
|
168
|
+
return createResult({ id: 'readability-subheading-distribution', name: 'Subheading Distribution', category: 'content', severity: 'info' }, 'warn', `${Math.round(wordsPerSubheading)} words between subheadings (too many)`, {
|
|
169
|
+
recommendation: 'Add more H2/H3 subheadings to break up content',
|
|
170
|
+
evidence: {
|
|
171
|
+
found: `${ctx.h2Count} subheadings for ${ctx.wordCount} words`,
|
|
172
|
+
expected: 'One subheading every 250-350 words',
|
|
173
|
+
impact: 'Subheadings improve scannability and featured snippet eligibility',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (ctx.wordCount > 300 && wordsPerSubheading > 300) {
|
|
178
|
+
return createResult({ id: 'readability-subheading-distribution', name: 'Subheading Distribution', category: 'content', severity: 'info' }, 'info', `${Math.round(wordsPerSubheading)} words per section`, {
|
|
179
|
+
recommendation: 'Consider adding more subheadings',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return createResult({ id: 'readability-subheading-distribution', name: 'Subheading Distribution', category: 'content', severity: 'info' }, 'pass', `${Math.round(wordsPerSubheading)} words per section (good)`);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'readability-text-variety',
|
|
187
|
+
name: 'Text Variety',
|
|
188
|
+
category: 'content',
|
|
189
|
+
severity: 'info',
|
|
190
|
+
description: 'Use varied sentence structures and word choices',
|
|
191
|
+
check: (ctx) => {
|
|
192
|
+
if (ctx.consecutiveSentenceStarts === undefined)
|
|
193
|
+
return null;
|
|
194
|
+
if (ctx.consecutiveSentenceStarts > 3) {
|
|
195
|
+
return createResult({ id: 'readability-text-variety', name: 'Text Variety', category: 'content', severity: 'info' }, 'info', `${ctx.consecutiveSentenceStarts} consecutive sentences start similarly`, {
|
|
196
|
+
recommendation: 'Vary sentence beginnings for better flow',
|
|
197
|
+
evidence: {
|
|
198
|
+
impact: 'Repetitive sentence structures feel monotonous',
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'readability-word-complexity',
|
|
207
|
+
name: 'Word Complexity',
|
|
208
|
+
category: 'content',
|
|
209
|
+
severity: 'info',
|
|
210
|
+
description: 'Avoid overly complex vocabulary',
|
|
211
|
+
check: (ctx) => {
|
|
212
|
+
if (ctx.complexWordPercentage === undefined)
|
|
213
|
+
return null;
|
|
214
|
+
if (ctx.complexWordPercentage > 15) {
|
|
215
|
+
return createResult({ id: 'readability-word-complexity', name: 'Word Complexity', category: 'content', severity: 'info' }, 'warn', `Complex words: ${Math.round(ctx.complexWordPercentage)}%`, {
|
|
216
|
+
recommendation: 'Use simpler vocabulary where possible',
|
|
217
|
+
evidence: {
|
|
218
|
+
found: `${Math.round(ctx.complexWordPercentage)}% words with 3+ syllables`,
|
|
219
|
+
expected: 'Less than 10% complex words for general audience',
|
|
220
|
+
impact: 'Complex vocabulary reduces comprehension',
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (ctx.complexWordPercentage > 10) {
|
|
225
|
+
return createResult({ id: 'readability-word-complexity', name: 'Word Complexity', category: 'content', severity: 'info' }, 'info', `Complex words: ${Math.round(ctx.complexWordPercentage)}%`);
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'readability-list-usage',
|
|
232
|
+
name: 'List Usage',
|
|
233
|
+
category: 'content',
|
|
234
|
+
severity: 'info',
|
|
235
|
+
description: 'Use lists to improve scannability',
|
|
236
|
+
check: (ctx) => {
|
|
237
|
+
if (!ctx.wordCount || ctx.listCount === undefined)
|
|
238
|
+
return null;
|
|
239
|
+
if (ctx.wordCount > 500 && ctx.listCount === 0) {
|
|
240
|
+
return createResult({ id: 'readability-list-usage', name: 'List Usage', category: 'content', severity: 'info' }, 'info', 'No lists found in long-form content', {
|
|
241
|
+
recommendation: 'Consider using bullet points or numbered lists',
|
|
242
|
+
evidence: {
|
|
243
|
+
found: `${ctx.wordCount} words with 0 lists`,
|
|
244
|
+
expected: 'At least 1 list per 500 words for long content',
|
|
245
|
+
impact: 'Lists improve scannability and featured snippet eligibility',
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (ctx.listCount > 0) {
|
|
250
|
+
return createResult({ id: 'readability-list-usage', name: 'List Usage', category: 'content', severity: 'info' }, 'pass', `${ctx.listCount} list(s) found`);
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
];
|