docsanity 0.1.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/LICENSE +21 -0
- package/README.md +181 -0
- package/dist/chunk-DQY4MKSJ.js +514 -0
- package/dist/chunk-DQY4MKSJ.js.map +1 -0
- package/dist/cli.cjs +831 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +319 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +535 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +226 -0
- package/dist/index.d.ts +226 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var cac = require('cac');
|
|
7
|
+
var pc = require('picocolors');
|
|
8
|
+
var readlevel = require('@didrod2539/readlevel');
|
|
9
|
+
var linklint = require('@didrod2539/linklint');
|
|
10
|
+
|
|
11
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
12
|
+
|
|
13
|
+
var pc__default = /*#__PURE__*/_interopDefault(pc);
|
|
14
|
+
|
|
15
|
+
// package.json
|
|
16
|
+
var package_default = {
|
|
17
|
+
name: "docsanity",
|
|
18
|
+
version: "0.1.0",
|
|
19
|
+
description: "One command for docs-site health: broken links & dead anchors, orphan pages, SEO frontmatter (with site-wide duplicate title/description detection), readability grade, and Markdown structure/alt checks \u2014 unified into one score and report. Local, deterministic, no API key.",
|
|
20
|
+
type: "module",
|
|
21
|
+
main: "./dist/index.js",
|
|
22
|
+
module: "./dist/index.js",
|
|
23
|
+
types: "./dist/index.d.ts",
|
|
24
|
+
exports: {
|
|
25
|
+
".": {
|
|
26
|
+
types: "./dist/index.d.ts",
|
|
27
|
+
import: "./dist/index.js",
|
|
28
|
+
require: "./dist/index.cjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
bin: {
|
|
32
|
+
docsanity: "./dist/cli.js"
|
|
33
|
+
},
|
|
34
|
+
files: [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
engines: {
|
|
38
|
+
node: ">=18"
|
|
39
|
+
},
|
|
40
|
+
scripts: {
|
|
41
|
+
build: "tsup",
|
|
42
|
+
test: "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
typecheck: "tsc --noEmit",
|
|
45
|
+
lint: "tsc --noEmit",
|
|
46
|
+
example: "node dist/cli.js scan examples/docs",
|
|
47
|
+
prepublishOnly: "npm run build"
|
|
48
|
+
},
|
|
49
|
+
keywords: [
|
|
50
|
+
"docs",
|
|
51
|
+
"documentation",
|
|
52
|
+
"docs-site",
|
|
53
|
+
"markdown",
|
|
54
|
+
"mdx",
|
|
55
|
+
"docusaurus",
|
|
56
|
+
"astro",
|
|
57
|
+
"nextra",
|
|
58
|
+
"broken-links",
|
|
59
|
+
"orphan-pages",
|
|
60
|
+
"frontmatter",
|
|
61
|
+
"seo",
|
|
62
|
+
"readability",
|
|
63
|
+
"link-checker",
|
|
64
|
+
"docs-linter",
|
|
65
|
+
"static-site",
|
|
66
|
+
"cli"
|
|
67
|
+
],
|
|
68
|
+
author: "didrod205 (https://github.com/didrod205)",
|
|
69
|
+
license: "MIT",
|
|
70
|
+
repository: {
|
|
71
|
+
type: "git",
|
|
72
|
+
url: "git+https://github.com/didrod205/docsanity.git"
|
|
73
|
+
},
|
|
74
|
+
bugs: {
|
|
75
|
+
url: "https://github.com/didrod205/docsanity/issues"
|
|
76
|
+
},
|
|
77
|
+
homepage: "https://github.com/didrod205/docsanity#readme",
|
|
78
|
+
dependencies: {
|
|
79
|
+
"@didrod2539/linklint": "^0.1.0",
|
|
80
|
+
"@didrod2539/readlevel": "^0.2.0",
|
|
81
|
+
cac: "^6.7.14",
|
|
82
|
+
picocolors: "^1.1.1"
|
|
83
|
+
},
|
|
84
|
+
devDependencies: {
|
|
85
|
+
"@types/node": "^22.10.0",
|
|
86
|
+
tsup: "^8.3.5",
|
|
87
|
+
typescript: "^5.7.2",
|
|
88
|
+
vitest: "^2.1.8"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
function readabilityCheck(page, config) {
|
|
92
|
+
const text = page.md.text;
|
|
93
|
+
const wordCount = text ? text.split(/\s+/).filter(Boolean).length : 0;
|
|
94
|
+
if (wordCount < config.minWordsForReadability) {
|
|
95
|
+
return { findings: [] };
|
|
96
|
+
}
|
|
97
|
+
const a = readlevel.analyze(text);
|
|
98
|
+
const summary = {
|
|
99
|
+
grade: a.grade,
|
|
100
|
+
gradeLabel: a.gradeLabel,
|
|
101
|
+
ease: a.ease,
|
|
102
|
+
words: a.words,
|
|
103
|
+
readingTimeSeconds: a.readingTimeSeconds
|
|
104
|
+
};
|
|
105
|
+
const findings = [];
|
|
106
|
+
if (a.grade > config.maxGrade) {
|
|
107
|
+
findings.push({
|
|
108
|
+
dimension: "readability",
|
|
109
|
+
rule: "readability.too-hard",
|
|
110
|
+
severity: "warning",
|
|
111
|
+
message: `Reads at grade ${a.grade.toFixed(0)} (${a.gradeLabel}); target \u2264 grade ${config.maxGrade}`,
|
|
112
|
+
detail: `Flesch ease ${a.readability.fleschReadingEase.toFixed(0)} (${a.ease}), ${a.averageWordsPerSentence.toFixed(0)} words/sentence`,
|
|
113
|
+
fix: "Shorten sentences and prefer common words to lower the reading grade."
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return { findings, summary };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/frontmatter.ts
|
|
120
|
+
function unquote(raw) {
|
|
121
|
+
const s = raw.trim();
|
|
122
|
+
if (s.startsWith('"') && s.endsWith('"') && s.length >= 2 || s.startsWith("'") && s.endsWith("'") && s.length >= 2) {
|
|
123
|
+
return s.slice(1, -1);
|
|
124
|
+
}
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
function coerce(raw) {
|
|
128
|
+
const s = raw.trim();
|
|
129
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
130
|
+
return s.slice(1, -1).split(",").map((x) => unquote(x)).filter((x) => x.length > 0);
|
|
131
|
+
}
|
|
132
|
+
if (s === "true" || s === "false") return s === "true";
|
|
133
|
+
if (s !== "" && !Number.isNaN(Number(s)) && /^-?\d/.test(s)) return Number(s);
|
|
134
|
+
return unquote(s);
|
|
135
|
+
}
|
|
136
|
+
function parseFrontmatter(content) {
|
|
137
|
+
const normalized = content.replace(/^/, "");
|
|
138
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(normalized);
|
|
139
|
+
if (!match) {
|
|
140
|
+
return { data: {}, body: content, offset: 0, present: false };
|
|
141
|
+
}
|
|
142
|
+
const block = match[1];
|
|
143
|
+
const data = {};
|
|
144
|
+
const lines = block.split(/\r?\n/);
|
|
145
|
+
let pendingListKey = null;
|
|
146
|
+
let pendingList = [];
|
|
147
|
+
const flush = () => {
|
|
148
|
+
if (pendingListKey !== null) {
|
|
149
|
+
data[pendingListKey] = pendingList;
|
|
150
|
+
pendingListKey = null;
|
|
151
|
+
pendingList = [];
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (/^\s*-\s+/.test(line) && pendingListKey !== null) {
|
|
156
|
+
pendingList.push(unquote(line.replace(/^\s*-\s+/, "")));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
flush();
|
|
160
|
+
const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
|
|
161
|
+
if (!kv) continue;
|
|
162
|
+
const key = kv[1];
|
|
163
|
+
const value = kv[2];
|
|
164
|
+
if (value.trim() === "") {
|
|
165
|
+
pendingListKey = key;
|
|
166
|
+
pendingList = [];
|
|
167
|
+
} else {
|
|
168
|
+
data[key] = coerce(value);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
flush();
|
|
172
|
+
const offset = match[0].split(/\r?\n/).length - 1;
|
|
173
|
+
return { data, body: normalized.slice(match[0].length), offset, present: true };
|
|
174
|
+
}
|
|
175
|
+
function asString(value) {
|
|
176
|
+
if (value === void 0) return "";
|
|
177
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
178
|
+
return String(value).trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/checks/seo.ts
|
|
182
|
+
function pageTitle(page) {
|
|
183
|
+
const fm = asString(page.frontmatter.data["title"]);
|
|
184
|
+
if (fm) return { value: fm, source: "frontmatter" };
|
|
185
|
+
const h1 = page.md.headings.find((h) => h.level === 1);
|
|
186
|
+
if (h1) return { value: h1.text, source: "h1" };
|
|
187
|
+
return { value: "", source: null };
|
|
188
|
+
}
|
|
189
|
+
function description(page) {
|
|
190
|
+
return asString(page.frontmatter.data["description"]);
|
|
191
|
+
}
|
|
192
|
+
function buildDuplicateIndex(pages) {
|
|
193
|
+
const titles = /* @__PURE__ */ new Map();
|
|
194
|
+
const descriptions = /* @__PURE__ */ new Map();
|
|
195
|
+
for (const page of pages) {
|
|
196
|
+
const t = pageTitle(page).value.trim().toLowerCase();
|
|
197
|
+
if (t) titles.set(t, [...titles.get(t) ?? [], page.relPath]);
|
|
198
|
+
const d = description(page).trim().toLowerCase();
|
|
199
|
+
if (d) descriptions.set(d, [...descriptions.get(d) ?? [], page.relPath]);
|
|
200
|
+
}
|
|
201
|
+
return { titles, descriptions };
|
|
202
|
+
}
|
|
203
|
+
function seoChecks(page, config, dup) {
|
|
204
|
+
const out = [];
|
|
205
|
+
const required = new Set(config.requireFrontmatter);
|
|
206
|
+
const title = pageTitle(page);
|
|
207
|
+
const desc = description(page);
|
|
208
|
+
if (required.has("title")) {
|
|
209
|
+
if (!title.value) {
|
|
210
|
+
out.push({
|
|
211
|
+
dimension: "seo",
|
|
212
|
+
rule: "seo.missing-title",
|
|
213
|
+
severity: "error",
|
|
214
|
+
message: "Page has no title (no frontmatter `title` and no H1)",
|
|
215
|
+
fix: "Add a `title:` to the frontmatter or an `# H1` heading."
|
|
216
|
+
});
|
|
217
|
+
} else if (title.source === "h1") {
|
|
218
|
+
out.push({
|
|
219
|
+
dimension: "seo",
|
|
220
|
+
rule: "seo.frontmatter-title",
|
|
221
|
+
severity: "info",
|
|
222
|
+
message: "Title comes from the H1; add an explicit frontmatter `title` for SEO control",
|
|
223
|
+
detail: `Using H1: "${title.value}"`
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (title.value && title.value.length > config.titleMax) {
|
|
227
|
+
out.push({
|
|
228
|
+
dimension: "seo",
|
|
229
|
+
rule: "seo.title-too-long",
|
|
230
|
+
severity: "warning",
|
|
231
|
+
message: `Title is ${title.value.length} chars (recommended \u2264 ${config.titleMax})`,
|
|
232
|
+
fix: "Shorten the title so it isn't truncated in search results."
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (required.has("description")) {
|
|
237
|
+
if (!desc) {
|
|
238
|
+
out.push({
|
|
239
|
+
dimension: "seo",
|
|
240
|
+
rule: "seo.missing-description",
|
|
241
|
+
severity: "warning",
|
|
242
|
+
message: "No frontmatter `description`",
|
|
243
|
+
fix: `Add a ${config.descriptionMin}\u2013${config.descriptionMax} char description for the search snippet.`
|
|
244
|
+
});
|
|
245
|
+
} else if (desc.length < config.descriptionMin) {
|
|
246
|
+
out.push({
|
|
247
|
+
dimension: "seo",
|
|
248
|
+
rule: "seo.description-short",
|
|
249
|
+
severity: "warning",
|
|
250
|
+
message: `Description is ${desc.length} chars (recommended \u2265 ${config.descriptionMin})`
|
|
251
|
+
});
|
|
252
|
+
} else if (desc.length > config.descriptionMax) {
|
|
253
|
+
out.push({
|
|
254
|
+
dimension: "seo",
|
|
255
|
+
rule: "seo.description-long",
|
|
256
|
+
severity: "warning",
|
|
257
|
+
message: `Description is ${desc.length} chars (recommended \u2264 ${config.descriptionMax})`,
|
|
258
|
+
fix: "Trim it so search engines don't truncate the snippet."
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const key of config.requireFrontmatter) {
|
|
263
|
+
if (key === "title" || key === "description") continue;
|
|
264
|
+
if (!asString(page.frontmatter.data[key])) {
|
|
265
|
+
out.push({
|
|
266
|
+
dimension: "seo",
|
|
267
|
+
rule: `seo.missing-frontmatter`,
|
|
268
|
+
severity: "warning",
|
|
269
|
+
message: `Missing required frontmatter: \`${key}\``
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const tKey = title.value.trim().toLowerCase();
|
|
274
|
+
const others = (dup.titles.get(tKey) ?? []).filter((p) => p !== page.relPath);
|
|
275
|
+
if (tKey && others.length) {
|
|
276
|
+
out.push({
|
|
277
|
+
dimension: "seo",
|
|
278
|
+
rule: "seo.duplicate-title",
|
|
279
|
+
severity: "warning",
|
|
280
|
+
message: `Duplicate title \u2014 also used by ${others.length} other page(s)`,
|
|
281
|
+
detail: others.slice(0, 3).join(", "),
|
|
282
|
+
fix: "Give each page a unique title; duplicates compete in search results."
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const dKey = desc.trim().toLowerCase();
|
|
286
|
+
const dOthers = (dup.descriptions.get(dKey) ?? []).filter((p) => p !== page.relPath);
|
|
287
|
+
if (dKey && dOthers.length) {
|
|
288
|
+
out.push({
|
|
289
|
+
dimension: "seo",
|
|
290
|
+
rule: "seo.duplicate-description",
|
|
291
|
+
severity: "warning",
|
|
292
|
+
message: `Duplicate description \u2014 also used by ${dOthers.length} other page(s)`,
|
|
293
|
+
detail: dOthers.slice(0, 3).join(", "),
|
|
294
|
+
fix: "Write a unique description per page."
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/checks/structure.ts
|
|
301
|
+
function structureChecks(page, _config) {
|
|
302
|
+
const out = [];
|
|
303
|
+
const { headings, images, fences } = page.md;
|
|
304
|
+
const h1s = headings.filter((h) => h.level === 1);
|
|
305
|
+
if (h1s.length === 0) {
|
|
306
|
+
out.push({
|
|
307
|
+
dimension: "structure",
|
|
308
|
+
rule: "structure.no-h1",
|
|
309
|
+
severity: "warning",
|
|
310
|
+
message: "No H1 heading",
|
|
311
|
+
fix: "Start the page with a single `# Title`."
|
|
312
|
+
});
|
|
313
|
+
} else if (h1s.length > 1) {
|
|
314
|
+
out.push({
|
|
315
|
+
dimension: "structure",
|
|
316
|
+
rule: "structure.multiple-h1",
|
|
317
|
+
severity: "warning",
|
|
318
|
+
message: `${h1s.length} H1 headings (use exactly one)`,
|
|
319
|
+
line: h1s[1].line,
|
|
320
|
+
fix: "Demote the extra H1s to H2/H3 so the outline has one top level."
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
let prev = 0;
|
|
324
|
+
for (const h of headings) {
|
|
325
|
+
if (prev !== 0 && h.level > prev + 1) {
|
|
326
|
+
out.push({
|
|
327
|
+
dimension: "structure",
|
|
328
|
+
rule: "structure.skipped-heading",
|
|
329
|
+
severity: "warning",
|
|
330
|
+
message: `Heading jumps from H${prev} to H${h.level} ("${h.text}")`,
|
|
331
|
+
line: h.line,
|
|
332
|
+
fix: "Don't skip levels \u2014 screen readers and outlines rely on order."
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
prev = h.level;
|
|
336
|
+
}
|
|
337
|
+
for (const img of images) {
|
|
338
|
+
if (img.alt.trim() === "") {
|
|
339
|
+
out.push({
|
|
340
|
+
dimension: "structure",
|
|
341
|
+
rule: "structure.image-alt",
|
|
342
|
+
severity: "warning",
|
|
343
|
+
message: `Image has no alt text: ${img.src}`,
|
|
344
|
+
line: img.line,
|
|
345
|
+
fix: "Add descriptive alt text: ``."
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const fence of fences) {
|
|
350
|
+
if (fence.lang === null) {
|
|
351
|
+
out.push({
|
|
352
|
+
dimension: "structure",
|
|
353
|
+
rule: "structure.code-language",
|
|
354
|
+
severity: "info",
|
|
355
|
+
message: "Code block has no language tag",
|
|
356
|
+
line: fence.line,
|
|
357
|
+
fix: "Add a language after the opening fence (e.g. ```ts) for highlighting."
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/config.ts
|
|
365
|
+
var DEFAULT_CONFIG = {
|
|
366
|
+
extensions: [".md", ".mdx", ".markdown"],
|
|
367
|
+
requireFrontmatter: ["title", "description"],
|
|
368
|
+
descriptionMin: 50,
|
|
369
|
+
descriptionMax: 160,
|
|
370
|
+
titleMax: 60,
|
|
371
|
+
maxGrade: 14,
|
|
372
|
+
minWordsForReadability: 80,
|
|
373
|
+
disable: [],
|
|
374
|
+
ignore: [],
|
|
375
|
+
minScore: 0
|
|
376
|
+
};
|
|
377
|
+
var CONFIG_FILENAMES = ["docsanity.config.json", ".docsanityrc.json", ".docsanityrc"];
|
|
378
|
+
function isPlainObject(v) {
|
|
379
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
380
|
+
}
|
|
381
|
+
function mergeConfig(base, override) {
|
|
382
|
+
const out = { ...base };
|
|
383
|
+
for (const [k, v] of Object.entries(override ?? {})) {
|
|
384
|
+
if (v !== void 0) out[k] = v;
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
function parseConfig(json, label = "config") {
|
|
389
|
+
let data;
|
|
390
|
+
try {
|
|
391
|
+
data = JSON.parse(json);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
throw new Error(`invalid ${label}: ${e.message}`);
|
|
394
|
+
}
|
|
395
|
+
if (!isPlainObject(data)) throw new Error(`invalid ${label}: must be a JSON object`);
|
|
396
|
+
return mergeConfig(DEFAULT_CONFIG, data);
|
|
397
|
+
}
|
|
398
|
+
function isDimensionEnabled(config, dim) {
|
|
399
|
+
return !config.disable.includes(dim);
|
|
400
|
+
}
|
|
401
|
+
function mapSeverity(s) {
|
|
402
|
+
return s === "error" || s === "warning" || s === "info" ? s : "warning";
|
|
403
|
+
}
|
|
404
|
+
function linkFindings(root, pages) {
|
|
405
|
+
const inputs = pages.map((p) => ({ path: p.absPath, content: p.content }));
|
|
406
|
+
const project = linklint.buildProjectFromInputs(root, inputs);
|
|
407
|
+
const report = linklint.analyze(project, linklint.DEFAULT_CONFIG);
|
|
408
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
409
|
+
for (const doc of report.documents) {
|
|
410
|
+
byPath.set(
|
|
411
|
+
doc.path,
|
|
412
|
+
doc.issues.map((i) => ({
|
|
413
|
+
dimension: "links",
|
|
414
|
+
rule: `links.${i.rule}`,
|
|
415
|
+
severity: mapSeverity(i.severity),
|
|
416
|
+
message: i.message,
|
|
417
|
+
line: i.line,
|
|
418
|
+
detail: i.detail,
|
|
419
|
+
fix: i.fix
|
|
420
|
+
}))
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
for (const abs of linklint.findOrphans(project)) {
|
|
424
|
+
const rel = path.relative(root, abs) || abs;
|
|
425
|
+
const list = byPath.get(rel) ?? [];
|
|
426
|
+
list.push({
|
|
427
|
+
dimension: "links",
|
|
428
|
+
rule: "links.orphan-document",
|
|
429
|
+
severity: "warning",
|
|
430
|
+
message: "Orphan page \u2014 nothing links here",
|
|
431
|
+
fix: "Link to it from an index/README, or remove it if it's obsolete."
|
|
432
|
+
});
|
|
433
|
+
byPath.set(rel, list);
|
|
434
|
+
}
|
|
435
|
+
return byPath;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/markdown.ts
|
|
439
|
+
var FENCE_RE = /^(\s*)(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/;
|
|
440
|
+
var HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
441
|
+
var IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
442
|
+
var HTML_IMG_RE = /<img\b[^>]*>/gi;
|
|
443
|
+
function htmlImgAlt(tag) {
|
|
444
|
+
const m = /\balt\s*=\s*["']([^"']*)["']/i.exec(tag);
|
|
445
|
+
return m ? m[1] : "";
|
|
446
|
+
}
|
|
447
|
+
function htmlImgSrc(tag) {
|
|
448
|
+
const m = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(tag);
|
|
449
|
+
return m ? m[1] : "";
|
|
450
|
+
}
|
|
451
|
+
function parseMarkdown(body, lineOffset = 0) {
|
|
452
|
+
const lines = body.split(/\r?\n/);
|
|
453
|
+
const headings = [];
|
|
454
|
+
const images = [];
|
|
455
|
+
const fences = [];
|
|
456
|
+
const textParts = [];
|
|
457
|
+
let inFence = false;
|
|
458
|
+
let fenceMarker = "";
|
|
459
|
+
for (let i = 0; i < lines.length; i++) {
|
|
460
|
+
const raw = lines[i];
|
|
461
|
+
const lineNo = i + 1 + lineOffset;
|
|
462
|
+
const fence = FENCE_RE.exec(raw);
|
|
463
|
+
if (fence && (!inFence || raw.trim().startsWith(fenceMarker))) {
|
|
464
|
+
const marker = fence[2];
|
|
465
|
+
if (!inFence) {
|
|
466
|
+
inFence = true;
|
|
467
|
+
fenceMarker = marker[0].repeat(3);
|
|
468
|
+
fences.push({ lang: fence[3] ? fence[3] : null, line: lineNo });
|
|
469
|
+
} else {
|
|
470
|
+
inFence = false;
|
|
471
|
+
fenceMarker = "";
|
|
472
|
+
}
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (inFence) continue;
|
|
476
|
+
const heading = HEADING_RE.exec(raw);
|
|
477
|
+
if (heading) {
|
|
478
|
+
headings.push({ level: heading[1].length, text: heading[2].trim(), line: lineNo });
|
|
479
|
+
textParts.push(heading[2].trim());
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
let m;
|
|
483
|
+
IMAGE_RE.lastIndex = 0;
|
|
484
|
+
while ((m = IMAGE_RE.exec(raw)) !== null) {
|
|
485
|
+
images.push({ alt: m[1], src: m[2], line: lineNo });
|
|
486
|
+
}
|
|
487
|
+
HTML_IMG_RE.lastIndex = 0;
|
|
488
|
+
while ((m = HTML_IMG_RE.exec(raw)) !== null) {
|
|
489
|
+
images.push({ alt: htmlImgAlt(m[0]), src: htmlImgSrc(m[0]), line: lineNo });
|
|
490
|
+
}
|
|
491
|
+
textParts.push(stripInline(raw));
|
|
492
|
+
}
|
|
493
|
+
return { headings, images, fences, text: textParts.join("\n").trim() };
|
|
494
|
+
}
|
|
495
|
+
function stripInline(line) {
|
|
496
|
+
return line.replace(IMAGE_RE, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/<[^>]+>/g, "").replace(/^[>\s]*>/g, "").replace(/^\s{0,3}([*+-]|\d+\.)\s+/g, "").replace(/[*_~]+/g, "").trim();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/page.ts
|
|
500
|
+
function parsePage(absPath, relPath, content) {
|
|
501
|
+
const frontmatter = parseFrontmatter(content);
|
|
502
|
+
const md = parseMarkdown(frontmatter.body, frontmatter.offset);
|
|
503
|
+
return { absPath, relPath, content, frontmatter, md };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/score.ts
|
|
507
|
+
var PENALTY = { error: 12, warning: 4, info: 1, pass: 0 };
|
|
508
|
+
function scorePage(findings) {
|
|
509
|
+
let penalty = 0;
|
|
510
|
+
for (const f of findings) penalty += PENALTY[f.severity] ?? 0;
|
|
511
|
+
return Math.max(0, 100 - penalty);
|
|
512
|
+
}
|
|
513
|
+
function gradeFor(score) {
|
|
514
|
+
if (score >= 90) return "A";
|
|
515
|
+
if (score >= 80) return "B";
|
|
516
|
+
if (score >= 70) return "C";
|
|
517
|
+
if (score >= 60) return "D";
|
|
518
|
+
return "F";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/types.ts
|
|
522
|
+
var DIMENSIONS = ["links", "seo", "readability", "structure"];
|
|
523
|
+
var DIMENSION_LABELS = {
|
|
524
|
+
links: "Links & references",
|
|
525
|
+
seo: "SEO & frontmatter",
|
|
526
|
+
readability: "Readability",
|
|
527
|
+
structure: "Structure & a11y"
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// src/analyze.ts
|
|
531
|
+
function analyzeDocs(root, inputs, config, meta) {
|
|
532
|
+
const pages = inputs.map((i) => parsePage(i.absPath, i.relPath, i.content));
|
|
533
|
+
const ignore = new Set(config.ignore);
|
|
534
|
+
const dup = buildDuplicateIndex(pages);
|
|
535
|
+
const links = isDimensionEnabled(config, "links") ? linkFindings(root, pages) : /* @__PURE__ */ new Map();
|
|
536
|
+
const pageReports = pages.map((page) => {
|
|
537
|
+
const findings = [];
|
|
538
|
+
let readability;
|
|
539
|
+
if (isDimensionEnabled(config, "links")) {
|
|
540
|
+
findings.push(...links.get(page.relPath) ?? []);
|
|
541
|
+
}
|
|
542
|
+
if (isDimensionEnabled(config, "seo")) {
|
|
543
|
+
findings.push(...seoChecks(page, config, dup));
|
|
544
|
+
}
|
|
545
|
+
if (isDimensionEnabled(config, "structure")) {
|
|
546
|
+
findings.push(...structureChecks(page));
|
|
547
|
+
}
|
|
548
|
+
if (isDimensionEnabled(config, "readability")) {
|
|
549
|
+
const r = readabilityCheck(page, config);
|
|
550
|
+
findings.push(...r.findings);
|
|
551
|
+
readability = r.summary;
|
|
552
|
+
}
|
|
553
|
+
const kept = findings.filter((f) => !ignore.has(f.rule));
|
|
554
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
555
|
+
for (const f of kept) {
|
|
556
|
+
if (f.severity === "error") counts.error++;
|
|
557
|
+
else if (f.severity === "warning") counts.warning++;
|
|
558
|
+
else if (f.severity === "info") counts.info++;
|
|
559
|
+
}
|
|
560
|
+
const score2 = scorePage(kept);
|
|
561
|
+
return {
|
|
562
|
+
path: page.relPath,
|
|
563
|
+
title: pageTitle(page).value || null,
|
|
564
|
+
score: score2,
|
|
565
|
+
grade: gradeFor(score2),
|
|
566
|
+
counts,
|
|
567
|
+
findings: kept,
|
|
568
|
+
readability
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
pageReports.sort((a, b) => a.path.localeCompare(b.path));
|
|
572
|
+
const errors = pageReports.reduce((s, p) => s + p.counts.error, 0);
|
|
573
|
+
const warnings = pageReports.reduce((s, p) => s + p.counts.warning, 0);
|
|
574
|
+
const infos = pageReports.reduce((s, p) => s + p.counts.info, 0);
|
|
575
|
+
const score = pageReports.length ? Math.round(pageReports.reduce((s, p) => s + p.score, 0) / pageReports.length) : 100;
|
|
576
|
+
const byDimension = Object.fromEntries(
|
|
577
|
+
DIMENSIONS.map((d) => [d, { errors: 0, warnings: 0, infos: 0 }])
|
|
578
|
+
);
|
|
579
|
+
for (const p of pageReports) {
|
|
580
|
+
for (const f of p.findings) {
|
|
581
|
+
const bucket = byDimension[f.dimension];
|
|
582
|
+
if (f.severity === "error") bucket.errors++;
|
|
583
|
+
else if (f.severity === "warning") bucket.warnings++;
|
|
584
|
+
else if (f.severity === "info") bucket.infos++;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
tool: "docsanity",
|
|
589
|
+
version: meta.version,
|
|
590
|
+
generatedAt: meta.generatedAt,
|
|
591
|
+
root,
|
|
592
|
+
summary: { pages: pageReports.length, score, grade: gradeFor(score), errors, warnings, infos, byDimension },
|
|
593
|
+
pages: pageReports
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
597
|
+
let file = explicitPath ? path.resolve(cwd, explicitPath) : void 0;
|
|
598
|
+
if (!file) {
|
|
599
|
+
for (const name of CONFIG_FILENAMES) {
|
|
600
|
+
const candidate = path.resolve(cwd, name);
|
|
601
|
+
if (fs.existsSync(candidate)) {
|
|
602
|
+
file = candidate;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (!file) return DEFAULT_CONFIG;
|
|
608
|
+
if (!fs.existsSync(file)) throw new Error(`config file not found: ${file}`);
|
|
609
|
+
return parseConfig(fs.readFileSync(file, "utf8"), `config ${file}`);
|
|
610
|
+
}
|
|
611
|
+
var IGNORE_DIRS = /* @__PURE__ */ new Set([
|
|
612
|
+
"node_modules",
|
|
613
|
+
".git",
|
|
614
|
+
"dist",
|
|
615
|
+
"build",
|
|
616
|
+
".next",
|
|
617
|
+
".docusaurus",
|
|
618
|
+
".astro",
|
|
619
|
+
".cache",
|
|
620
|
+
"coverage"
|
|
621
|
+
]);
|
|
622
|
+
function walk(dir, exts, out) {
|
|
623
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
624
|
+
if (entry.isDirectory()) {
|
|
625
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
626
|
+
walk(path.join(dir, entry.name), exts, out);
|
|
627
|
+
} else if (entry.isFile() && exts.has(path.extname(entry.name).toLowerCase())) {
|
|
628
|
+
out.push(path.join(dir, entry.name));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function loadDocs(targets, config) {
|
|
633
|
+
const exts = new Set(config.extensions.map((e) => e.toLowerCase()));
|
|
634
|
+
const files = [];
|
|
635
|
+
let root = process.cwd();
|
|
636
|
+
if (targets.length === 1) {
|
|
637
|
+
const abs = path.resolve(targets[0]);
|
|
638
|
+
if (fs.statSync(abs).isDirectory()) root = abs;
|
|
639
|
+
}
|
|
640
|
+
for (const target of targets) {
|
|
641
|
+
const abs = path.resolve(target);
|
|
642
|
+
const stat = fs.statSync(abs);
|
|
643
|
+
if (stat.isDirectory()) walk(abs, exts, files);
|
|
644
|
+
else if (exts.has(path.extname(abs).toLowerCase())) files.push(abs);
|
|
645
|
+
}
|
|
646
|
+
const seen = /* @__PURE__ */ new Set();
|
|
647
|
+
const docs = [];
|
|
648
|
+
for (const file of files.sort()) {
|
|
649
|
+
if (seen.has(file)) continue;
|
|
650
|
+
seen.add(file);
|
|
651
|
+
docs.push({
|
|
652
|
+
absPath: file,
|
|
653
|
+
relPath: path.relative(root, file) || file,
|
|
654
|
+
content: fs.readFileSync(file, "utf8")
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return { root, docs };
|
|
658
|
+
}
|
|
659
|
+
function sev(severity, text) {
|
|
660
|
+
if (severity === "error") return pc__default.default.red(text);
|
|
661
|
+
if (severity === "warning") return pc__default.default.yellow(text);
|
|
662
|
+
if (severity === "info") return pc__default.default.blue(text);
|
|
663
|
+
return pc__default.default.green(text);
|
|
664
|
+
}
|
|
665
|
+
var MARK = { error: "\u2717", warning: "\u26A0", info: "\u2139", pass: "\u2713" };
|
|
666
|
+
function gradeColor(grade) {
|
|
667
|
+
if (grade === "A" || grade === "B") return pc__default.default.green;
|
|
668
|
+
if (grade === "C" || grade === "D") return pc__default.default.yellow;
|
|
669
|
+
return pc__default.default.red;
|
|
670
|
+
}
|
|
671
|
+
function printReport(report, quiet = false) {
|
|
672
|
+
for (const page of report.pages) {
|
|
673
|
+
const g2 = gradeColor(page.grade);
|
|
674
|
+
const head = `${pc__default.default.bold(page.path)} ${g2(`${page.score}/100 (${page.grade})`)}`;
|
|
675
|
+
const meta = page.readability ? pc__default.default.dim(` grade ${page.readability.grade.toFixed(0)} \xB7 ${page.readability.words}w`) : "";
|
|
676
|
+
console.log(`
|
|
677
|
+
${head}${meta}`);
|
|
678
|
+
if (quiet) continue;
|
|
679
|
+
if (page.findings.length === 0) {
|
|
680
|
+
console.log(` ${pc__default.default.green("\u2713 no issues")}`);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
for (const f of page.findings) {
|
|
684
|
+
const where = f.line ? pc__default.default.dim(`L${f.line}`.padEnd(6)) : "".padEnd(6);
|
|
685
|
+
const mark = sev(f.severity, MARK[f.severity]);
|
|
686
|
+
console.log(` ${mark} ${where} ${f.message} ${pc__default.default.dim(f.rule)}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const s = report.summary;
|
|
690
|
+
const g = gradeColor(s.grade);
|
|
691
|
+
console.log(`
|
|
692
|
+
${pc__default.default.bold("Dimensions")}`);
|
|
693
|
+
for (const dim of Object.keys(s.byDimension)) {
|
|
694
|
+
const b = s.byDimension[dim];
|
|
695
|
+
const status = b.errors + b.warnings === 0 ? pc__default.default.green("clean") : `${pc__default.default.red(`${b.errors}e`)} ${pc__default.default.yellow(`${b.warnings}w`)}`;
|
|
696
|
+
console.log(` ${DIMENSION_LABELS[dim].padEnd(22)} ${status}`);
|
|
697
|
+
}
|
|
698
|
+
console.log(
|
|
699
|
+
`
|
|
700
|
+
${pc__default.default.bold("Overall")} ${g(`${s.score}/100 (${s.grade})`)} ` + pc__default.default.dim(`\xB7 ${s.pages} page(s), ${s.errors} error(s), ${s.warnings} warning(s)`)
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/report/json.ts
|
|
705
|
+
function toJSON(report) {
|
|
706
|
+
return JSON.stringify(report, null, 2) + "\n";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/report/markdown.ts
|
|
710
|
+
var ICON = { error: "\u2717", warning: "\u26A0", info: "\u2139", pass: "\u2713" };
|
|
711
|
+
function toMarkdown(report) {
|
|
712
|
+
const s = report.summary;
|
|
713
|
+
const out = [];
|
|
714
|
+
out.push(`# docsanity report`);
|
|
715
|
+
out.push("");
|
|
716
|
+
out.push(
|
|
717
|
+
`**${s.score}/100 (${s.grade})** across **${s.pages}** page(s) \u2014 ${s.errors} error(s), ${s.warnings} warning(s), ${s.infos} info(s)`
|
|
718
|
+
);
|
|
719
|
+
out.push("");
|
|
720
|
+
out.push(`| Dimension | Errors | Warnings |`);
|
|
721
|
+
out.push(`| --------- | -----: | -------: |`);
|
|
722
|
+
for (const dim of Object.keys(report.summary.byDimension)) {
|
|
723
|
+
const b = report.summary.byDimension[dim];
|
|
724
|
+
out.push(`| ${DIMENSION_LABELS[dim]} | ${b.errors} | ${b.warnings} |`);
|
|
725
|
+
}
|
|
726
|
+
out.push("");
|
|
727
|
+
for (const page of report.pages) {
|
|
728
|
+
const head = `## ${page.path} \u2014 ${page.score}/100 (${page.grade})`;
|
|
729
|
+
out.push(head);
|
|
730
|
+
if (page.readability) {
|
|
731
|
+
out.push(
|
|
732
|
+
`_${page.readability.words} words \xB7 grade ${page.readability.grade.toFixed(0)} (${page.readability.gradeLabel}) \xB7 ${page.readability.ease}_`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
out.push("");
|
|
736
|
+
if (page.findings.length === 0) {
|
|
737
|
+
out.push(`\u2713 No issues.`);
|
|
738
|
+
out.push("");
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
out.push(`| | Rule | Where | Message |`);
|
|
742
|
+
out.push(`| - | ---- | ----- | ------- |`);
|
|
743
|
+
for (const f of page.findings) {
|
|
744
|
+
const where = f.line ? `L${f.line}` : "";
|
|
745
|
+
out.push(`| ${ICON[f.severity]} | \`${f.rule}\` | ${where} | ${f.message.replace(/\|/g, "\\|")} |`);
|
|
746
|
+
}
|
|
747
|
+
out.push("");
|
|
748
|
+
}
|
|
749
|
+
out.push(`---`);
|
|
750
|
+
out.push(`<sub>Generated by docsanity v${report.version} \xB7 ${report.generatedAt}</sub>`);
|
|
751
|
+
out.push("");
|
|
752
|
+
return out.join("\n");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/cli.ts
|
|
756
|
+
var cli = cac.cac("docsanity");
|
|
757
|
+
function fail(message) {
|
|
758
|
+
console.error(`${pc__default.default.red("docsanity:")} ${message}`);
|
|
759
|
+
process.exit(2);
|
|
760
|
+
}
|
|
761
|
+
function parseDimensions(value) {
|
|
762
|
+
if (!value) return [];
|
|
763
|
+
const known = new Set(DIMENSIONS);
|
|
764
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean).map((d) => {
|
|
765
|
+
if (!known.has(d)) fail(`unknown dimension "${d}". Known: ${DIMENSIONS.join(", ")}`);
|
|
766
|
+
return d;
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
cli.command("scan [...targets]", "Audit a docs directory or files").option("--config <file>", "Path to a config file").option("--disable <dims>", "Comma-separated dimensions to skip (links,seo,readability,structure)").option("--max-grade <n>", "Flag pages reading above this grade level").option("--json <file>", "Write a JSON report to this path").option("--md <file>", "Write a Markdown report to this path").option("--min-score <n>", "CI gate: exit non-zero if the overall score is below this").option("--quiet", "Show only per-page headers").example(" docsanity scan ./docs").example(" docsanity scan ./docs --min-score 85 --md docs-report.md").example(" docsanity scan ./docs --disable readability").action((targets, options) => {
|
|
770
|
+
if (!targets || targets.length === 0) fail("provide a docs directory or files to scan.");
|
|
771
|
+
try {
|
|
772
|
+
const config = loadConfig(options.config);
|
|
773
|
+
const disable = parseDimensions(options.disable);
|
|
774
|
+
if (disable.length) config.disable = [.../* @__PURE__ */ new Set([...config.disable, ...disable])];
|
|
775
|
+
if (options.maxGrade !== void 0) config.maxGrade = Number(options.maxGrade);
|
|
776
|
+
const { root, docs } = loadDocs(targets, config);
|
|
777
|
+
if (docs.length === 0) fail(`no matching files (${config.extensions.join(", ")}) found.`);
|
|
778
|
+
const report = analyzeDocs(root, docs, config, {
|
|
779
|
+
version: package_default.version,
|
|
780
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
781
|
+
});
|
|
782
|
+
printReport(report, Boolean(options.quiet));
|
|
783
|
+
if (options.json) {
|
|
784
|
+
fs.writeFileSync(path.resolve(options.json), toJSON(report));
|
|
785
|
+
console.log(pc__default.default.dim(`
|
|
786
|
+
Wrote JSON report \u2192 ${options.json}`));
|
|
787
|
+
}
|
|
788
|
+
if (options.md) {
|
|
789
|
+
fs.writeFileSync(path.resolve(options.md), toMarkdown(report));
|
|
790
|
+
console.log(pc__default.default.dim(`Wrote Markdown report \u2192 ${options.md}`));
|
|
791
|
+
}
|
|
792
|
+
const minScore = options.minScore !== void 0 ? Number(options.minScore) : config.minScore;
|
|
793
|
+
if (report.summary.score < minScore) {
|
|
794
|
+
console.error(
|
|
795
|
+
`
|
|
796
|
+
${pc__default.default.red("docsanity:")} score ${report.summary.score} is below the minimum ${minScore}.`
|
|
797
|
+
);
|
|
798
|
+
process.exit(1);
|
|
799
|
+
}
|
|
800
|
+
} catch (e) {
|
|
801
|
+
fail(e.message);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
cli.command("report <input>", "Render a saved JSON report as Markdown").option("--md <file>", "Write Markdown to this path instead of stdout").action((input, options) => {
|
|
805
|
+
try {
|
|
806
|
+
const report = JSON.parse(fs.readFileSync(path.resolve(input), "utf8"));
|
|
807
|
+
const md = toMarkdown(report);
|
|
808
|
+
if (options.md) {
|
|
809
|
+
fs.writeFileSync(path.resolve(options.md), md);
|
|
810
|
+
console.log(`Wrote ${options.md}`);
|
|
811
|
+
} else {
|
|
812
|
+
process.stdout.write(md);
|
|
813
|
+
}
|
|
814
|
+
} catch (e) {
|
|
815
|
+
fail(e.message);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
cli.command("init", "Write a docsanity.config.json with the defaults").option("--force", "Overwrite an existing config").action((options) => {
|
|
819
|
+
const file = path.resolve("docsanity.config.json");
|
|
820
|
+
if (fs.existsSync(file) && !options.force) {
|
|
821
|
+
console.error(`${pc__default.default.red("docsanity:")} docsanity.config.json already exists (use --force).`);
|
|
822
|
+
process.exit(1);
|
|
823
|
+
}
|
|
824
|
+
fs.writeFileSync(file, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
825
|
+
console.log("Created docsanity.config.json");
|
|
826
|
+
});
|
|
827
|
+
cli.help();
|
|
828
|
+
cli.version(package_default.version);
|
|
829
|
+
cli.parse();
|
|
830
|
+
//# sourceMappingURL=cli.cjs.map
|
|
831
|
+
//# sourceMappingURL=cli.cjs.map
|