@writedocs/generator 0.4.11 → 0.4.12
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/astro.config.mjs +5 -4
- package/bin/writedocs.js +44 -25
- package/package.json +1 -1
- package/src/cli/api-pages-output.js +11 -0
- package/src/cli/astro-output.js +146 -0
- package/src/cli/astro-worker.js +78 -0
- package/src/cli/build-auth.js +7 -5
- package/src/cli/build.js +104 -7
- package/src/cli/convert.js +37 -26
- package/src/cli/dev-lock.js +51 -0
- package/src/cli/dev.js +158 -6
- package/src/cli/generate-api-pages.js +15 -10
- package/src/cli/init.js +7 -5
- package/src/cli/output.js +160 -0
- package/src/cli/preflight.js +11 -8
- package/src/cli/run-astro.js +76 -46
- package/src/cli/run-pagefind.js +14 -9
- package/src/components/AppIcon.astro +2 -1
- package/src/lib/astro-log-destination.js +25 -0
- package/src/lib/cli-report.js +20 -0
- package/src/lib/mdx-inline-react.js +2 -1
- package/src/lib/mdx-unknown-components.js +5 -3
package/src/cli/convert.js
CHANGED
|
@@ -7,32 +7,33 @@ import path from 'node:path';
|
|
|
7
7
|
import { loadMintlifyConfig, convertMintlifyConfig, formatNotes } from '../lib/mintlify-convert.js';
|
|
8
8
|
import { validateDocsConfig, formatValidationIssuesDetailed } from '../lib/config-schema.js';
|
|
9
9
|
import { checkContent, formatContentIssues } from '../lib/content-check.js';
|
|
10
|
+
import { log, step, plural, color, displayPath, CliExit } from './output.js';
|
|
10
11
|
|
|
11
12
|
export async function runConvert({ contentDir, force = false, dryRun = false }) {
|
|
12
13
|
const docsJsonPath = path.join(contentDir, 'docs.json');
|
|
13
14
|
if (!fs.existsSync(docsJsonPath)) {
|
|
14
15
|
const legacy = path.join(contentDir, 'mint.json');
|
|
15
16
|
if (fs.existsSync(legacy)) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
);
|
|
17
|
+
log.error("Found mint.json, Mintlify's older config format.");
|
|
18
|
+
log.detail(color.dim(`Run "npx mint upgrade" in ${contentDir} to turn it into docs.json, then run this again.`));
|
|
19
19
|
} else {
|
|
20
|
-
|
|
20
|
+
log.error(`No docs.json found in ${contentDir}`);
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
throw new CliExit(1);
|
|
23
23
|
}
|
|
24
24
|
const outPath = path.join(contentDir, 'writedocs.json');
|
|
25
25
|
if (!dryRun && !force && fs.existsSync(outPath)) {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
log.error(`${displayPath(outPath)} already exists.`);
|
|
27
|
+
log.detail(color.dim('Pass --force to overwrite it, or --dry-run to only see the result.'));
|
|
28
|
+
throw new CliExit(1);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
let docs;
|
|
31
32
|
try {
|
|
32
33
|
docs = loadMintlifyConfig(docsJsonPath);
|
|
33
34
|
} catch (err) {
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
log.error(`Couldn't read ${displayPath(docsJsonPath)}: ${err.message}`);
|
|
36
|
+
throw new CliExit(1);
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const { config, notes } = convertMintlifyConfig(docs);
|
|
@@ -42,41 +43,51 @@ export async function runConvert({ contentDir, force = false, dryRun = false })
|
|
|
42
43
|
// converter bug, and writing it would only hand the author a broken file.
|
|
43
44
|
const result = validateDocsConfig(text);
|
|
44
45
|
if (!result.ok) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
log.error('The converted writedocs.json is not valid - this is a bug in the converter, please report it:');
|
|
47
|
+
log.line();
|
|
48
|
+
log.line(formatValidationIssuesDetailed(result.issues));
|
|
49
|
+
log.line(`\n${text}`);
|
|
50
|
+
throw new CliExit(1);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
if (dryRun) {
|
|
52
|
-
|
|
54
|
+
log.line(text);
|
|
53
55
|
} else {
|
|
54
56
|
fs.writeFileSync(outPath, text);
|
|
55
|
-
|
|
57
|
+
log.success(`Converted ${path.basename(docsJsonPath)} to ${color.bold(displayPath(outPath))}`);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
if (notes.length) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
log.line();
|
|
62
|
+
log.info(`${plural(notes.length, 'thing')} couldn't be carried over as-is:`);
|
|
63
|
+
log.line();
|
|
64
|
+
log.line(formatNotes(notes));
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
// The pages, checked against the converted config - what's left to fix
|
|
64
68
|
// before the first build.
|
|
69
|
+
const checking = step('Checking pages');
|
|
65
70
|
const content = await checkContent(contentDir, text);
|
|
71
|
+
checking.stop();
|
|
66
72
|
if (content.errors.length) {
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
log.line();
|
|
74
|
+
log.error(`${plural(content.errors.length, 'error')} in the pages - the build would fail on these:`);
|
|
75
|
+
log.line();
|
|
76
|
+
log.line(formatContentIssues(content.errors));
|
|
69
77
|
}
|
|
70
78
|
if (content.warnings.length) {
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
log.line();
|
|
80
|
+
log.warn(`${plural(content.warnings.length, 'warning')} in the pages:`);
|
|
81
|
+
log.line();
|
|
82
|
+
log.line(formatContentIssues(content.warnings));
|
|
73
83
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
);
|
|
84
|
+
log.line();
|
|
85
|
+
const summary = `Checked ${plural(content.pages, 'page')}: ${plural(content.errors.length, 'error')}, ${plural(content.warnings.length, 'warning')}`;
|
|
86
|
+
if (content.errors.length) log.error(summary);
|
|
87
|
+
else log.success(summary);
|
|
77
88
|
if (!config.domain) {
|
|
78
|
-
|
|
79
|
-
'
|
|
89
|
+
log.info(
|
|
90
|
+
'Next: set "domain" in writedocs.json to your site\'s URL - it turns on sitemap.xml and absolute links for social previews. (Mintlify sets this in its dashboard, not docs.json.)'
|
|
80
91
|
);
|
|
81
92
|
}
|
|
82
93
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// One `writedocs dev` at a time. Every project is served with the writedocs
|
|
2
|
+
// package itself as Astro's root (see run-astro.js), so two previews running
|
|
3
|
+
// at once - even of different projects - share Astro's dev state in
|
|
4
|
+
// <packageRoot>/.astro and overwrite each other's pages. The `astro` CLI
|
|
5
|
+
// has its own lock for this, but writedocs drives Astro through its API
|
|
6
|
+
// (astro-worker.js), which doesn't; this is the same guard, with a message
|
|
7
|
+
// that makes sense to a writedocs author.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
function lockPath(packageRoot) {
|
|
12
|
+
return path.join(packageRoot, '.astro', 'writedocs-dev.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isAlive(pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
// EPERM: it exists, it's just not ours to signal.
|
|
21
|
+
return err.code === 'EPERM';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The preview already running - { pid, contentDir, url } - or null. A
|
|
26
|
+
* lock left behind by a preview that didn't exit cleanly is ignored. */
|
|
27
|
+
export function runningPreview(packageRoot) {
|
|
28
|
+
let lock;
|
|
29
|
+
try {
|
|
30
|
+
lock = JSON.parse(fs.readFileSync(lockPath(packageRoot), 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isInteger(lock?.pid) || lock.pid === process.pid || !isAlive(lock.pid)) return null;
|
|
35
|
+
return lock;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeLock(packageRoot, data) {
|
|
39
|
+
const file = lockPath(packageRoot);
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
fs.writeFileSync(file, JSON.stringify({ pid: process.pid, ...data }, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Removes the lock, if it's this process's. */
|
|
45
|
+
export function removeLock(packageRoot) {
|
|
46
|
+
const file = lockPath(packageRoot);
|
|
47
|
+
try {
|
|
48
|
+
const lock = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
49
|
+
if (lock.pid === process.pid) fs.rmSync(file, { force: true });
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
package/src/cli/dev.js
CHANGED
|
@@ -1,12 +1,164 @@
|
|
|
1
1
|
import { runAstro } from './run-astro.js';
|
|
2
2
|
import { preflightCheck } from './preflight.js';
|
|
3
3
|
import { generateApiPages } from './generate-api-pages.js';
|
|
4
|
+
import { log, step, duration, formatProblems, color, CliExit } from './output.js';
|
|
5
|
+
import { describeError, requestLog, authorWarning, verboseLine, stripAnsi } from './astro-output.js';
|
|
6
|
+
import { reportApiPages } from './api-pages-output.js';
|
|
7
|
+
import { runningPreview, writeLock, removeLock } from './dev-lock.js';
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
// The same problem tends to arrive more than once in a row - Vite and
|
|
10
|
+
// Astro each log a failed page, and a page compiles for more than one
|
|
11
|
+
// environment. Within this window, a repeat is dropped.
|
|
12
|
+
const REPEAT_WINDOW_MS = 2000;
|
|
13
|
+
|
|
14
|
+
export async function runDev({ contentDir, packageRoot, port, verbose = false }) {
|
|
15
|
+
const started = Date.now();
|
|
6
16
|
preflightCheck(contentDir);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
const other = runningPreview(packageRoot);
|
|
18
|
+
if (other) {
|
|
19
|
+
log.error(`Another writedocs preview is already running${other.url ? ` at ${other.url}` : ''}.`);
|
|
20
|
+
log.detail(
|
|
21
|
+
color.dim(
|
|
22
|
+
`It's serving ${other.contentDir}. Stop it first (Ctrl+C in its terminal) - two previews at once overwrite each other's pages.`
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
throw new CliExit(1);
|
|
26
|
+
}
|
|
27
|
+
writeLock(packageRoot, { contentDir, url: null });
|
|
28
|
+
process.on('exit', () => removeLock(packageRoot));
|
|
29
|
+
|
|
30
|
+
const api = await generateApiPages({ contentDir });
|
|
31
|
+
reportApiPages(api);
|
|
32
|
+
api.warnings.forEach((message) => log.warn(message));
|
|
33
|
+
|
|
34
|
+
const starting = step('Starting local preview');
|
|
35
|
+
let ready = false;
|
|
36
|
+
|
|
37
|
+
const lastShown = new Map();
|
|
38
|
+
const once = (key, print) => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
if (now - (lastShown.get(key) ?? 0) < REPEAT_WINDOW_MS) return;
|
|
41
|
+
lastShown.set(key, now);
|
|
42
|
+
print();
|
|
43
|
+
};
|
|
44
|
+
const oneLine = (where, message) => (where ? `${color.bold(where)} ${message}` : message);
|
|
45
|
+
|
|
46
|
+
// A failed page is logged twice - Vite's error (message only) and then
|
|
47
|
+
// Astro's (message plus the file). Errors wait a moment so the two can
|
|
48
|
+
// be merged into one line that names the file.
|
|
49
|
+
// When the same page fails again, only the first report of it named the
|
|
50
|
+
// file: later ones point at the MDX compiler instead. The file each
|
|
51
|
+
// error was last seen in is remembered here, by the error itself.
|
|
52
|
+
const pendingErrors = new Map();
|
|
53
|
+
const knownWhere = new Map();
|
|
54
|
+
const showError = (problem) => {
|
|
55
|
+
if (problem.where) knownWhere.set(problem.key, problem.where);
|
|
56
|
+
else if (knownWhere.has(problem.key)) problem = { ...problem, where: knownWhere.get(problem.key) };
|
|
57
|
+
const existing = pendingErrors.get(problem.message);
|
|
58
|
+
if (existing) {
|
|
59
|
+
if (!existing.problem.where && problem.where) existing.problem = problem;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const entry = { problem };
|
|
63
|
+
entry.timer = setTimeout(() => {
|
|
64
|
+
pendingErrors.delete(problem.message);
|
|
65
|
+
const { where, message, hint } = entry.problem;
|
|
66
|
+
once(`error\n${where}\n${message}`, () => {
|
|
67
|
+
log.error(oneLine(where, message));
|
|
68
|
+
if (hint) log.detail(color.dim(`Hint: ${hint}`));
|
|
69
|
+
});
|
|
70
|
+
}, 150);
|
|
71
|
+
pendingErrors.set(problem.message, entry);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let fatal = null;
|
|
75
|
+
const rawTail = [];
|
|
76
|
+
const { child, exited } = runAstro('dev', {
|
|
77
|
+
packageRoot,
|
|
78
|
+
contentDir,
|
|
79
|
+
port,
|
|
80
|
+
onEvent(event) {
|
|
81
|
+
if (verbose) {
|
|
82
|
+
const line = verboseLine(event);
|
|
83
|
+
if (line !== null) log.line(color.dim(line));
|
|
84
|
+
}
|
|
85
|
+
switch (event.type) {
|
|
86
|
+
case 'ready': {
|
|
87
|
+
ready = true;
|
|
88
|
+
starting.succeed(`Preview ready ${color.dim(`in ${duration(Date.now() - started)}`)}`);
|
|
89
|
+
const [local] = event.urls?.local ?? [];
|
|
90
|
+
writeLock(packageRoot, { contentDir, url: local ?? null });
|
|
91
|
+
const wanted = Number(port ?? 4321);
|
|
92
|
+
const actual = local ? Number(new URL(local).port) : wanted;
|
|
93
|
+
log.line();
|
|
94
|
+
log.line(` ${color.bold('Local:')} ${color.cyan(local ?? `http://localhost:${wanted}/`)}`);
|
|
95
|
+
if (actual !== wanted) log.line(color.dim(` Port ${wanted} was in use, so the preview is on ${actual}.`));
|
|
96
|
+
log.line();
|
|
97
|
+
log.line(color.dim(' Edit any page and the preview updates. Press Ctrl+C to stop.'));
|
|
98
|
+
log.line();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
case 'fatal':
|
|
102
|
+
fatal = event.error;
|
|
103
|
+
return;
|
|
104
|
+
case 'report':
|
|
105
|
+
// 'info' notes are about the build (sitemap and the like), not
|
|
106
|
+
// the preview.
|
|
107
|
+
if (event.level === 'warn') once(`warn\n${event.where}\n${event.message}`, () => log.warn(oneLine(event.where, event.message)));
|
|
108
|
+
return;
|
|
109
|
+
case 'raw':
|
|
110
|
+
rawTail.push(event.line);
|
|
111
|
+
if (rawTail.length > 40) rawTail.shift();
|
|
112
|
+
return;
|
|
113
|
+
case 'astro-log': {
|
|
114
|
+
if (event.level === 'error') {
|
|
115
|
+
showError(describeError(stripAnsi(event.message), contentDir));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const request = requestLog(event);
|
|
119
|
+
// A missing page is worth knowing about (a broken link, a typo in
|
|
120
|
+
// the navigation); a missing favicon or source map isn't.
|
|
121
|
+
if (request?.status === 404 && !/\.[a-z0-9]+$/i.test(request.url.split('?')[0])) {
|
|
122
|
+
once(`404\n${request.url}`, () => log.warn(`${color.bold(request.url)} No page at this address (404).`));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const warning = authorWarning(event, contentDir);
|
|
126
|
+
if (warning) once(`warn\n${warning}`, () => log.warn(warning));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Ctrl+C: stop the server quietly. (The child gets the same signal from
|
|
134
|
+
// the terminal; killing it here covers the cases where it doesn't.) A
|
|
135
|
+
// second Ctrl+C doesn't wait for it.
|
|
136
|
+
let stopping = false;
|
|
137
|
+
const stop = () => {
|
|
138
|
+
if (stopping) process.exit(130);
|
|
139
|
+
stopping = true;
|
|
140
|
+
child.kill();
|
|
141
|
+
};
|
|
142
|
+
process.on('SIGINT', stop);
|
|
143
|
+
process.on('SIGTERM', stop);
|
|
144
|
+
|
|
145
|
+
const code = await exited;
|
|
146
|
+
if (stopping) {
|
|
147
|
+
if (ready) log.line(color.dim('Preview stopped.'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// The server never stops on its own - getting here is a failure.
|
|
151
|
+
if (!ready) starting.fail('Could not start the local preview');
|
|
152
|
+
else log.error('The local preview stopped unexpectedly.');
|
|
153
|
+
if (fatal) {
|
|
154
|
+
const problem = describeError(fatal, contentDir);
|
|
155
|
+
log.line();
|
|
156
|
+
log.line(formatProblems([{ where: problem.where, message: problem.message }]));
|
|
157
|
+
if (problem.hint) log.line(`\n${color.dim(` Hint: ${problem.hint}`)}`);
|
|
158
|
+
if (verbose && fatal.stack) log.line(`\n${color.dim(fatal.stack)}`);
|
|
159
|
+
} else if (rawTail.length) {
|
|
160
|
+
log.line();
|
|
161
|
+
log.line(color.dim(rawTail.join('\n')));
|
|
162
|
+
}
|
|
163
|
+
throw new CliExit(code || 1);
|
|
12
164
|
}
|
|
@@ -235,7 +235,7 @@ function collectOpenApiGroups(navigation) {
|
|
|
235
235
|
* in the same writedocs.json never collide with each other on disk, exactly
|
|
236
236
|
* as they won't collide in the URL space either (both keyed off the
|
|
237
237
|
* same `path` value). */
|
|
238
|
-
async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides }) {
|
|
238
|
+
async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides, summary }) {
|
|
239
239
|
const pathPrefix = normalizePathPrefix(group.openapi.path);
|
|
240
240
|
const specPath = path.resolve(contentDir, group.openapi.src);
|
|
241
241
|
if (!fs.existsSync(specPath)) {
|
|
@@ -245,7 +245,6 @@ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, o
|
|
|
245
245
|
);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
console.log(`[writedocs] Parsing OpenAPI spec for "${group.group}": ${group.openapi.src}`);
|
|
249
248
|
const spec = await SwaggerParser.dereference(specPath);
|
|
250
249
|
const operations = buildOperations(spec);
|
|
251
250
|
|
|
@@ -293,7 +292,7 @@ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, o
|
|
|
293
292
|
}
|
|
294
293
|
|
|
295
294
|
fs.writeFileSync(path.join(openapiOutDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
296
|
-
|
|
295
|
+
summary.groups.push({ group: group.group, src: group.openapi.src, operations: operations.length });
|
|
297
296
|
}
|
|
298
297
|
|
|
299
298
|
/**
|
|
@@ -325,8 +324,13 @@ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, o
|
|
|
325
324
|
* No-ops (after clearing any stale output from a previous run) if
|
|
326
325
|
* writedocs.json's navigation has no openapi groups at all - most sites don't
|
|
327
326
|
* have one.
|
|
327
|
+
*
|
|
328
|
+
* Prints nothing: returns what it did, for the CLI to report -
|
|
329
|
+
* { groups: [{ group, src, operations }], pageSpecs: [{ spec, operations }],
|
|
330
|
+
* warnings: [message] }.
|
|
328
331
|
*/
|
|
329
332
|
export async function generateApiPages({ contentDir }) {
|
|
333
|
+
const summary = { groups: [], pageSpecs: [], warnings: [] };
|
|
330
334
|
const writedocsJsonPath = path.join(contentDir, 'writedocs.json');
|
|
331
335
|
const generatedDocsDir = path.join(writedocsTempDir(contentDir), 'generated-docs');
|
|
332
336
|
const openapiOutDir = path.join(writedocsTempDir(contentDir), 'openapi');
|
|
@@ -335,7 +339,7 @@ export async function generateApiPages({ contentDir }) {
|
|
|
335
339
|
try {
|
|
336
340
|
config = JSON.parse(fs.readFileSync(writedocsJsonPath, 'utf-8'));
|
|
337
341
|
} catch {
|
|
338
|
-
return; // preflightCheck() (run first, see dev.js/build.js) already reports this
|
|
342
|
+
return summary; // preflightCheck() (run first, see dev.js/build.js) already reports this
|
|
339
343
|
}
|
|
340
344
|
|
|
341
345
|
// Always start from a clean slate: a group's `path` (or the group
|
|
@@ -362,10 +366,11 @@ export async function generateApiPages({ contentDir }) {
|
|
|
362
366
|
|
|
363
367
|
const overrides = findHandWrittenOverrides(contentDir);
|
|
364
368
|
for (const group of groups) {
|
|
365
|
-
await generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides });
|
|
369
|
+
await generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides, summary });
|
|
366
370
|
}
|
|
367
371
|
|
|
368
|
-
await parsePageSpecs({ contentDir, openapiOutDir, groupSpecs: groups.map((g) => path.resolve(contentDir, g.openapi.src)) });
|
|
372
|
+
await parsePageSpecs({ contentDir, openapiOutDir, groupSpecs: groups.map((g) => path.resolve(contentDir, g.openapi.src)), summary });
|
|
373
|
+
return summary;
|
|
369
374
|
}
|
|
370
375
|
|
|
371
376
|
/** Mintlify's page-level form, `openapi: "/spec.json METHOD /path"`: the
|
|
@@ -376,7 +381,7 @@ export async function generateApiPages({ contentDir }) {
|
|
|
376
381
|
* pages are generated - the pages that name the spec are the pages. A
|
|
377
382
|
* spec that's missing or doesn't parse is a warning, not a build failure:
|
|
378
383
|
* its pages show the playground's own "no operation found" notice. */
|
|
379
|
-
async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs }) {
|
|
384
|
+
async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs, summary }) {
|
|
380
385
|
const specs = new Set();
|
|
381
386
|
for (const rel of findAllPages(contentDir)) {
|
|
382
387
|
let data;
|
|
@@ -392,14 +397,14 @@ async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs }) {
|
|
|
392
397
|
const specPath = path.resolve(contentDir, spec);
|
|
393
398
|
if (groupSpecs.includes(specPath)) continue;
|
|
394
399
|
if (!fs.existsSync(specPath)) {
|
|
395
|
-
|
|
400
|
+
summary.warnings.push(`Pages name the OpenAPI spec "${spec}", which doesn't exist - their API playground shows a notice instead.`);
|
|
396
401
|
continue;
|
|
397
402
|
}
|
|
398
403
|
let operations;
|
|
399
404
|
try {
|
|
400
405
|
operations = buildOperations(await SwaggerParser.dereference(specPath));
|
|
401
406
|
} catch (err) {
|
|
402
|
-
|
|
407
|
+
summary.warnings.push(`Couldn't parse the OpenAPI spec "${spec}" that pages name: ${err.message}`);
|
|
403
408
|
continue;
|
|
404
409
|
}
|
|
405
410
|
const outDir = path.join(openapiOutDir, '_pages', specDirName(spec), 'operations');
|
|
@@ -407,6 +412,6 @@ async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs }) {
|
|
|
407
412
|
for (const operation of operations) {
|
|
408
413
|
fs.writeFileSync(path.join(outDir, operationFileName(operation.method, operation.path)), JSON.stringify(operation, null, 2));
|
|
409
414
|
}
|
|
410
|
-
|
|
415
|
+
summary.pageSpecs.push({ spec, operations: operations.length });
|
|
411
416
|
}
|
|
412
417
|
}
|
package/src/cli/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { log, color } from './output.js';
|
|
3
4
|
|
|
4
5
|
const WRITEDOCS_JSON = {
|
|
5
6
|
name: 'My Docs',
|
|
@@ -59,23 +60,24 @@ export async function runInit({ targetDir }) {
|
|
|
59
60
|
|
|
60
61
|
const configPath = path.join(targetDir, 'writedocs.json');
|
|
61
62
|
if (fs.existsSync(configPath)) {
|
|
62
|
-
|
|
63
|
+
log.warn('writedocs.json already exists - left as it is.');
|
|
63
64
|
} else {
|
|
64
65
|
fs.writeFileSync(configPath, JSON.stringify(WRITEDOCS_JSON, null, 2) + '\n');
|
|
65
|
-
|
|
66
|
+
log.success('Created writedocs.json');
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const indexPath = path.join(targetDir, 'index.mdx');
|
|
69
70
|
if (!fs.existsSync(indexPath)) {
|
|
70
71
|
fs.writeFileSync(indexPath, INDEX_MDX);
|
|
71
|
-
|
|
72
|
+
log.success('Created index.mdx');
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
const gsPath = path.join(targetDir, 'docs', 'getting-started.mdx');
|
|
75
76
|
if (!fs.existsSync(gsPath)) {
|
|
76
77
|
fs.writeFileSync(gsPath, GETTING_STARTED_MDX);
|
|
77
|
-
|
|
78
|
+
log.success('Created docs/getting-started.mdx');
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
log.line();
|
|
82
|
+
log.line(`Ready. Run ${color.bold('writedocs dev')} to preview your site.`);
|
|
81
83
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The writedocs CLI's own terminal output: every command prints through
|
|
2
|
+
// these helpers, so they all read the same way - a ✓/✗/⚠ line per step, and
|
|
3
|
+
// problems as "file:line message" - and nothing Astro, Vite or Node prints
|
|
4
|
+
// on their own reaches the terminal (see run-astro.js, which collects all of
|
|
5
|
+
// that as events instead).
|
|
6
|
+
//
|
|
7
|
+
// Colors and the spinner only on an interactive terminal. In CI or a log
|
|
8
|
+
// file (not a TTY), every line is plain text and a step prints once, when
|
|
9
|
+
// it finishes. NO_COLOR turns colors off; FORCE_COLOR turns them on.
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const stream = process.stdout;
|
|
13
|
+
// Output piped into something that stopped reading (`writedocs build | head`):
|
|
14
|
+
// nothing more can be shown, so stop instead of crashing on EPIPE.
|
|
15
|
+
stream.on('error', (err) => {
|
|
16
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
17
|
+
throw err;
|
|
18
|
+
});
|
|
19
|
+
const interactive = Boolean(stream.isTTY) && process.env.TERM !== 'dumb' && !process.env.CI;
|
|
20
|
+
const useColor =
|
|
21
|
+
process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0'
|
|
22
|
+
? true
|
|
23
|
+
: process.env.NO_COLOR === undefined && interactive;
|
|
24
|
+
|
|
25
|
+
const paint = (open, close) => (text) => (useColor ? `\x1b[${open}m${text}\x1b[${close}m` : String(text));
|
|
26
|
+
export const color = {
|
|
27
|
+
bold: paint(1, 22),
|
|
28
|
+
dim: paint(2, 22),
|
|
29
|
+
red: paint(31, 39),
|
|
30
|
+
green: paint(32, 39),
|
|
31
|
+
yellow: paint(33, 39),
|
|
32
|
+
cyan: paint(36, 39),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const symbols = {
|
|
36
|
+
success: color.green('✓'),
|
|
37
|
+
error: color.red('✗'),
|
|
38
|
+
warn: color.yellow('⚠'),
|
|
39
|
+
info: color.cyan('ℹ'),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// One spinner at a time; anything printed while it spins clears its line
|
|
43
|
+
// first and redraws it after.
|
|
44
|
+
let activeSpinner = null;
|
|
45
|
+
|
|
46
|
+
function writeLine(text = '') {
|
|
47
|
+
if (activeSpinner) activeSpinner.clear();
|
|
48
|
+
stream.write(`${text}\n`);
|
|
49
|
+
if (activeSpinner) activeSpinner.render();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const log = {
|
|
53
|
+
line: writeLine,
|
|
54
|
+
success: (text) => writeLine(`${symbols.success} ${text}`),
|
|
55
|
+
error: (text) => writeLine(`${symbols.error} ${text}`),
|
|
56
|
+
warn: (text) => writeLine(`${symbols.warn} ${text}`),
|
|
57
|
+
info: (text) => writeLine(`${symbols.info} ${text}`),
|
|
58
|
+
// Text under a ✓/✗ line, indented to line up with it.
|
|
59
|
+
detail: (text) => writeLine(indent(text, 2)),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function indent(text, spaces) {
|
|
63
|
+
const pad = ' '.repeat(spaces);
|
|
64
|
+
return String(text)
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map((line) => (line ? pad + line : line))
|
|
67
|
+
.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
71
|
+
|
|
72
|
+
/** A step in progress: a spinner with `text` on a terminal, nothing
|
|
73
|
+
* elsewhere, until it ends with succeed()/fail()/stop(). */
|
|
74
|
+
export function step(text) {
|
|
75
|
+
let current = text;
|
|
76
|
+
let frame = 0;
|
|
77
|
+
let timer = null;
|
|
78
|
+
const spinner = {
|
|
79
|
+
clear() {
|
|
80
|
+
stream.write('\r\x1b[2K');
|
|
81
|
+
},
|
|
82
|
+
render() {
|
|
83
|
+
stream.write(`\r\x1b[2K${color.cyan(FRAMES[frame])} ${current}`);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
if (interactive) {
|
|
87
|
+
activeSpinner = spinner;
|
|
88
|
+
spinner.render();
|
|
89
|
+
timer = setInterval(() => {
|
|
90
|
+
frame = (frame + 1) % FRAMES.length;
|
|
91
|
+
spinner.render();
|
|
92
|
+
}, 80);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
}
|
|
95
|
+
const end = () => {
|
|
96
|
+
if (timer) clearInterval(timer);
|
|
97
|
+
if (activeSpinner === spinner) {
|
|
98
|
+
spinner.clear();
|
|
99
|
+
activeSpinner = null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
update(next) {
|
|
104
|
+
current = next;
|
|
105
|
+
if (activeSpinner === spinner) spinner.render();
|
|
106
|
+
},
|
|
107
|
+
succeed(done = current) {
|
|
108
|
+
end();
|
|
109
|
+
log.success(done);
|
|
110
|
+
},
|
|
111
|
+
fail(done = current) {
|
|
112
|
+
end();
|
|
113
|
+
log.error(done);
|
|
114
|
+
},
|
|
115
|
+
stop() {
|
|
116
|
+
end();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Thrown by a command that already printed why it failed: bin/writedocs.js
|
|
122
|
+
* exits with `code` without printing anything more. */
|
|
123
|
+
export class CliExit extends Error {
|
|
124
|
+
constructor(code = 1) {
|
|
125
|
+
super(`exit ${code}`);
|
|
126
|
+
this.code = code;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The message of an error nobody formatted - a thrown Error's text,
|
|
131
|
+
* without the "[writedocs] " prefix older messages carry. */
|
|
132
|
+
export function errorText(err) {
|
|
133
|
+
return String(err?.message ?? err).replace(/^\[writedocs\]\s*/, '');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function plural(count, word, many = `${word}s`) {
|
|
137
|
+
return `${count} ${count === 1 ? word : many}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function duration(ms) {
|
|
141
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A path as the author would type it: relative to the current directory
|
|
145
|
+
* when it's inside it, with forward slashes; absolute otherwise. */
|
|
146
|
+
export function displayPath(target) {
|
|
147
|
+
const rel = path.relative(process.cwd(), target);
|
|
148
|
+
if (rel === '') return '.';
|
|
149
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return target;
|
|
150
|
+
return rel.split(path.sep).join('/');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Problems as "file:line" on one line and the message indented under
|
|
154
|
+
* it - the same shape `writedocs validate` uses. `items`: { where?,
|
|
155
|
+
* message } with `where` already "file:line" (or empty). */
|
|
156
|
+
export function formatProblems(items) {
|
|
157
|
+
return items
|
|
158
|
+
.map(({ where, message }) => (where ? ` ${color.bold(where)}\n${indent(message, 4)}` : indent(message, 2)))
|
|
159
|
+
.join('\n\n');
|
|
160
|
+
}
|
package/src/cli/preflight.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { log, color, CliExit } from './output.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Fast, dependency-light sanity check that runs before handing off to
|
|
@@ -17,19 +18,21 @@ export function preflightCheck(contentDir) {
|
|
|
17
18
|
// that's a one-line fix rather than a real missing-config problem.
|
|
18
19
|
const legacyConfigPath = path.join(contentDir, 'docs.json');
|
|
19
20
|
if (fs.existsSync(legacyConfigPath)) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
log.error(`Found docs.json in ${contentDir}, but writedocs reads writedocs.json.`);
|
|
22
|
+
log.detail(color.dim('A Mintlify project? Run "writedocs convert --mintlify" to create writedocs.json from it.'));
|
|
23
|
+
log.detail(color.dim('An older writedocs project? Rename docs.json to writedocs.json.'));
|
|
24
|
+
throw new CliExit(1);
|
|
23
25
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
log.error(`No writedocs.json found in ${contentDir}`);
|
|
27
|
+
log.detail(color.dim('Run "writedocs init" to create one.'));
|
|
28
|
+
throw new CliExit(1);
|
|
27
29
|
}
|
|
28
30
|
try {
|
|
29
31
|
JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
30
32
|
} catch (err) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
log.error(`writedocs.json is not valid JSON: ${err.message}`);
|
|
34
|
+
log.detail(color.dim('Run "writedocs validate" to see where.'));
|
|
35
|
+
throw new CliExit(1);
|
|
33
36
|
}
|
|
34
37
|
// No docs/-folder check here: docs/ isn't a required directory - a page
|
|
35
38
|
// can live anywhere in the project (see findAllPages() in
|