@writedocs/generator 0.4.10 → 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 CHANGED
@@ -36,6 +36,7 @@ import { remarkUnknownComponentFallback } from './src/lib/mdx-unknown-components
36
36
  import { remarkExtractInlineReactComponents } from './src/lib/mdx-inline-react.js';
37
37
  import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
38
38
  import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
39
+ import { report } from './src/lib/cli-report.js';
39
40
  import {
40
41
  loadDocsConfig,
41
42
  resolveCodeblockTheme,
@@ -105,7 +106,7 @@ const contentPublicDir = path.join(contentDir, 'public');
105
106
  // document-relative URLs isn't meaningful anyway.
106
107
  const siteUrl = resolveSiteUrl(docsConfig);
107
108
  if (!siteUrl) {
108
- console.log('[writedocs] No writedocs.json "domain" set - skipping sitemap.xml generation.');
109
+ report('info', 'No "domain" set in writedocs.json, so no sitemap.xml was generated.');
109
110
  }
110
111
 
111
112
  /** Recursively lists every .md/.mdx file under `baseDir` (Node 20's
@@ -184,18 +185,18 @@ function singleSitemapFile() {
184
185
  const chunks = fs.readdirSync(outDir).filter((f) => /^sitemap-\d+\.xml$/.test(f));
185
186
  const target = path.join(outDir, 'sitemap.xml');
186
187
  if (chunks.length !== 1) {
187
- console.log(`[writedocs] ${chunks.length} sitemap files generated - keeping sitemap-index.xml.`);
188
+ report('debug', `${chunks.length} sitemap files generated - keeping sitemap-index.xml.`);
188
189
  return;
189
190
  }
190
191
  if (fs.existsSync(target)) {
191
- console.log('[writedocs] public/sitemap.xml exists - keeping it and the generated sitemap-index.xml.');
192
+ report('info', 'public/sitemap.xml exists, so it was kept - the generated sitemap is sitemap-index.xml.');
192
193
  return;
193
194
  }
194
195
  fs.renameSync(path.join(outDir, chunks[0]), target);
195
196
  fs.rmSync(path.join(outDir, 'sitemap-index.xml'), { force: true });
196
197
  // O próprio plugin acabou de logar "sitemap-index.xml created"; esta
197
198
  // linha diz o que de fato ficou no build.
198
- console.log(`[writedocs] Replaced ${chunks[0]} + sitemap-index.xml with a single sitemap.xml.`);
199
+ report('debug', `Replaced ${chunks[0]} + sitemap-index.xml with a single sitemap.xml.`);
199
200
  },
200
201
  },
201
202
  };
package/bin/writedocs.js CHANGED
@@ -8,6 +8,7 @@ import { runDev } from '../src/cli/dev.js';
8
8
  import { runBuild } from '../src/cli/build.js';
9
9
  import { runInit } from '../src/cli/init.js';
10
10
  import { requireBuildKey } from '../src/cli/build-auth.js';
11
+ import { log, step, plural, color, CliExit, errorText } from '../src/cli/output.js';
11
12
  // O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
12
13
  // que a plataforma importa por `@writedocs/generator/config-schema` - e o que
13
14
  // faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
@@ -49,11 +50,13 @@ program
49
50
  .description('Start a local dev server with hot reload')
50
51
  .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
51
52
  .option('-p, --port <port>', 'port to run on')
53
+ .option('--verbose', "also print Astro's and Vite's own output")
52
54
  .action(async (dir, opts) => {
53
55
  await runDev({
54
56
  contentDir: path.resolve(process.cwd(), dir),
55
57
  packageRoot,
56
58
  port: opts.port,
59
+ verbose: Boolean(opts.verbose),
57
60
  });
58
61
  });
59
62
 
@@ -66,6 +69,7 @@ program
66
69
  .description('Build a static site into <dir>/dist')
67
70
  .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
68
71
  .option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
72
+ .option('--verbose', "also print Astro's and Vite's own output")
69
73
  .action(async (dir, opts) => {
70
74
  const contentDir = path.resolve(process.cwd(), dir);
71
75
  // Project-scoped, not global: a .env sitting next to this project's own
@@ -75,7 +79,7 @@ program
75
79
  // environment - an explicit `export`/CI secret still wins over the file.
76
80
  dotenv.config({ path: path.join(contentDir, '.env'), quiet: true });
77
81
  await requireBuildKey(opts.key || process.env.WRITEDOCS_API_KEY);
78
- await runBuild({ contentDir, packageRoot });
82
+ await runBuild({ contentDir, packageRoot, verbose: Boolean(opts.verbose) });
79
83
  });
80
84
 
81
85
  program
@@ -91,10 +95,9 @@ program
91
95
  const configPath = path.join(contentDir, 'writedocs.json');
92
96
  if (!fs.existsSync(configPath)) {
93
97
  // Mesma mensagem que loadDocsConfig() ja da pro mesmo caso.
94
- console.error(
95
- `[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
96
- );
97
- process.exit(1);
98
+ log.error(`No writedocs.json found in ${contentDir}`);
99
+ log.detail(color.dim('Run "writedocs init" to create one.'));
100
+ throw new CliExit(1);
98
101
  }
99
102
 
100
103
  const rawText = fs.readFileSync(configPath, 'utf-8');
@@ -109,15 +112,21 @@ program
109
112
  // A versao longa (linha, frase humana, sugestao) em vez da curta que o
110
113
  // `writedocs build` imprime: aqui o usuario pediu explicitamente um
111
114
  // diagnostico, e tem a tela inteira pra ele.
112
- console.error(`[writedocs] ${configPath} failed validation:\n`);
113
- console.error(formatValidationIssuesDetailed(result.issues, { fileName: nomeArquivo }));
114
- console.error('');
115
+ log.error(
116
+ result.kind === 'invalid_json'
117
+ ? `${nomeArquivo} is not valid JSON`
118
+ : `${plural(result.issues.length, 'error')} in ${nomeArquivo}`
119
+ );
120
+ log.line();
121
+ log.line(formatValidationIssuesDetailed(result.issues, { fileName: nomeArquivo }));
122
+ log.line();
115
123
  }
116
124
 
117
125
  if (warnings.length > 0) {
118
- console.error(`[writedocs] ${warnings.length} warning${warnings.length === 1 ? '' : 's'}:\n`);
119
- console.error(formatValidationIssuesDetailed(warnings, { fileName: nomeArquivo }));
120
- console.error('');
126
+ log.warn(`${plural(warnings.length, 'warning')} in ${nomeArquivo}`);
127
+ log.line();
128
+ log.line(formatValidationIssuesDetailed(warnings, { fileName: nomeArquivo }));
129
+ log.line();
121
130
  }
122
131
 
123
132
  // The pages: everything that would fail the build (errors) or build
@@ -128,27 +137,35 @@ program
128
137
  let content = null;
129
138
  if (!options.configOnly) {
130
139
  const { checkContent, formatContentIssues } = await import('../src/lib/content-check.js');
140
+ const checking = step('Checking pages');
131
141
  content = await checkContent(contentDir, result.kind === 'invalid_json' ? null : rawText);
142
+ checking.stop();
132
143
  if (content.errors.length > 0) {
133
- console.error(`[writedocs] ${content.errors.length} error${content.errors.length === 1 ? '' : 's'} in the pages:\n`);
134
- console.error(formatContentIssues(content.errors));
135
- console.error('');
144
+ log.error(`${plural(content.errors.length, 'error')} in the pages`);
145
+ log.line();
146
+ log.line(formatContentIssues(content.errors));
147
+ log.line();
136
148
  }
137
149
  if (content.warnings.length > 0) {
138
- console.error(`[writedocs] ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'} in the pages:\n`);
139
- console.error(formatContentIssues(content.warnings));
140
- console.error('');
150
+ log.warn(`${plural(content.warnings.length, 'warning')} in the pages`);
151
+ log.line();
152
+ log.line(formatContentIssues(content.warnings));
153
+ log.line();
141
154
  }
142
155
  }
143
156
 
144
- if (!result.ok || (content && content.errors.length > 0)) process.exit(1);
157
+ if (!result.ok || (content && content.errors.length > 0)) {
158
+ const total = (result.ok ? 0 : result.issues.length) + (content ? content.errors.length : 0);
159
+ log.error(`Not valid: ${plural(total, 'error')} to fix - the build would fail on these.`);
160
+ throw new CliExit(1);
161
+ }
145
162
  // Aviso nao invalida: um config so com avisos continua valido, e sai 0 -
146
163
  // o mesmo criterio que a plataforma usa pra marcar o projeto como `valid`.
147
- console.log(`[writedocs] ${configPath} is valid.`);
164
+ log.success(`${nomeArquivo} is valid`);
148
165
  if (content) {
149
- console.log(
150
- `[writedocs] Checked ${content.pages} page${content.pages === 1 ? '' : 's'}: no errors` +
151
- (content.warnings.length ? `, ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'} above.` : '.')
166
+ log.success(
167
+ `Checked ${plural(content.pages, 'page')}: no errors` +
168
+ (content.warnings.length ? `, ${plural(content.warnings.length, 'warning')} above` : '')
152
169
  );
153
170
  }
154
171
  });
@@ -165,8 +182,8 @@ program
165
182
  // --mintlify is the only source today; the flag is still required so
166
183
  // the command reads the same once other tools are added.
167
184
  if (!options.mintlify && !options.docsJson) {
168
- console.error('[writedocs] Say what to convert from: writedocs convert --mintlify [dir]');
169
- process.exit(1);
185
+ log.error('Say what to convert from: writedocs convert --mintlify [dir]');
186
+ throw new CliExit(1);
170
187
  }
171
188
  const { runConvert } = await import('../src/cli/convert.js');
172
189
  await runConvert({
@@ -185,6 +202,8 @@ program
185
202
  });
186
203
 
187
204
  program.parseAsync(process.argv).catch((err) => {
188
- console.error(`[writedocs] ${err.message}`);
205
+ // A command that already printed its own error throws CliExit.
206
+ if (err instanceof CliExit) process.exit(err.code);
207
+ log.error(errorText(err));
189
208
  process.exit(1);
190
209
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,11 @@
1
+ // What generateApiPages() did, as ✓ lines - shared by `dev` and `build`.
2
+ import { log, plural, color } from './output.js';
3
+
4
+ export function reportApiPages({ groups, pageSpecs }) {
5
+ for (const { group, src, operations } of groups) {
6
+ log.success(`Generated ${plural(operations, 'API page')} for "${group}" ${color.dim(`from ${src}`)}`);
7
+ }
8
+ for (const { spec, operations } of pageSpecs) {
9
+ log.success(`Read ${plural(operations, 'operation')} from ${spec} ${color.dim('(named by pages)')}`);
10
+ }
11
+ }
@@ -0,0 +1,146 @@
1
+ // Turns what Astro and Vite report (see astro-worker.js) into what the
2
+ // writedocs CLI shows: an error becomes { where, message, hint } - the
3
+ // author's file and line, and the reason in one sentence - instead of a
4
+ // bundler error with a stack trace; a log line is either something the
5
+ // author should see or noise to hide.
6
+ import path from 'node:path';
7
+
8
+ const ANSI = /\x1b\[[0-9;]*m/g;
9
+ export const stripAnsi = (text) => String(text ?? '').replace(ANSI, '');
10
+
11
+ // Astro's formatted errors (core/messages/runtime.js formatErrorMessage)
12
+ // append these sections after the message itself.
13
+ const SECTION = /\n\s*(Stack trace|Hint|Error reference|Location|Caused by):/;
14
+
15
+ function relativeTo(contentDir, file) {
16
+ if (!file) return null;
17
+ const clean = file.replace(/^file:\/\/\/?/, '').replace(/[?#].*$/, '');
18
+ const abs = path.resolve(clean);
19
+ const rel = path.relative(contentDir, abs);
20
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
21
+ return rel.split(path.sep).join('/');
22
+ }
23
+
24
+ function position(line, column) {
25
+ if (!Number.isInteger(line) || line < 1) return '';
26
+ return Number.isInteger(column) && column > 0 ? `:${line}:${column}` : `:${line}`;
27
+ }
28
+
29
+ /** Everything an Astro/Vite error can say, as { where, message, hint }.
30
+ * `input` is a serialized error (astro-worker.js serializeError) or the
31
+ * text of an error log line. */
32
+ export function describeError(input, contentDir) {
33
+ const error = typeof input === 'string' ? { message: input } : input ?? {};
34
+ let text = stripAnsi(error.message).replace(/\r/g, '');
35
+ let file = error.loc?.file ?? null;
36
+ let line = error.loc?.line;
37
+ let column = error.loc?.column;
38
+ let hint = error.hint ? stripAnsi(error.hint).trim() : null;
39
+ let name = error.name;
40
+
41
+ // A bundler failure wraps the real error:
42
+ // Build failed with 1 error:
43
+ //
44
+ // [plugin @mdx-js/rolldown] /abs/docs/page.mdx:undefined:undefined
45
+ // MDXError: Expected a closing tag for `<Card>` (110:1-110:17)
46
+ // at ...
47
+ const bundled = text.match(/^Build failed with \d+ errors?:\s*\n+([\s\S]*)$/);
48
+ if (bundled) {
49
+ const [head, ...rest] = bundled[1].split('\n');
50
+ const plugin = head.match(/^\[plugin [^\]]+\]\s+(.+?)(?::(\d+|undefined):(\d+|undefined))?\s*$/);
51
+ if (plugin) {
52
+ file = file ?? plugin[1];
53
+ if (plugin[2] && plugin[2] !== 'undefined') line = line ?? Number(plugin[2]);
54
+ if (plugin[3] && plugin[3] !== 'undefined') column = column ?? Number(plugin[3]);
55
+ text = rest.join('\n');
56
+ } else {
57
+ text = bundled[1];
58
+ }
59
+ }
60
+
61
+ // Astro's own sections: keep the hint, and take the file from the first
62
+ // stack frame when it's one of the author's files.
63
+ const sectionAt = text.search(SECTION);
64
+ if (sectionAt !== -1) {
65
+ const sections = text.slice(sectionAt);
66
+ text = text.slice(0, sectionAt);
67
+ const hintMatch = sections.match(/\n\s*Hint:\s*\n([\s\S]*?)(?=\n\s*(Stack trace|Error reference|Location|Caused by):|$)/);
68
+ if (hintMatch && !hint) hint = hintMatch[1].trim().replace(/\n\s+/g, ' ');
69
+ const frame = sections.match(/Stack trace:\s*\n\s*at (.+)/);
70
+ if (frame && !file) file = frame[1].trim();
71
+ }
72
+
73
+ // Stack frames and an "SomethingError: " prefix are noise here.
74
+ text = text
75
+ .split('\n')
76
+ .filter((l) => !/^\s+at\s/.test(l) && !/^\s*\[\.\.\.\] See full stack trace/.test(l))
77
+ .join('\n')
78
+ .trim();
79
+ const prefixed = text.match(/^([A-Z][A-Za-z]*Error|YAMLException):\s+([\s\S]*)$/);
80
+ if (prefixed) {
81
+ name = name && name !== 'Error' ? name : prefixed[1];
82
+ text = prefixed[2];
83
+ }
84
+ text = text.replace(/^\[writedocs\]\s*/, '');
85
+
86
+ // micromark/MDX put the position at the end: "(110:1-110:17)" or "(3:5)".
87
+ const pos = text.match(/\s*\((\d+):(\d+)(?:-\d+:\d+)?\)\s*$/);
88
+ if (pos) {
89
+ line = line ?? Number(pos[1]);
90
+ column = column ?? Number(pos[2]);
91
+ text = text.slice(0, pos.index);
92
+ }
93
+
94
+ if (name === 'YAMLException') text = `Frontmatter isn't valid YAML: ${text}`;
95
+
96
+ const rel = relativeTo(contentDir, file);
97
+ return {
98
+ where: rel ? `${rel}${position(line, column)}` : null,
99
+ message: text || 'Unknown error.',
100
+ hint: hint || null,
101
+ // The same error seen again - dev.js uses it to recall the file when
102
+ // a repeat of the error no longer names it.
103
+ key: `${text}|${position(line, column)}`,
104
+ };
105
+ }
106
+
107
+ /** A route Astro just wrote during a build (" ├─ /docs/x/index.html"),
108
+ * or null. */
109
+ export function builtRoute(event) {
110
+ if (event.type !== 'astro-log' || event.level !== 'info') return null;
111
+ const match = stripAnsi(event.message).match(/^\s*[├└]─\s+(\S+)/);
112
+ return match ? match[1] : null;
113
+ }
114
+
115
+ /** A dev request log ("[404] /nope/ 18ms") as { status, url }, or null. */
116
+ export function requestLog(event) {
117
+ if (event.type !== 'astro-log') return null;
118
+ const match = stripAnsi(event.message).match(/^\[(\d{3})\]\s+(?:\(rewrite\)\s+)?(?:[A-Z]+\s+)?(\S+)/);
119
+ return match ? { status: Number(match[1]), url: match[2] } : null;
120
+ }
121
+
122
+ /** An Astro/Vite warning worth showing the author: one about their own
123
+ * files (it names a path inside the project), rewritten to relative
124
+ * paths. Everything else - bundle sizes, dependency optimization, route
125
+ * internals - is about writedocs itself, and stays hidden (--verbose shows
126
+ * it). */
127
+ export function authorWarning(event, contentDir) {
128
+ if (event.type !== 'astro-log' || event.level !== 'warn') return null;
129
+ const text = stripAnsi(event.message);
130
+ const variants = [contentDir, contentDir.split(path.sep).join('/'), contentDir.split('/').join('\\')];
131
+ if (!variants.some((v) => text.includes(v))) return null;
132
+ let out = text;
133
+ for (const v of variants) out = out.split(`${v}/`).join('').split(`${v}\\`).join('').split(v).join('.');
134
+ return out.trim();
135
+ }
136
+
137
+ /** One event as a plain line, for --verbose. */
138
+ export function verboseLine(event) {
139
+ if (event.type === 'astro-log') {
140
+ const label = event.label && event.label !== 'SKIP_FORMAT' ? `[${event.label}] ` : '';
141
+ const level = event.level === 'warn' || event.level === 'error' ? `${event.level.toUpperCase()} ` : '';
142
+ return `${level}${label}${stripAnsi(event.message)}`;
143
+ }
144
+ if (event.type === 'raw') return stripAnsi(event.line);
145
+ return null;
146
+ }
@@ -0,0 +1,78 @@
1
+ // Runs Astro for `writedocs dev` and `writedocs build`, in a process of its
2
+ // own (spawned by run-astro.js). Astro is driven through its programmatic API
3
+ // rather than its CLI binary, so that:
4
+ // - every Astro/Vite log line goes to lib/astro-log-destination.js, which
5
+ // forwards it to the writedocs CLI instead of printing it;
6
+ // - a fatal error comes back here as an Error object, sent to the CLI with
7
+ // its file/line/hint intact, not as pre-formatted terminal text;
8
+ // - none of the `astro` CLI's own behavior applies: its one-dev-server-per-
9
+ // root lock (every writedocs project shares one root, packageRoot, so two
10
+ // projects' `writedocs dev` would refuse each other), and its automatic
11
+ // "run in the background" mode when it detects an AI agent.
12
+ //
13
+ // Messages to the parent (process.send):
14
+ // { type: 'astro-log', level, label, message } - from the log destination
15
+ // { type: 'ready', urls: { local, network } } - dev server listening
16
+ // { type: 'done' } - build finished
17
+ // { type: 'fatal', error } - see serializeError()
18
+ import path from 'node:path';
19
+ import { build, dev } from 'astro';
20
+
21
+ const command = process.argv[2];
22
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT;
23
+ const port = process.env.WRITEDOCS_PORT ? Number(process.env.WRITEDOCS_PORT) : undefined;
24
+
25
+ function send(message) {
26
+ if (typeof process.send === 'function' && process.connected) process.send(message);
27
+ }
28
+
29
+ function serializeError(err) {
30
+ if (!(err instanceof Error)) return { message: String(err) };
31
+ return {
32
+ name: err.name,
33
+ message: err.message,
34
+ hint: typeof err.hint === 'string' ? err.hint : undefined,
35
+ loc: err.loc && err.loc.file ? { file: err.loc.file, line: err.loc.line, column: err.loc.column } : undefined,
36
+ id: typeof err.id === 'string' ? err.id : undefined,
37
+ frame: typeof err.frame === 'string' ? err.frame : undefined,
38
+ stack: err.stack,
39
+ cause: err.cause ? serializeError(err.cause) : undefined,
40
+ };
41
+ }
42
+
43
+ async function fail(err) {
44
+ send({ type: 'fatal', error: serializeError(err) });
45
+ // Let the IPC message leave before exiting.
46
+ await new Promise((resolve) => setTimeout(resolve, 50));
47
+ process.exit(1);
48
+ }
49
+
50
+ process.on('uncaughtException', fail);
51
+ process.on('unhandledRejection', fail);
52
+ // The writedocs CLI went away (closed terminal, killed) - don't outlive it.
53
+ process.on('disconnect', () => process.exit(0));
54
+
55
+ const inlineConfig = {
56
+ root: packageRoot,
57
+ logLevel: 'info',
58
+ logger: { entrypoint: path.join(packageRoot, 'src', 'lib', 'astro-log-destination.js') },
59
+ };
60
+
61
+ try {
62
+ if (command === 'dev') {
63
+ const server = await dev({
64
+ ...inlineConfig,
65
+ ...(port ? { server: { port } } : {}),
66
+ });
67
+ send({ type: 'ready', urls: server.resolvedUrls ?? { local: [], network: [] } });
68
+ } else if (command === 'build') {
69
+ await build(inlineConfig);
70
+ send({ type: 'done' });
71
+ await new Promise((resolve) => setTimeout(resolve, 50));
72
+ process.exit(0);
73
+ } else {
74
+ throw new Error(`astro-worker: unknown command "${command}"`);
75
+ }
76
+ } catch (err) {
77
+ await fail(err);
78
+ }
@@ -15,16 +15,18 @@
15
15
  * See docs/dev/docs/deploy.mdx for how to deploy key-server/ and issue
16
16
  * keys, and key-server/README.md for the service's own endpoints.
17
17
  */
18
+ import { log } from './output.js';
19
+
18
20
  export const EX_TEMPFAIL = 75;
19
21
 
20
22
  export async function requireBuildKey(providedKey) {
21
23
  const serverUrl = process.env.WRITEDOCS_KEY_SERVER_URL;
22
24
  if (!serverUrl) {
23
- console.error('[writedocs] build is not available.');
25
+ log.error('build is not available.');
24
26
  process.exit(1);
25
27
  }
26
28
  if (!providedKey) {
27
- console.error('[writedocs] build requires a valid --key (or WRITEDOCS_API_KEY).');
29
+ log.error('build requires a valid --key (or WRITEDOCS_API_KEY).');
28
30
  process.exit(1);
29
31
  }
30
32
 
@@ -47,12 +49,12 @@ export async function requireBuildKey(providedKey) {
47
49
  // Fails closed on a network error/timeout too, same as an explicit
48
50
  // rejection - an unreachable authorization server is not treated as
49
51
  // "no opinion, let it through".
50
- console.error(`[writedocs] Could not reach the build authorization server: ${err.message}`);
52
+ log.error(`Could not reach the build authorization server: ${err.message}`);
51
53
  process.exit(EX_TEMPFAIL);
52
54
  }
53
55
 
54
56
  if (res.status >= 500) {
55
- console.error(`[writedocs] The build authorization server failed (HTTP ${res.status}) - try again later.`);
57
+ log.error(`The build authorization server failed (HTTP ${res.status}) - try again later.`);
56
58
  process.exit(EX_TEMPFAIL);
57
59
  }
58
60
 
@@ -63,7 +65,7 @@ export async function requireBuildKey(providedKey) {
63
65
  }
64
66
 
65
67
  if (!valid) {
66
- console.error('[writedocs] build requires a valid --key (or WRITEDOCS_API_KEY) - the server rejected this one.');
68
+ log.error('build requires a valid --key (or WRITEDOCS_API_KEY) - the server rejected this one.');
67
69
  process.exit(1);
68
70
  }
69
71
  }
package/src/cli/build.js CHANGED
@@ -6,12 +6,88 @@ import { preflightCheck } from './preflight.js';
6
6
  import { generateApiPages } from './generate-api-pages.js';
7
7
  import { writeRedirectsFile } from './write-redirects-file.js';
8
8
  import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
9
+ import { log, step, plural, duration, displayPath, formatProblems, color, CliExit } from './output.js';
10
+ import { describeError, builtRoute, authorWarning, verboseLine } from './astro-output.js';
11
+ import { reportApiPages } from './api-pages-output.js';
9
12
 
10
- export async function runBuild({ contentDir, packageRoot }) {
13
+ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
14
+ const started = Date.now();
11
15
  preflightCheck(contentDir);
12
- await generateApiPages({ contentDir });
13
- console.log(`[writedocs] Building ${contentDir} -> ${contentDir}/dist`);
14
- await runAstro(['build', '--root', packageRoot], { packageRoot, contentDir });
16
+
17
+ // Collected while building, printed together at the end - a warning in
18
+ // the middle of a spinner is easy to miss.
19
+ const warnings = [];
20
+ const notes = [];
21
+ const seen = new Set();
22
+ const addWarning = (where, message) => {
23
+ const key = `${where ?? ''}\n${message}`;
24
+ if (seen.has(key)) return;
25
+ seen.add(key);
26
+ warnings.push({ where, message });
27
+ };
28
+
29
+ const api = await generateApiPages({ contentDir });
30
+ reportApiPages(api);
31
+ api.warnings.forEach((message) => addWarning(null, message));
32
+
33
+ const building = step('Building pages');
34
+ const buildStarted = Date.now();
35
+ let pages = 0;
36
+ let fatal = null;
37
+ const rawTail = [];
38
+ const { exited } = runAstro('build', {
39
+ packageRoot,
40
+ contentDir,
41
+ onEvent(event) {
42
+ if (verbose) {
43
+ const line = verboseLine(event);
44
+ if (line !== null) log.line(color.dim(line));
45
+ }
46
+ if (event.type === 'fatal') {
47
+ fatal = event.error;
48
+ return;
49
+ }
50
+ if (event.type === 'report') {
51
+ if (event.level === 'warn') addWarning(event.where, event.message);
52
+ else if (event.level === 'info' && !notes.includes(event.message)) notes.push(event.message);
53
+ return;
54
+ }
55
+ if (event.type === 'raw') {
56
+ rawTail.push(event.line);
57
+ if (rawTail.length > 40) rawTail.shift();
58
+ return;
59
+ }
60
+ const route = builtRoute(event);
61
+ if (route) {
62
+ if (route.endsWith('.html')) pages += 1;
63
+ building.update(`Building pages ${color.dim(`(${pages})`)}`);
64
+ return;
65
+ }
66
+ const warning = authorWarning(event, contentDir);
67
+ if (warning) addWarning(null, warning);
68
+ },
69
+ });
70
+ const code = await exited;
71
+
72
+ if (fatal || code !== 0) {
73
+ building.fail('Build failed');
74
+ if (fatal) {
75
+ const problem = describeError(fatal, contentDir);
76
+ log.line();
77
+ log.line(formatProblems([{ where: problem.where, message: problem.message }]));
78
+ if (problem.hint) log.line(`\n${color.dim(` Hint: ${problem.hint}`)}`);
79
+ if (verbose && fatal.stack) log.line(`\n${color.dim(fatal.stack)}`);
80
+ } else if (rawTail.length) {
81
+ // No structured error (the worker itself crashed) - show what it printed.
82
+ log.line();
83
+ log.line(color.dim(rawTail.join('\n')));
84
+ }
85
+ log.line();
86
+ log.line(color.dim('Run "writedocs validate" to list every problem in the project at once.'));
87
+ throw new CliExit(1);
88
+ }
89
+ building.succeed(`Built ${plural(pages, 'page')} ${color.dim(`in ${duration(Date.now() - buildStarted)}`)}`);
90
+
15
91
  const distDir = path.join(contentDir, 'dist');
16
92
  // Astro just wrote its actual output to writedocsBuildStagingDir()
17
93
  // (astro.config.mjs's own `outDir`), not distDir directly - see that
@@ -34,7 +110,28 @@ export async function runBuild({ contentDir, packageRoot }) {
34
110
  // - this turns those into real instant edge redirects on hosts that read
35
111
  // a `_redirects` file (Cloudflare Pages, Netlify), purely additively.
36
112
  writeRedirectsFile(distDir, contentDir);
37
- console.log('[writedocs] Indexing search...');
38
- await runPagefind(distDir, { packageRoot });
39
- console.log(`[writedocs] Done. Output written to ${contentDir}/dist`);
113
+
114
+ const indexing = step('Indexing search');
115
+ try {
116
+ await runPagefind(distDir, { packageRoot });
117
+ } catch (err) {
118
+ indexing.fail('Search indexing failed');
119
+ log.line();
120
+ log.line(color.dim(err.message));
121
+ throw new CliExit(1);
122
+ }
123
+ indexing.succeed('Indexed search');
124
+
125
+ if (warnings.length) {
126
+ log.line();
127
+ log.warn(plural(warnings.length, 'warning'));
128
+ log.line();
129
+ log.line(formatProblems(warnings));
130
+ }
131
+ if (notes.length) {
132
+ log.line();
133
+ notes.forEach((note) => log.info(note));
134
+ }
135
+ log.line();
136
+ log.success(`Done in ${duration(Date.now() - started)} ${color.dim('-')} site written to ${color.bold(displayPath(distDir))}`);
40
137
  }