@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/src/lib/content-check.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// - an .mdx file doesn't parse as MDX
|
|
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
|
+
// - a Markdown image with a relative path names a file that doesn't exist
|
|
13
|
+
// - an OpenAPI group's spec is missing or doesn't parse, or two groups
|
|
14
|
+
// share one `openapi.path`
|
|
12
15
|
// Warnings - the build succeeds, but not as written:
|
|
13
16
|
// - an unknown component (the build shows only its content - see
|
|
14
17
|
// lib/mdx-unknown-components.js)
|
|
@@ -17,6 +20,10 @@
|
|
|
17
20
|
// writedocs.json
|
|
18
21
|
// - a built-in component inside a page component that runs as React,
|
|
19
22
|
// where it renders simplified (lib/mdx-inline-react.js)
|
|
23
|
+
// - a page's `openapi:` names an operation no spec has, or a spec that's
|
|
24
|
+
// missing or doesn't parse (the page shows a notice instead of the API
|
|
25
|
+
// playground)
|
|
26
|
+
// - a spec that parses but isn't valid OpenAPI
|
|
20
27
|
import fs from 'node:fs';
|
|
21
28
|
import path from 'node:path';
|
|
22
29
|
import matter from 'gray-matter';
|
|
@@ -26,6 +33,8 @@ import { iconExists } from './icons.js';
|
|
|
26
33
|
import { findUnknownComponents } from './mdx-unknown-components.js';
|
|
27
34
|
import { findInteractiveComponents, simplifiedBuiltinMessage } from './mdx-inline-react.js';
|
|
28
35
|
import { pageFrontmatterSchema, createJsonLocator } from './config-schema.js';
|
|
36
|
+
import { parseOpenApiRef } from './openapi-ref.js';
|
|
37
|
+
import { collectOpenApiGroups, operationKeys } from './openapi-spec.js';
|
|
29
38
|
|
|
30
39
|
/** An issue: { file, line?, message, suggestion? } - `file` relative to the
|
|
31
40
|
* content directory, POSIX slashes. */
|
|
@@ -58,7 +67,7 @@ async function parseMdx(text) {
|
|
|
58
67
|
return mdxProcessor.parse(text);
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
async function checkPage(contentDir, rel, errors, warnings) {
|
|
70
|
+
async function checkPage(contentDir, rel, errors, warnings, openapiRefs) {
|
|
62
71
|
const raw = fs.readFileSync(path.join(contentDir, rel), 'utf-8').replace(/\r\n/g, '\n');
|
|
63
72
|
let parsed;
|
|
64
73
|
try {
|
|
@@ -90,6 +99,8 @@ async function checkPage(contentDir, rel, errors, warnings) {
|
|
|
90
99
|
errors.push(issue(rel, key ? lineOfFrontmatterKey(frontmatterText, key) : 2, `Frontmatter ${where}${i.message}`));
|
|
91
100
|
}
|
|
92
101
|
}
|
|
102
|
+
const ref = parseOpenApiRef(parsed.data.openapi);
|
|
103
|
+
if (ref) openapiRefs.push({ rel, line: lineOfFrontmatterKey(frontmatterText, 'openapi'), ref, value: parsed.data.openapi });
|
|
93
104
|
if (typeof parsed.data.icon === 'string' && !iconExists(parsed.data.icon)) {
|
|
94
105
|
const { message, suggestion } = unknownIconMessage(parsed.data.icon);
|
|
95
106
|
warnings.push(issue(rel, lineOfFrontmatterKey(frontmatterText, 'icon'), message, suggestion));
|
|
@@ -118,6 +129,28 @@ async function checkPage(contentDir, rel, errors, warnings) {
|
|
|
118
129
|
);
|
|
119
130
|
return;
|
|
120
131
|
}
|
|
132
|
+
// A Markdown image with a relative path is imported by the build (Astro's
|
|
133
|
+
// image pipeline), so a missing file fails the whole build.
|
|
134
|
+
visit(tree, 'image', (node) => {
|
|
135
|
+
const url = String(node.url ?? '');
|
|
136
|
+
if (!url || url.startsWith('/') || url.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//')) return;
|
|
137
|
+
let target;
|
|
138
|
+
try {
|
|
139
|
+
target = path.resolve(path.dirname(path.join(contentDir, rel)), decodeURI(url.split(/[?#]/)[0]));
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (fs.existsSync(target)) return;
|
|
144
|
+
const line = node.position?.start?.line;
|
|
145
|
+
errors.push(
|
|
146
|
+
issue(
|
|
147
|
+
rel,
|
|
148
|
+
line ? line + lineOffset : undefined,
|
|
149
|
+
`Image ${url} doesn't exist - the build fails on it.`,
|
|
150
|
+
'The path is relative to this page\'s folder. Fix it, or use a path from the project root, like "/images/example.png".'
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
});
|
|
121
154
|
for (const { name, line } of findUnknownComponents(tree)) {
|
|
122
155
|
warnings.push(
|
|
123
156
|
issue(
|
|
@@ -185,6 +218,123 @@ function configIcons(config) {
|
|
|
185
218
|
return found;
|
|
186
219
|
}
|
|
187
220
|
|
|
221
|
+
/** The JSON path of `target` (an object inside `root`), for line lookup. */
|
|
222
|
+
function jsonPathOf(root, target, trail = []) {
|
|
223
|
+
if (root === target) return trail;
|
|
224
|
+
if (!root || typeof root !== 'object') return null;
|
|
225
|
+
for (const [key, value] of Object.entries(root)) {
|
|
226
|
+
const found = jsonPathOf(value, target, [...trail, Array.isArray(root) ? Number(key) : key]);
|
|
227
|
+
if (found) return found;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const posix = (p) => p.split(path.sep).join('/');
|
|
233
|
+
|
|
234
|
+
/** OpenAPI: the specs writedocs.json's groups point at (the build fails
|
|
235
|
+
* without them - src/cli/generate-api-pages.js), the specs pages name, and
|
|
236
|
+
* every page's `openapi:` operation. `config` is null when writedocs.json
|
|
237
|
+
* isn't valid JSON - then only the pages' own specs are checked. */
|
|
238
|
+
async function checkOpenApi(contentDir, config, locate, openapiRefs, errors, warnings) {
|
|
239
|
+
const groups = config ? collectOpenApiGroups(config.navigation) : [];
|
|
240
|
+
if (groups.length === 0 && openapiRefs.length === 0) return;
|
|
241
|
+
const { default: SwaggerParser } = await import('@apidevtools/swagger-parser');
|
|
242
|
+
|
|
243
|
+
// Each spec loaded once: { keys } or { missing } or { error }.
|
|
244
|
+
const specs = new Map();
|
|
245
|
+
async function load(abs) {
|
|
246
|
+
if (specs.has(abs)) return specs.get(abs);
|
|
247
|
+
let result;
|
|
248
|
+
if (!fs.existsSync(abs)) result = { missing: true };
|
|
249
|
+
else {
|
|
250
|
+
try {
|
|
251
|
+
const spec = await SwaggerParser.dereference(abs);
|
|
252
|
+
result = { keys: operationKeys(spec) };
|
|
253
|
+
// Parses, but may still not be valid OpenAPI - the build takes it
|
|
254
|
+
// as it is, so that's only worth a warning.
|
|
255
|
+
try {
|
|
256
|
+
await SwaggerParser.validate(abs);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
result.invalid = String(err.message).split('\n')[0];
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
result = { error: String(err.message).split('\n')[0] };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
specs.set(abs, result);
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
const reported = new Set();
|
|
268
|
+
const once = (list, key, value) => {
|
|
269
|
+
if (reported.has(key)) return;
|
|
270
|
+
reported.add(key);
|
|
271
|
+
list.push(value);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const seenPaths = new Map();
|
|
275
|
+
for (const group of groups) {
|
|
276
|
+
const trail = jsonPathOf(config.navigation, group) ?? [];
|
|
277
|
+
const line = (key) => locate?.([ 'navigation', ...trail, 'openapi', key])?.line;
|
|
278
|
+
const src = String(group.openapi?.src ?? '');
|
|
279
|
+
const mountedAt = String(group.openapi?.path ?? '').replace(/^\/+|\/+$/g, '');
|
|
280
|
+
if (seenPaths.has(mountedAt)) {
|
|
281
|
+
errors.push(
|
|
282
|
+
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.")
|
|
283
|
+
);
|
|
284
|
+
} else seenPaths.set(mountedAt, group.group);
|
|
285
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(src)) {
|
|
286
|
+
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.'));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const abs = path.resolve(contentDir, src);
|
|
290
|
+
const spec = await load(abs);
|
|
291
|
+
if (spec.missing) {
|
|
292
|
+
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."));
|
|
293
|
+
} else if (spec.error) {
|
|
294
|
+
once(errors, `error ${abs}`, issue(posix(path.relative(contentDir, abs)), undefined, `This OpenAPI spec doesn't parse: ${spec.error}`));
|
|
295
|
+
} else if (spec.invalid) {
|
|
296
|
+
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.'));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Every operation any loaded spec defines - where a page's short form
|
|
301
|
+
// (`openapi: "GET /pets"`) is looked up (findOperationFile() in
|
|
302
|
+
// lib/openapi-render.ts).
|
|
303
|
+
const pageSpecs = [...new Set(openapiRefs.filter((r) => r.ref.spec).map((r) => path.resolve(contentDir, r.ref.spec)))];
|
|
304
|
+
for (const abs of pageSpecs) await load(abs);
|
|
305
|
+
const allKeys = new Set();
|
|
306
|
+
for (const spec of specs.values()) for (const key of spec.keys ?? []) allKeys.add(key);
|
|
307
|
+
|
|
308
|
+
for (const { rel, line, ref, value } of openapiRefs) {
|
|
309
|
+
const key = `${ref.method} ${ref.path}`;
|
|
310
|
+
if (ref.spec) {
|
|
311
|
+
const abs = path.resolve(contentDir, ref.spec);
|
|
312
|
+
const spec = specs.get(abs);
|
|
313
|
+
if (spec.missing) {
|
|
314
|
+
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.'));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (spec.error) {
|
|
318
|
+
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.`));
|
|
319
|
+
once(warnings, `error ${abs}`, issue(posix(path.relative(contentDir, abs)), undefined, `This OpenAPI spec doesn't parse: ${spec.error}`));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (spec.keys.has(key) || allKeys.has(key)) continue;
|
|
323
|
+
} else if (allKeys.has(key)) continue;
|
|
324
|
+
const specsLoaded = [...specs.values()].some((s) => s.keys);
|
|
325
|
+
warnings.push(
|
|
326
|
+
issue(
|
|
327
|
+
rel,
|
|
328
|
+
line,
|
|
329
|
+
`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.`,
|
|
330
|
+
specsLoaded
|
|
331
|
+
? 'Check the method and the path - they have to match the spec exactly, like "GET /pets/{petId}".'
|
|
332
|
+
: 'Add an OpenAPI group to writedocs.json\'s navigation, or name the spec in the page: openapi: "/openapi.yaml GET /pets".'
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
188
338
|
/**
|
|
189
339
|
* Checks a content directory. `configText` is writedocs.json's raw text, or
|
|
190
340
|
* null when it isn't valid JSON (then only the pages themselves are
|
|
@@ -194,7 +344,8 @@ export async function checkContent(contentDir, configText) {
|
|
|
194
344
|
const errors = [];
|
|
195
345
|
const warnings = [];
|
|
196
346
|
const pages = findAllPages(contentDir);
|
|
197
|
-
|
|
347
|
+
const openapiRefs = [];
|
|
348
|
+
for (const rel of pages) await checkPage(contentDir, rel, errors, warnings, openapiRefs);
|
|
198
349
|
|
|
199
350
|
let config = null;
|
|
200
351
|
if (configText !== null) {
|
|
@@ -243,6 +394,7 @@ export async function checkContent(contentDir, configText) {
|
|
|
243
394
|
warnings.push(issue('writedocs.json', locate(jsonPath)?.line, message, suggestion));
|
|
244
395
|
}
|
|
245
396
|
}
|
|
397
|
+
await checkOpenApi(contentDir, config, config ? createJsonLocator(configText) : null, openapiRefs, errors, warnings);
|
|
246
398
|
|
|
247
399
|
const byLocation = (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0);
|
|
248
400
|
errors.sort(byLocation);
|