@writedocs/generator 0.4.12 → 0.6.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/bin/writedocs.js +67 -1
- package/package.json +1 -1
- package/src/cli/astro-output.js +33 -6
- package/src/cli/build.js +1 -1
- package/src/cli/dev.js +2 -2
- package/src/cli/generate-api-pages.js +1 -56
- package/src/cli/output.js +6 -0
- package/src/lib/a11y-check.js +291 -0
- package/src/lib/content-check.js +154 -2
- package/src/lib/link-check.js +473 -0
- package/src/lib/openapi-spec.js +73 -0
package/bin/writedocs.js
CHANGED
|
@@ -8,7 +8,7 @@ import { runDev } from '../src/cli/dev.js';
|
|
|
8
8
|
import { runBuild } from '../src/cli/build.js';
|
|
9
9
|
import { runInit } from '../src/cli/init.js';
|
|
10
10
|
import { requireBuildKey } from '../src/cli/build-auth.js';
|
|
11
|
-
import { log, step, plural, color, CliExit, errorText } from '../src/cli/output.js';
|
|
11
|
+
import { log, step, plural, color, CliExit, errorText, stopActiveStep } from '../src/cli/output.js';
|
|
12
12
|
// O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
|
|
13
13
|
// que a plataforma importa por `@writedocs/generator/config-schema` - e o que
|
|
14
14
|
// faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
|
|
@@ -170,6 +170,71 @@ program
|
|
|
170
170
|
}
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
program
|
|
174
|
+
// Like `validate`: no key, no build, no network - reads the project and
|
|
175
|
+
// exits 0 (no broken links) or 1, so it works as a CI step.
|
|
176
|
+
.command('broken-links')
|
|
177
|
+
.description('Check every internal link - pages, anchors and files - in the pages and writedocs.json')
|
|
178
|
+
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
179
|
+
.action(async (dir) => {
|
|
180
|
+
const contentDir = path.resolve(process.cwd(), dir);
|
|
181
|
+
const { preflightCheck } = await import('../src/cli/preflight.js');
|
|
182
|
+
preflightCheck(contentDir);
|
|
183
|
+
const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
|
|
184
|
+
const checking = step('Checking links');
|
|
185
|
+
// Generated OpenAPI pages are link targets too - same step dev/build run.
|
|
186
|
+
const { generateApiPages } = await import('../src/cli/generate-api-pages.js');
|
|
187
|
+
await generateApiPages({ contentDir });
|
|
188
|
+
const { checkLinks } = await import('../src/lib/link-check.js');
|
|
189
|
+
const { formatContentIssues } = await import('../src/lib/content-check.js');
|
|
190
|
+
const result = await checkLinks(contentDir, configText);
|
|
191
|
+
checking.stop();
|
|
192
|
+
|
|
193
|
+
const summary = `${plural(result.links, 'link')} in ${plural(result.pages, 'page')}${
|
|
194
|
+
result.external ? color.dim(` (${plural(result.external, 'external link')} not checked)`) : ''
|
|
195
|
+
}`;
|
|
196
|
+
if (result.broken.length === 0) {
|
|
197
|
+
log.success(`No broken links - checked ${summary}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
log.error(plural(result.broken.length, 'broken link'));
|
|
201
|
+
log.line();
|
|
202
|
+
log.line(formatContentIssues(result.broken));
|
|
203
|
+
log.line();
|
|
204
|
+
log.error(`${plural(result.broken.length, 'broken link')} - checked ${summary}`);
|
|
205
|
+
throw new CliExit(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
program
|
|
209
|
+
// Same shape as `broken-links`: reads the project, exits 0 (nothing
|
|
210
|
+
// found) or 1.
|
|
211
|
+
.command('a11y')
|
|
212
|
+
.description('Check accessibility - color contrast, image alt text, headings, link text')
|
|
213
|
+
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
214
|
+
.action(async (dir) => {
|
|
215
|
+
const contentDir = path.resolve(process.cwd(), dir);
|
|
216
|
+
const { preflightCheck } = await import('../src/cli/preflight.js');
|
|
217
|
+
preflightCheck(contentDir);
|
|
218
|
+
const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
|
|
219
|
+
const checking = step('Checking accessibility');
|
|
220
|
+
const { checkAccessibility } = await import('../src/lib/a11y-check.js');
|
|
221
|
+
const { formatContentIssues } = await import('../src/lib/content-check.js');
|
|
222
|
+
const result = await checkAccessibility(contentDir, configText);
|
|
223
|
+
checking.stop();
|
|
224
|
+
|
|
225
|
+
if (result.issues.length === 0) {
|
|
226
|
+
log.success(`No accessibility issues - checked writedocs.json and ${plural(result.pages, 'page')}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const count = plural(result.issues.length, 'accessibility issue');
|
|
230
|
+
log.error(count);
|
|
231
|
+
log.line();
|
|
232
|
+
log.line(formatContentIssues(result.issues));
|
|
233
|
+
log.line();
|
|
234
|
+
log.error(`${count} - checked writedocs.json and ${plural(result.pages, 'page')}`);
|
|
235
|
+
throw new CliExit(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
173
238
|
program
|
|
174
239
|
.command('convert')
|
|
175
240
|
.description("Convert another docs tool's config into writedocs.json")
|
|
@@ -203,6 +268,7 @@ program
|
|
|
203
268
|
|
|
204
269
|
program.parseAsync(process.argv).catch((err) => {
|
|
205
270
|
// A command that already printed its own error throws CliExit.
|
|
271
|
+
stopActiveStep();
|
|
206
272
|
if (err instanceof CliExit) process.exit(err.code);
|
|
207
273
|
log.error(errorText(err));
|
|
208
274
|
process.exit(1);
|
package/package.json
CHANGED
package/src/cli/astro-output.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// author's file and line, and the reason in one sentence - instead of a
|
|
4
4
|
// bundler error with a stack trace; a log line is either something the
|
|
5
5
|
// author should see or noise to hide.
|
|
6
|
+
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
|
|
8
9
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
@@ -12,10 +13,14 @@ export const stripAnsi = (text) => String(text ?? '').replace(ANSI, '');
|
|
|
12
13
|
// append these sections after the message itself.
|
|
13
14
|
const SECTION = /\n\s*(Stack trace|Hint|Error reference|Location|Caused by):/;
|
|
14
15
|
|
|
15
|
-
function
|
|
16
|
-
if (!file) return null;
|
|
16
|
+
function absolutePath(file, root) {
|
|
17
17
|
const clean = file.replace(/^file:\/\/\/?/, '').replace(/[?#].*$/, '');
|
|
18
|
-
|
|
18
|
+
return path.resolve(root ?? process.cwd(), clean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function relativeTo(contentDir, file, root) {
|
|
22
|
+
if (!file) return null;
|
|
23
|
+
const abs = absolutePath(file, root);
|
|
19
24
|
const rel = path.relative(contentDir, abs);
|
|
20
25
|
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
21
26
|
return rel.split(path.sep).join('/');
|
|
@@ -28,8 +33,9 @@ function position(line, column) {
|
|
|
28
33
|
|
|
29
34
|
/** Everything an Astro/Vite error can say, as { where, message, hint }.
|
|
30
35
|
* `input` is a serialized error (astro-worker.js serializeError) or the
|
|
31
|
-
* text of an error log line.
|
|
32
|
-
|
|
36
|
+
* text of an error log line. `root`: the folder relative paths in it are
|
|
37
|
+
* relative to - Astro's working folder, the package root. */
|
|
38
|
+
export function describeError(input, contentDir, { root } = {}) {
|
|
33
39
|
const error = typeof input === 'string' ? { message: input } : input ?? {};
|
|
34
40
|
let text = stripAnsi(error.message).replace(/\r/g, '');
|
|
35
41
|
let file = error.loc?.file ?? null;
|
|
@@ -58,6 +64,27 @@ export function describeError(input, contentDir) {
|
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
// A rolldown diagnostic, with a code frame of the *compiled* module
|
|
68
|
+
// (whose line numbers aren't the page's):
|
|
69
|
+
// [UNRESOLVED_IMPORT] Could not resolve './pic.png' in ../docs/page.mdx
|
|
70
|
+
// ╭─[ ../docs/page.mdx:9:29 ]
|
|
71
|
+
const diagnostic = text.match(/^\[([A-Z][A-Z_]+)\]\s+([^\n]*)/);
|
|
72
|
+
if (diagnostic) {
|
|
73
|
+
const unresolved = diagnostic[2].match(/^Could not resolve '([^']+)' in (.+)$/);
|
|
74
|
+
if (unresolved) {
|
|
75
|
+
const [, spec, from] = unresolved;
|
|
76
|
+
file = file ?? from.trim();
|
|
77
|
+
text = `Can't find ${spec}, which this page uses.`;
|
|
78
|
+
// The line it's on in the page itself.
|
|
79
|
+
try {
|
|
80
|
+
const index = fs.readFileSync(absolutePath(file, root), 'utf8').split('\n').findIndex((l) => l.includes(spec));
|
|
81
|
+
if (index !== -1) line = index + 1;
|
|
82
|
+
} catch {}
|
|
83
|
+
} else {
|
|
84
|
+
text = diagnostic[2];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
// Astro's own sections: keep the hint, and take the file from the first
|
|
62
89
|
// stack frame when it's one of the author's files.
|
|
63
90
|
const sectionAt = text.search(SECTION);
|
|
@@ -93,7 +120,7 @@ export function describeError(input, contentDir) {
|
|
|
93
120
|
|
|
94
121
|
if (name === 'YAMLException') text = `Frontmatter isn't valid YAML: ${text}`;
|
|
95
122
|
|
|
96
|
-
const rel = relativeTo(contentDir, file);
|
|
123
|
+
const rel = relativeTo(contentDir, file, root);
|
|
97
124
|
return {
|
|
98
125
|
where: rel ? `${rel}${position(line, column)}` : null,
|
|
99
126
|
message: text || 'Unknown error.',
|
package/src/cli/build.js
CHANGED
|
@@ -72,7 +72,7 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
|
|
|
72
72
|
if (fatal || code !== 0) {
|
|
73
73
|
building.fail('Build failed');
|
|
74
74
|
if (fatal) {
|
|
75
|
-
const problem = describeError(fatal, contentDir);
|
|
75
|
+
const problem = describeError(fatal, contentDir, { root: packageRoot });
|
|
76
76
|
log.line();
|
|
77
77
|
log.line(formatProblems([{ where: problem.where, message: problem.message }]));
|
|
78
78
|
if (problem.hint) log.line(`\n${color.dim(` Hint: ${problem.hint}`)}`);
|
package/src/cli/dev.js
CHANGED
|
@@ -112,7 +112,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
112
112
|
return;
|
|
113
113
|
case 'astro-log': {
|
|
114
114
|
if (event.level === 'error') {
|
|
115
|
-
showError(describeError(stripAnsi(event.message), contentDir));
|
|
115
|
+
showError(describeError(stripAnsi(event.message), contentDir, { root: packageRoot }));
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
const request = requestLog(event);
|
|
@@ -151,7 +151,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
151
151
|
if (!ready) starting.fail('Could not start the local preview');
|
|
152
152
|
else log.error('The local preview stopped unexpectedly.');
|
|
153
153
|
if (fatal) {
|
|
154
|
-
const problem = describeError(fatal, contentDir);
|
|
154
|
+
const problem = describeError(fatal, contentDir, { root: packageRoot });
|
|
155
155
|
log.line();
|
|
156
156
|
log.line(formatProblems([{ where: problem.where, message: problem.message }]));
|
|
157
157
|
if (problem.hint) log.line(`\n${color.dim(` Hint: ${problem.hint}`)}`);
|
|
@@ -5,8 +5,8 @@ import matter from 'gray-matter';
|
|
|
5
5
|
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
6
6
|
import { parseOpenApiRef, openApiOperationKey, specDirName } from '../lib/openapi-ref.js';
|
|
7
7
|
import { findAllPages } from '../lib/pages.js';
|
|
8
|
+
import { HTTP_METHODS, collectOpenApiGroups } from '../lib/openapi-spec.js';
|
|
8
9
|
|
|
9
|
-
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
10
10
|
|
|
11
11
|
/** Canonical "METHOD /path" key used everywhere an operation needs to be
|
|
12
12
|
* identified: the manifest, a hand-written page's `openapi:` frontmatter,
|
|
@@ -172,61 +172,6 @@ function rmrf(dir) {
|
|
|
172
172
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
/** Walks writedocs.json's raw `navigation` tree (any shape - a bare array, or
|
|
176
|
-
* an object choosing tabs/versions/languages/dropdowns/products, plus
|
|
177
|
-
* `global.dropdowns`) looking for group nodes shaped like
|
|
178
|
-
* `{ group, openapi: { src, path } }`, however deeply nested inside
|
|
179
|
-
* hand-authored groups or containers. Runs directly against the raw
|
|
180
|
-
* JSON (before writedocs.json's own zod validation even happens - this CLI
|
|
181
|
-
* step runs first, see generateApiPages() below), so it deliberately
|
|
182
|
-
* doesn't import anything from lib/config.ts and just duck-types each
|
|
183
|
-
* node the same way lib/config.ts's own walkSections()/
|
|
184
|
-
* expandOpenApiInContainer() do. */
|
|
185
|
-
function collectOpenApiGroups(navigation) {
|
|
186
|
-
const found = [];
|
|
187
|
-
|
|
188
|
-
function fromPagesItem(item) {
|
|
189
|
-
if (!item || typeof item !== 'object') return; // plain page-slug string - not a group
|
|
190
|
-
if (item.group && item.openapi) {
|
|
191
|
-
found.push(item);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if (Array.isArray(item.pages)) {
|
|
195
|
-
for (const child of item.pages) fromPagesItem(child);
|
|
196
|
-
}
|
|
197
|
-
// otherwise a { label, href } link leaf - nothing to collect
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function fromContainer(node) {
|
|
201
|
-
if (!node || typeof node !== 'object') return;
|
|
202
|
-
if (Array.isArray(node.pages)) {
|
|
203
|
-
for (const item of node.pages) fromPagesItem(item);
|
|
204
|
-
} else if (Array.isArray(node.tabs)) {
|
|
205
|
-
for (const t of node.tabs) fromContainer(t);
|
|
206
|
-
} else if (Array.isArray(node.versions)) {
|
|
207
|
-
for (const v of node.versions) fromContainer(v);
|
|
208
|
-
} else if (Array.isArray(node.languages)) {
|
|
209
|
-
for (const l of node.languages) fromContainer(l);
|
|
210
|
-
} else if (Array.isArray(node.dropdowns)) {
|
|
211
|
-
for (const d of node.dropdowns) fromContainer(d);
|
|
212
|
-
} else if (Array.isArray(node.products)) {
|
|
213
|
-
for (const p of node.products) fromContainer(p);
|
|
214
|
-
}
|
|
215
|
-
// otherwise a bare { href } container - nothing to collect
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (Array.isArray(navigation)) {
|
|
219
|
-
for (const item of navigation) fromPagesItem(item);
|
|
220
|
-
} else if (navigation && typeof navigation === 'object') {
|
|
221
|
-
if (Array.isArray(navigation.global?.dropdowns)) {
|
|
222
|
-
for (const d of navigation.global.dropdowns) fromContainer(d);
|
|
223
|
-
}
|
|
224
|
-
fromContainer(navigation);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return found;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
175
|
/** Generates every page for one `{ group, openapi: { src, path } }`
|
|
231
176
|
* navigation node: dereferences its spec, writes a stub page per
|
|
232
177
|
* operation (unless a hand-written override already claims it), and
|
package/src/cli/output.js
CHANGED
|
@@ -67,6 +67,11 @@ export function indent(text, spaces) {
|
|
|
67
67
|
.join('\n');
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Ends whatever step is still spinning - for an error thrown mid-step. */
|
|
71
|
+
export function stopActiveStep() {
|
|
72
|
+
activeSpinner?.stop();
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
71
76
|
|
|
72
77
|
/** A step in progress: a spinner with `text` on a terminal, nothing
|
|
@@ -76,6 +81,7 @@ export function step(text) {
|
|
|
76
81
|
let frame = 0;
|
|
77
82
|
let timer = null;
|
|
78
83
|
const spinner = {
|
|
84
|
+
stop: () => end(),
|
|
79
85
|
clear() {
|
|
80
86
|
stream.write('\r\x1b[2K');
|
|
81
87
|
},
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// `writedocs a11y`: accessibility problems that can be found in the source,
|
|
2
|
+
// without building - the ones an author introduces and can fix in a page or
|
|
3
|
+
// in writedocs.json. Plain JavaScript, loaded by plain Node from an
|
|
4
|
+
// installed package (see lib/icons.js's comment on why not TypeScript).
|
|
5
|
+
//
|
|
6
|
+
// - contrast (WCAG 2 AA, 4.5:1 for text) of the colors writedocs.json sets:
|
|
7
|
+
// links (styles.colors.primary) on the light and dark backgrounds, body
|
|
8
|
+
// text (styles.colors.text) on them, white text on primary (step
|
|
9
|
+
// numbers, the info banner), and the navbar's text on its own color.
|
|
10
|
+
// Only colors the project sets are checked - writedocs' own defaults
|
|
11
|
+
// aren't something the author wrote.
|
|
12
|
+
// - images without alt text: a Markdown image with empty alt, an
|
|
13
|
+
// <img>/<Image> with no `alt` at all (alt="" is how an image is marked
|
|
14
|
+
// decorative, so it's accepted)
|
|
15
|
+
// - an <iframe> without a `title`
|
|
16
|
+
// - a link with no text
|
|
17
|
+
// - headings: a skipped level (the page title is the h1, so the first
|
|
18
|
+
// section heading is ##), and a # heading in the page body - a second h1.
|
|
19
|
+
// A custom or blank page has no title h1 (it builds its own layout), so
|
|
20
|
+
// its own h1 is expected there.
|
|
21
|
+
//
|
|
22
|
+
// Issues are shaped like lib/content-check.js's: { file, line?, message,
|
|
23
|
+
// suggestion? }.
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import matter from 'gray-matter';
|
|
27
|
+
import { visit } from 'unist-util-visit';
|
|
28
|
+
import { findAllPages } from './pages.js';
|
|
29
|
+
import { createJsonLocator } from './config-schema.js';
|
|
30
|
+
|
|
31
|
+
const processors = {};
|
|
32
|
+
async function parse(text, format) {
|
|
33
|
+
if (!processors[format]) {
|
|
34
|
+
const { createProcessor } = await import('@mdx-js/mdx');
|
|
35
|
+
processors[format] = createProcessor({ format });
|
|
36
|
+
}
|
|
37
|
+
return processors[format].parse(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Contrast -------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
// The theme's own fallbacks (src/layout/BaseLayout.astro).
|
|
43
|
+
const DEFAULT_BACKGROUND = { light: '#ffffff', dark: '#0b1120' };
|
|
44
|
+
const DEFAULT_TEXT = { light: '#0f172a', dark: '#e2e8f0' };
|
|
45
|
+
const MIN_TEXT_CONTRAST = 4.5;
|
|
46
|
+
|
|
47
|
+
function parseHex(value) {
|
|
48
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(value ?? '').trim());
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
|
|
51
|
+
return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function luminance(rgb) {
|
|
55
|
+
const [r, g, b] = rgb.map((v) => {
|
|
56
|
+
const c = v / 255;
|
|
57
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
58
|
+
});
|
|
59
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** WCAG contrast ratio of two hex colors, or null when either isn't hex. */
|
|
63
|
+
export function contrastRatio(a, b) {
|
|
64
|
+
const x = parseHex(a);
|
|
65
|
+
const y = parseHex(b);
|
|
66
|
+
if (!x || !y) return null;
|
|
67
|
+
const [hi, lo] = [luminance(x), luminance(y)].sort((p, q) => q - p);
|
|
68
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Black or white - the same rule BaseLayout.astro uses for text on a
|
|
72
|
+
* configured navbar color (contrastTextColor() in lib/config.ts). */
|
|
73
|
+
function navbarTextFor(hex) {
|
|
74
|
+
const rgb = parseHex(hex);
|
|
75
|
+
if (!rgb) return '#ffffff';
|
|
76
|
+
const [r, g, b] = rgb;
|
|
77
|
+
return (299 * r + 587 * g + 114 * b) / 1000 > 150 ? '#000000' : '#ffffff';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkColors(config, locate) {
|
|
81
|
+
const issues = [];
|
|
82
|
+
const styles = config?.styles ?? {};
|
|
83
|
+
const colors = styles.colors ?? {};
|
|
84
|
+
const background = {
|
|
85
|
+
light: styles.background?.colors?.light ?? DEFAULT_BACKGROUND.light,
|
|
86
|
+
dark: styles.background?.colors?.dark ?? DEFAULT_BACKGROUND.dark,
|
|
87
|
+
};
|
|
88
|
+
const at = (...trail) => locate(['styles', ...trail])?.line;
|
|
89
|
+
const ratioText = (r) => `${r.toFixed(2)}:1`;
|
|
90
|
+
const low = (fg, bg) => {
|
|
91
|
+
const r = contrastRatio(fg, bg);
|
|
92
|
+
return r !== null && r < MIN_TEXT_CONTRAST ? r : null;
|
|
93
|
+
};
|
|
94
|
+
const push = (line, message, suggestion) => issues.push({ file: 'writedocs.json', line, message, suggestion });
|
|
95
|
+
|
|
96
|
+
const primary = { light: colors.primary, dark: colors.dark?.primary ?? colors.primary };
|
|
97
|
+
const text = { light: colors.text, dark: colors.dark?.text };
|
|
98
|
+
const setBackground = { light: styles.background?.colors?.light, dark: styles.background?.colors?.dark };
|
|
99
|
+
|
|
100
|
+
for (const mode of ['light', 'dark']) {
|
|
101
|
+
// Links: primary on the page background. Checked when either one is
|
|
102
|
+
// the project's own choice.
|
|
103
|
+
const primaryIsSet = mode === 'light' ? colors.primary !== undefined : colors.dark?.primary !== undefined || colors.primary !== undefined;
|
|
104
|
+
if (primary[mode] && (primaryIsSet || setBackground[mode])) {
|
|
105
|
+
const r = low(primary[mode], background[mode]);
|
|
106
|
+
if (r) {
|
|
107
|
+
const line = mode === 'dark' && colors.dark?.primary ? at('colors', 'dark', 'primary') : colors.primary ? at('colors', 'primary') : at('background', 'colors', mode);
|
|
108
|
+
push(
|
|
109
|
+
line,
|
|
110
|
+
`Links are hard to read in ${mode} mode: the primary color ${primary[mode]} on the ${mode} background ${background[mode]} has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`,
|
|
111
|
+
mode === 'dark' && !colors.dark?.primary
|
|
112
|
+
? 'Set a lighter styles.colors.dark.primary for dark mode.'
|
|
113
|
+
: `Use a ${mode === 'light' ? 'darker' : 'lighter'} primary color, or change the background.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Body text.
|
|
118
|
+
if (text[mode] || setBackground[mode]) {
|
|
119
|
+
const fg = text[mode] ?? DEFAULT_TEXT[mode];
|
|
120
|
+
const r = low(fg, background[mode]);
|
|
121
|
+
if (r) {
|
|
122
|
+
push(
|
|
123
|
+
text[mode] ? at('colors', ...(mode === 'dark' ? ['dark', 'text'] : ['text'])) : at('background', 'colors', mode),
|
|
124
|
+
`Body text is hard to read in ${mode} mode: ${fg} on ${background[mode]} has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// White text on the primary color (step numbers, the info banner).
|
|
131
|
+
const primaries = [...new Set([colors.primary, colors.dark?.primary].filter(Boolean))];
|
|
132
|
+
for (const color of primaries) {
|
|
133
|
+
const r = low('#ffffff', color);
|
|
134
|
+
if (r) {
|
|
135
|
+
push(
|
|
136
|
+
color === colors.primary ? at('colors', 'primary') : at('colors', 'dark', 'primary'),
|
|
137
|
+
`White text on the primary color ${color} (step numbers, the info banner) has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`,
|
|
138
|
+
'Use a darker primary color.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The navbar's text on its own color - black or white, picked by a rough
|
|
144
|
+
// brightness rule that can land on the weaker of the two.
|
|
145
|
+
for (const mode of ['light', 'dark']) {
|
|
146
|
+
const side = styles.navbar?.[mode];
|
|
147
|
+
const bg = typeof side === 'string' ? side : side?.background;
|
|
148
|
+
if (!bg) continue;
|
|
149
|
+
const fg = navbarTextFor(bg);
|
|
150
|
+
const r = low(fg, bg);
|
|
151
|
+
if (r) {
|
|
152
|
+
push(
|
|
153
|
+
at('navbar', mode, ...(typeof side === 'string' ? [] : ['background'])),
|
|
154
|
+
`The navbar's ${fg === '#ffffff' ? 'white' : 'black'} text on ${bg} (${mode} mode) has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`,
|
|
155
|
+
'Use a darker or a lighter navbar color - mid-tones can\'t reach 4.5:1 with either black or white text.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return issues;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Pages ----------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function attribute(node, name) {
|
|
165
|
+
const attr = node.attributes?.find((a) => a.type === 'mdxJsxAttribute' && a.name === name);
|
|
166
|
+
if (!attr) return undefined;
|
|
167
|
+
return typeof attr.value === 'string' ? attr.value : null; // null: an expression - can't tell, so not reported
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function textOf(node) {
|
|
171
|
+
if (typeof node.value === 'string' && (node.type === 'text' || node.type === 'inlineCode')) return node.value;
|
|
172
|
+
if (node.type === 'image') return node.alt ?? '';
|
|
173
|
+
return (node.children ?? []).map(textOf).join('');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Issues in one page (`headings`: true) or snippet (false). */
|
|
177
|
+
async function checkFile(contentDir, abs, { headings }) {
|
|
178
|
+
let raw;
|
|
179
|
+
try {
|
|
180
|
+
raw = fs.readFileSync(abs, 'utf-8').replace(/\r\n/g, '\n');
|
|
181
|
+
} catch {
|
|
182
|
+
return { issues: [], imports: [] };
|
|
183
|
+
}
|
|
184
|
+
let parsed;
|
|
185
|
+
try {
|
|
186
|
+
parsed = matter(raw, {});
|
|
187
|
+
} catch {
|
|
188
|
+
return { issues: [], imports: [] }; // `writedocs validate` reports it
|
|
189
|
+
}
|
|
190
|
+
const bodyStart = raw.lastIndexOf(parsed.content);
|
|
191
|
+
const lineOffset = bodyStart > 0 ? raw.slice(0, bodyStart).split('\n').length - 1 : 0;
|
|
192
|
+
let tree;
|
|
193
|
+
try {
|
|
194
|
+
tree = await parse(parsed.content, /\.mdx$/i.test(abs) ? 'mdx' : 'md');
|
|
195
|
+
} catch {
|
|
196
|
+
return { issues: [], imports: [] }; // `writedocs validate` reports it
|
|
197
|
+
}
|
|
198
|
+
const file = path.relative(contentDir, abs).split(path.sep).join('/');
|
|
199
|
+
const issues = [];
|
|
200
|
+
const imports = [];
|
|
201
|
+
const lineOf = (node) => (node.position?.start?.line ? node.position.start.line + lineOffset : undefined);
|
|
202
|
+
const push = (node, message, suggestion) => issues.push({ file, line: lineOf(node), message, suggestion });
|
|
203
|
+
// The layout renders the page title as the h1 - except in the canvas
|
|
204
|
+
// modes (custom, blank: see [...slug].astro), where the page builds its
|
|
205
|
+
// own layout and its own h1.
|
|
206
|
+
const ownsH1 = ['custom', 'blank'].includes(parsed.data?.mode);
|
|
207
|
+
let previousLevel = ownsH1 ? 0 : 1;
|
|
208
|
+
|
|
209
|
+
visit(tree, (node) => {
|
|
210
|
+
switch (node.type) {
|
|
211
|
+
case 'image':
|
|
212
|
+
if (!String(node.alt ?? '').trim()) {
|
|
213
|
+
push(node, `Image ${node.url} has no alt text.`, 'Describe the image in the brackets: .');
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
case 'link':
|
|
217
|
+
if (!textOf(node).trim()) push(node, `Link to ${node.url} has no text - a screen reader reads out the address instead.`);
|
|
218
|
+
break;
|
|
219
|
+
case 'heading':
|
|
220
|
+
if (!headings) break;
|
|
221
|
+
if (node.depth === 1 && !ownsH1) {
|
|
222
|
+
push(node, 'A # heading in the page is a second h1 - the page title is already the h1.', 'Use ## for the page\'s sections.');
|
|
223
|
+
} else if (node.depth > previousLevel + 1) {
|
|
224
|
+
push(
|
|
225
|
+
node,
|
|
226
|
+
`Heading level skips from h${previousLevel} to h${node.depth} ("${textOf(node).trim()}").`,
|
|
227
|
+
`Use ${'#'.repeat(previousLevel + 1)} here, or add the missing level above it.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
previousLevel = node.depth;
|
|
231
|
+
break;
|
|
232
|
+
case 'html':
|
|
233
|
+
// Raw HTML in a .md page.
|
|
234
|
+
for (const m of node.value.matchAll(/<img\b[^>]*>/gi)) {
|
|
235
|
+
if (!/\balt\s*=/i.test(m[0])) push(node, 'An <img> has no alt attribute.', 'Add alt="what it shows", or alt="" if it\'s decorative.');
|
|
236
|
+
}
|
|
237
|
+
for (const m of node.value.matchAll(/<iframe\b[^>]*>/gi)) {
|
|
238
|
+
if (!/\btitle\s*=/i.test(m[0])) push(node, 'An <iframe> has no title.', 'Add title="what it shows" - screen readers announce it.');
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case 'mdxjsEsm':
|
|
242
|
+
for (const m of node.value.matchAll(/\bfrom\s*['"]([^'"]+\.mdx?)['"]/g)) imports.push(m[1]);
|
|
243
|
+
break;
|
|
244
|
+
case 'mdxJsxFlowElement':
|
|
245
|
+
case 'mdxJsxTextElement': {
|
|
246
|
+
const name = node.name ?? '';
|
|
247
|
+
if (/^(img|Image)$/.test(name) && attribute(node, 'alt') === undefined) {
|
|
248
|
+
push(node, `<${name}${attribute(node, 'src') ? ` src="${attribute(node, 'src')}"` : ''}> has no alt attribute.`, 'Add alt="what it shows", or alt="" if it\'s decorative.');
|
|
249
|
+
}
|
|
250
|
+
if (/^iframe$/i.test(name) && attribute(node, 'title') === undefined) {
|
|
251
|
+
push(node, 'An <iframe> has no title.', 'Add title="what it shows" - screen readers announce it.');
|
|
252
|
+
}
|
|
253
|
+
const level = /^h([1-6])$/.exec(name);
|
|
254
|
+
if (headings && level) {
|
|
255
|
+
const depth = Number(level[1]);
|
|
256
|
+
if (depth === 1 && !ownsH1) push(node, 'An <h1> in the page is a second h1 - the page title is already the h1.', 'Use <h2> for the page\'s sections.');
|
|
257
|
+
else if (depth > previousLevel + 1) push(node, `Heading level skips from h${previousLevel} to h${depth}.`, `Use <h${previousLevel + 1}> here, or add the missing level above it.`);
|
|
258
|
+
previousLevel = depth;
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return { issues, imports };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Checks `contentDir`. `configText`: writedocs.json's raw text (valid JSON -
|
|
269
|
+
* the CLI checks that first). Returns { pages, issues }.
|
|
270
|
+
*/
|
|
271
|
+
export async function checkAccessibility(contentDir, configText) {
|
|
272
|
+
const config = JSON.parse(configText);
|
|
273
|
+
const issues = checkColors(config, createJsonLocator(configText));
|
|
274
|
+
const pages = findAllPages(contentDir);
|
|
275
|
+
const snippetsDone = new Set();
|
|
276
|
+
async function withSnippets(abs, options) {
|
|
277
|
+
const result = await checkFile(contentDir, abs, options);
|
|
278
|
+
issues.push(...result.issues);
|
|
279
|
+
for (const spec of result.imports) {
|
|
280
|
+
const snippet = spec.startsWith('/') ? path.join(contentDir, spec) : path.resolve(path.dirname(abs), spec);
|
|
281
|
+
if (snippetsDone.has(snippet)) continue;
|
|
282
|
+
snippetsDone.add(snippet);
|
|
283
|
+
// A snippet's headings sit wherever the page puts it, so only its
|
|
284
|
+
// images, iframes and links are checked.
|
|
285
|
+
await withSnippets(snippet, { headings: false });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const rel of pages) await withSnippets(path.join(contentDir, rel), { headings: true });
|
|
289
|
+
issues.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
|
|
290
|
+
return { pages: pages.length, issues };
|
|
291
|
+
}
|