@writedocs/generator 0.5.0 → 0.7.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.
@@ -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: ![What it shows](...).');
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
+ }
@@ -10,6 +10,10 @@
10
10
  // - writedocs.json's navigation lists a page that doesn't exist
11
11
  // - a redirect is a pattern (`/old/:slug`) rather than one exact path
12
12
  // - a Markdown image with a relative path names a file that doesn't exist
13
+ // - an import the build can't resolve: a Docusaurus path (@site/...), or a
14
+ // relative or /snippets/ import of a file that isn't there
15
+ // - an OpenAPI group's spec is missing or doesn't parse, or two groups
16
+ // share one `openapi.path`
13
17
  // Warnings - the build succeeds, but not as written:
14
18
  // - an unknown component (the build shows only its content - see
15
19
  // lib/mdx-unknown-components.js)
@@ -18,6 +22,10 @@
18
22
  // writedocs.json
19
23
  // - a built-in component inside a page component that runs as React,
20
24
  // where it renders simplified (lib/mdx-inline-react.js)
25
+ // - a page's `openapi:` names an operation no spec has, or a spec that's
26
+ // missing or doesn't parse (the page shows a notice instead of the API
27
+ // playground)
28
+ // - a spec that parses but isn't valid OpenAPI
21
29
  import fs from 'node:fs';
22
30
  import path from 'node:path';
23
31
  import matter from 'gray-matter';
@@ -27,6 +35,8 @@ import { iconExists } from './icons.js';
27
35
  import { findUnknownComponents } from './mdx-unknown-components.js';
28
36
  import { findInteractiveComponents, simplifiedBuiltinMessage } from './mdx-inline-react.js';
29
37
  import { pageFrontmatterSchema, createJsonLocator } from './config-schema.js';
38
+ import { parseOpenApiRef } from './openapi-ref.js';
39
+ import { collectOpenApiGroups, operationKeys } from './openapi-spec.js';
30
40
 
31
41
  /** An issue: { file, line?, message, suggestion? } - `file` relative to the
32
42
  * content directory, POSIX slashes. */
@@ -59,7 +69,7 @@ async function parseMdx(text) {
59
69
  return mdxProcessor.parse(text);
60
70
  }
61
71
 
62
- async function checkPage(contentDir, rel, errors, warnings) {
72
+ async function checkPage(contentDir, rel, errors, warnings, openapiRefs) {
63
73
  const raw = fs.readFileSync(path.join(contentDir, rel), 'utf-8').replace(/\r\n/g, '\n');
64
74
  let parsed;
65
75
  try {
@@ -91,6 +101,8 @@ async function checkPage(contentDir, rel, errors, warnings) {
91
101
  errors.push(issue(rel, key ? lineOfFrontmatterKey(frontmatterText, key) : 2, `Frontmatter ${where}${i.message}`));
92
102
  }
93
103
  }
104
+ const ref = parseOpenApiRef(parsed.data.openapi);
105
+ if (ref) openapiRefs.push({ rel, line: lineOfFrontmatterKey(frontmatterText, 'openapi'), ref, value: parsed.data.openapi });
94
106
  if (typeof parsed.data.icon === 'string' && !iconExists(parsed.data.icon)) {
95
107
  const { message, suggestion } = unknownIconMessage(parsed.data.icon);
96
108
  warnings.push(issue(rel, lineOfFrontmatterKey(frontmatterText, 'icon'), message, suggestion));
@@ -119,6 +131,45 @@ async function checkPage(contentDir, rel, errors, warnings) {
119
131
  );
120
132
  return;
121
133
  }
134
+ // Imports the build can't resolve fail it: Docusaurus' path aliases
135
+ // (common in pages from the previous writedocs), and a relative or
136
+ // /snippets/ import of a file that isn't there. Package imports aren't
137
+ // checked - whether they resolve depends on what's installed.
138
+ visit(tree, 'mdxjsEsm', (node) => {
139
+ const code = String(node.value ?? '');
140
+ for (const m of code.matchAll(/\b(?:import|export)\b[^'";]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)) {
141
+ const spec = m[1] ?? m[2];
142
+ const at = node.position?.start?.line ? node.position.start.line + lineOffset + code.slice(0, m.index).split('\n').length - 1 : undefined;
143
+ if (/^@(site|theme|docusaurus|generated)(\/|$)/.test(spec)) {
144
+ errors.push(
145
+ issue(
146
+ rel,
147
+ at,
148
+ `Imports "${spec}", a Docusaurus path - the build fails on it.`,
149
+ /^@site\/src\/components\/?$/.test(spec)
150
+ ? "writedocs' components (Card, Callout, Tabs, Accordion, ...) need no import - remove this line."
151
+ : spec.startsWith('@site/src/components/')
152
+ ? 'A custom component of the old site - move it into a snippet (snippets/), or remove it and what uses it.'
153
+ : 'Remove the import, and what uses it.'
154
+ )
155
+ );
156
+ continue;
157
+ }
158
+ let target = null;
159
+ if (spec.startsWith('./') || spec.startsWith('../')) target = path.resolve(path.dirname(path.join(contentDir, rel)), spec);
160
+ else if (spec.startsWith('/snippets/')) target = path.join(contentDir, spec);
161
+ if (!target) continue;
162
+ const exists = ['', '.mdx', '.md', '.js', '.jsx', '.ts', '.tsx', '.json'].some((ext) => {
163
+ try {
164
+ return fs.statSync(target + ext).isFile();
165
+ } catch {
166
+ return false;
167
+ }
168
+ });
169
+ if (!exists) errors.push(issue(rel, at, `Imports ${spec}, which doesn't exist - the build fails on it.`));
170
+ }
171
+ });
172
+
122
173
  // A Markdown image with a relative path is imported by the build (Astro's
123
174
  // image pipeline), so a missing file fails the whole build.
124
175
  visit(tree, 'image', (node) => {
@@ -208,6 +259,123 @@ function configIcons(config) {
208
259
  return found;
209
260
  }
210
261
 
262
+ /** The JSON path of `target` (an object inside `root`), for line lookup. */
263
+ function jsonPathOf(root, target, trail = []) {
264
+ if (root === target) return trail;
265
+ if (!root || typeof root !== 'object') return null;
266
+ for (const [key, value] of Object.entries(root)) {
267
+ const found = jsonPathOf(value, target, [...trail, Array.isArray(root) ? Number(key) : key]);
268
+ if (found) return found;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ const posix = (p) => p.split(path.sep).join('/');
274
+
275
+ /** OpenAPI: the specs writedocs.json's groups point at (the build fails
276
+ * without them - src/cli/generate-api-pages.js), the specs pages name, and
277
+ * every page's `openapi:` operation. `config` is null when writedocs.json
278
+ * isn't valid JSON - then only the pages' own specs are checked. */
279
+ async function checkOpenApi(contentDir, config, locate, openapiRefs, errors, warnings) {
280
+ const groups = config ? collectOpenApiGroups(config.navigation) : [];
281
+ if (groups.length === 0 && openapiRefs.length === 0) return;
282
+ const { default: SwaggerParser } = await import('@apidevtools/swagger-parser');
283
+
284
+ // Each spec loaded once: { keys } or { missing } or { error }.
285
+ const specs = new Map();
286
+ async function load(abs) {
287
+ if (specs.has(abs)) return specs.get(abs);
288
+ let result;
289
+ if (!fs.existsSync(abs)) result = { missing: true };
290
+ else {
291
+ try {
292
+ const spec = await SwaggerParser.dereference(abs);
293
+ result = { keys: operationKeys(spec) };
294
+ // Parses, but may still not be valid OpenAPI - the build takes it
295
+ // as it is, so that's only worth a warning.
296
+ try {
297
+ await SwaggerParser.validate(abs);
298
+ } catch (err) {
299
+ result.invalid = String(err.message).split('\n')[0];
300
+ }
301
+ } catch (err) {
302
+ result = { error: String(err.message).split('\n')[0] };
303
+ }
304
+ }
305
+ specs.set(abs, result);
306
+ return result;
307
+ }
308
+ const reported = new Set();
309
+ const once = (list, key, value) => {
310
+ if (reported.has(key)) return;
311
+ reported.add(key);
312
+ list.push(value);
313
+ };
314
+
315
+ const seenPaths = new Map();
316
+ for (const group of groups) {
317
+ const trail = jsonPathOf(config.navigation, group) ?? [];
318
+ const line = (key) => locate?.([ 'navigation', ...trail, 'openapi', key])?.line;
319
+ const src = String(group.openapi?.src ?? '');
320
+ const mountedAt = String(group.openapi?.path ?? '').replace(/^\/+|\/+$/g, '');
321
+ if (seenPaths.has(mountedAt)) {
322
+ errors.push(
323
+ issue('writedocs.json', line('path'), `Two OpenAPI groups are both at openapi.path "${group.openapi.path}" ("${seenPaths.get(mountedAt)}" and "${group.group}").`, "Give each group's openapi.path a different value.")
324
+ );
325
+ } else seenPaths.set(mountedAt, group.group);
326
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(src)) {
327
+ errors.push(issue('writedocs.json', line('src'), `The "${group.group}" group's OpenAPI spec is a URL (${src}) - it has to be a file in the project.`, 'Download the spec into the project and point openapi.src at the file.'));
328
+ continue;
329
+ }
330
+ const abs = path.resolve(contentDir, src);
331
+ const spec = await load(abs);
332
+ if (spec.missing) {
333
+ errors.push(issue('writedocs.json', line('src'), `The "${group.group}" group's OpenAPI spec "${src}" doesn't exist.`, "openapi.src is relative to the folder writedocs.json is in."));
334
+ } else if (spec.error) {
335
+ once(errors, `error ${abs}`, issue(posix(path.relative(contentDir, abs)), undefined, `This OpenAPI spec doesn't parse: ${spec.error}`));
336
+ } else if (spec.invalid) {
337
+ once(warnings, `invalid ${abs}`, issue(posix(path.relative(contentDir, abs)), undefined, `This OpenAPI spec isn't valid OpenAPI: ${spec.invalid}`, 'The build uses it as it is - the API pages may be incomplete.'));
338
+ }
339
+ }
340
+
341
+ // Every operation any loaded spec defines - where a page's short form
342
+ // (`openapi: "GET /pets"`) is looked up (findOperationFile() in
343
+ // lib/openapi-render.ts).
344
+ const pageSpecs = [...new Set(openapiRefs.filter((r) => r.ref.spec).map((r) => path.resolve(contentDir, r.ref.spec)))];
345
+ for (const abs of pageSpecs) await load(abs);
346
+ const allKeys = new Set();
347
+ for (const spec of specs.values()) for (const key of spec.keys ?? []) allKeys.add(key);
348
+
349
+ for (const { rel, line, ref, value } of openapiRefs) {
350
+ const key = `${ref.method} ${ref.path}`;
351
+ if (ref.spec) {
352
+ const abs = path.resolve(contentDir, ref.spec);
353
+ const spec = specs.get(abs);
354
+ if (spec.missing) {
355
+ warnings.push(issue(rel, line, `openapi names the spec "${ref.spec}", which doesn't exist - the page shows a notice instead of the API playground.`, 'The spec path is relative to the folder writedocs.json is in.'));
356
+ continue;
357
+ }
358
+ if (spec.error) {
359
+ warnings.push(issue(rel, line, `openapi names the spec "${ref.spec}", which doesn't parse - the page shows a notice instead of the API playground.`));
360
+ once(warnings, `error ${abs}`, issue(posix(path.relative(contentDir, abs)), undefined, `This OpenAPI spec doesn't parse: ${spec.error}`));
361
+ continue;
362
+ }
363
+ if (spec.keys.has(key) || allKeys.has(key)) continue;
364
+ } else if (allKeys.has(key)) continue;
365
+ const specsLoaded = [...specs.values()].some((s) => s.keys);
366
+ warnings.push(
367
+ issue(
368
+ rel,
369
+ line,
370
+ `openapi: "${value}" - ${specsLoaded ? 'no OpenAPI spec has this operation' : 'there is no OpenAPI spec to find it in'}, so the page shows a notice instead of the API playground.`,
371
+ specsLoaded
372
+ ? 'Check the method and the path - they have to match the spec exactly, like "GET /pets/{petId}".'
373
+ : 'Add an OpenAPI group to writedocs.json\'s navigation, or name the spec in the page: openapi: "/openapi.yaml GET /pets".'
374
+ )
375
+ );
376
+ }
377
+ }
378
+
211
379
  /**
212
380
  * Checks a content directory. `configText` is writedocs.json's raw text, or
213
381
  * null when it isn't valid JSON (then only the pages themselves are
@@ -217,7 +385,8 @@ export async function checkContent(contentDir, configText) {
217
385
  const errors = [];
218
386
  const warnings = [];
219
387
  const pages = findAllPages(contentDir);
220
- for (const rel of pages) await checkPage(contentDir, rel, errors, warnings);
388
+ const openapiRefs = [];
389
+ for (const rel of pages) await checkPage(contentDir, rel, errors, warnings, openapiRefs);
221
390
 
222
391
  let config = null;
223
392
  if (configText !== null) {
@@ -266,6 +435,7 @@ export async function checkContent(contentDir, configText) {
266
435
  warnings.push(issue('writedocs.json', locate(jsonPath)?.line, message, suggestion));
267
436
  }
268
437
  }
438
+ await checkOpenApi(contentDir, config, config ? createJsonLocator(configText) : null, openapiRefs, errors, warnings);
269
439
 
270
440
  const byLocation = (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0);
271
441
  errors.sort(byLocation);