@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.
@@ -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
- console.error(
17
- `[writedocs] Found mint.json, Mintlify's older config format. Run \`npx mint upgrade\` in ${contentDir} to turn it into docs.json, then run this again.`
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
- console.error(`[writedocs] No docs.json found in ${contentDir}.`);
20
+ log.error(`No docs.json found in ${contentDir}`);
21
21
  }
22
- process.exit(1);
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
- console.error(`[writedocs] ${outPath} already exists. Pass --force to overwrite it, or --dry-run to only see the result.`);
27
- process.exit(1);
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
- console.error(`[writedocs] Couldn't read ${docsJsonPath}: ${err.message}`);
35
- process.exit(1);
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
- console.error('[writedocs] The converted writedocs.json is not valid - this is a bug in the converter, please report it:\n');
46
- console.error(formatValidationIssuesDetailed(result.issues));
47
- console.error(`\n${text}`);
48
- process.exit(1);
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
- console.log(text);
54
+ log.line(text);
53
55
  } else {
54
56
  fs.writeFileSync(outPath, text);
55
- console.log(`[writedocs] Converted ${path.basename(docsJsonPath)} -> ${outPath}`);
57
+ log.success(`Converted ${path.basename(docsJsonPath)} to ${color.bold(displayPath(outPath))}`);
56
58
  }
57
59
 
58
60
  if (notes.length) {
59
- console.log(`\n[writedocs] ${notes.length} thing${notes.length === 1 ? '' : 's'} couldn't be carried over as-is:\n`);
60
- console.log(formatNotes(notes));
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
- console.log(`\n[writedocs] ${content.errors.length} error${content.errors.length === 1 ? '' : 's'} in the pages - the build would fail on these:\n`);
68
- console.log(formatContentIssues(content.errors));
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
- console.log(`\n[writedocs] ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'} in the pages:\n`);
72
- console.log(formatContentIssues(content.warnings));
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
- console.log(
75
- `\n[writedocs] Checked ${content.pages} page${content.pages === 1 ? '' : 's'}: ${content.errors.length} error${content.errors.length === 1 ? '' : 's'}, ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'}.`
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
- console.log(
79
- '[writedocs] 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.)'
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
- export async function runDev({ contentDir, packageRoot, port }) {
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
- await generateApiPages({ contentDir });
8
- const args = ['dev', '--root', packageRoot];
9
- if (port) args.push('--port', String(port));
10
- console.log(`[writedocs] Serving ${contentDir}`);
11
- await runAstro(args, { packageRoot, contentDir });
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
- console.log(`[writedocs] Generated ${operations.length} API operation page(s) for "${group.group}"`);
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
- console.warn(`[writedocs] Pages name the OpenAPI spec "${spec}", which doesn't exist - their API playground shows a notice instead.`);
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
- console.warn(`[writedocs] Couldn't parse the OpenAPI spec "${spec}" that pages name: ${err.message}`);
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
- console.log(`[writedocs] Parsed OpenAPI spec "${spec}" for the pages that name it (${operations.length} operations)`);
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
- console.error(`[writedocs] writedocs.json already exists in ${targetDir}, skipping.`);
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
- console.log(`[writedocs] Created writedocs.json`);
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
- console.log(`[writedocs] Created index.mdx`);
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
- console.log(`[writedocs] Created docs/getting-started.mdx`);
78
+ log.success('Created docs/getting-started.mdx');
78
79
  }
79
80
 
80
- console.log(`\n[writedocs] Ready. Run "writedocs dev" to preview your site.`);
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
+ }
@@ -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
- console.error(`[writedocs] Found docs.json in ${contentDir}, but the config file is now named writedocs.json.`);
21
- console.error(`[writedocs] Rename it: mv docs.json writedocs.json`);
22
- process.exit(1);
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
- console.error(`[writedocs] No writedocs.json found in ${contentDir}`);
25
- console.error(`[writedocs] Run "writedocs init" to create one.`);
26
- process.exit(1);
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
- console.error(`[writedocs] writedocs.json is not valid JSON: ${err.message}`);
32
- process.exit(1);
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