@zpress/cli 0.1.4 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.mjs +261 -13
  2. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli, command } from "@kidd-cli/core";
3
- import { createPaths, generateAssets, loadConfig, loadManifest, resolveEntries, sync } from "@zpress/core";
3
+ import { configError, createPaths, generateAssets, loadConfig, loadManifest, resolveEntries, sync } from "@zpress/core";
4
4
  import { z } from "zod";
5
+ import node_path from "node:path";
5
6
  import { execFileSync, spawn } from "node:child_process";
6
7
  import { platform } from "node:os";
7
8
  import { build, dev, serve } from "@rspress/core";
@@ -9,7 +10,6 @@ import { createRspressConfig } from "@zpress/ui";
9
10
  import { match as external_ts_pattern_match } from "ts-pattern";
10
11
  import promises from "node:fs/promises";
11
12
  import node_fs from "node:fs";
12
- import node_path from "node:path";
13
13
  const DEFAULT_PORT = 6174;
14
14
  async function startDevServer(options) {
15
15
  const rspressConfig = createRspressConfig(options);
@@ -33,6 +33,14 @@ async function buildSite(options) {
33
33
  configFilePath: ''
34
34
  });
35
35
  }
36
+ async function buildSiteForCheck(options) {
37
+ const rspressConfig = createRspressConfig(options);
38
+ await build({
39
+ docDirectory: options.paths.contentDir,
40
+ config: rspressConfig,
41
+ configFilePath: ''
42
+ });
43
+ }
36
44
  async function serveSite(options) {
37
45
  const rspressConfig = createRspressConfig(options);
38
46
  await serve({
@@ -66,6 +74,170 @@ function openBrowser(url) {
66
74
  detached: true
67
75
  }).unref();
68
76
  }
77
+ const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
78
+ function stripAnsi(text) {
79
+ return text.replace(ANSI_PATTERN, '');
80
+ }
81
+ function runConfigCheck(params) {
82
+ const { config, loadError } = params;
83
+ if (loadError) return {
84
+ passed: false,
85
+ errors: [
86
+ loadError
87
+ ]
88
+ };
89
+ if (!config) return {
90
+ passed: false,
91
+ errors: [
92
+ configError('empty_sections', 'Config is missing')
93
+ ]
94
+ };
95
+ return {
96
+ passed: true,
97
+ errors: []
98
+ };
99
+ }
100
+ function chunkToString(chunk) {
101
+ if ('string' == typeof chunk) return chunk;
102
+ return Buffer.from(chunk).toString('utf8');
103
+ }
104
+ function toError(value) {
105
+ if (value instanceof Error) return value;
106
+ return new Error(String(value));
107
+ }
108
+ function createInterceptor(chunks) {
109
+ return function(chunk, encodingOrCb, maybeCb) {
110
+ const text = chunkToString(chunk);
111
+ chunks.push(text);
112
+ if ('function' == typeof encodingOrCb) encodingOrCb();
113
+ else if ('function' == typeof maybeCb) maybeCb();
114
+ return true;
115
+ };
116
+ }
117
+ async function captureOutput(fn) {
118
+ const chunks = [];
119
+ const originalStdoutWrite = process.stdout.write;
120
+ const originalStderrWrite = process.stderr.write;
121
+ process.stdout.write = createInterceptor(chunks);
122
+ process.stderr.write = createInterceptor(chunks);
123
+ try {
124
+ const result = await fn();
125
+ return {
126
+ result,
127
+ error: null,
128
+ captured: chunks.join('')
129
+ };
130
+ } catch (error) {
131
+ return {
132
+ result: null,
133
+ error: toError(error),
134
+ captured: chunks.join('')
135
+ };
136
+ } finally{
137
+ process.stdout.write = originalStdoutWrite;
138
+ process.stderr.write = originalStderrWrite;
139
+ }
140
+ }
141
+ function flushGroup(results, file, links) {
142
+ if (file && links.length > 0) return [
143
+ ...results,
144
+ {
145
+ file,
146
+ links
147
+ }
148
+ ];
149
+ return results;
150
+ }
151
+ function parseDeadlinks(stderr) {
152
+ const clean = stripAnsi(stderr);
153
+ const lines = clean.split('\n');
154
+ const headerPattern = /Dead links found in (.+?):\s*$/;
155
+ const linkPattern = /"\[\.\.]\(([^)]+)\)"/;
156
+ const acc = lines.reduce((state, line)=>{
157
+ const headerMatch = headerPattern.exec(line);
158
+ if (headerMatch) {
159
+ const file = headerMatch[1] ?? '';
160
+ return {
161
+ results: flushGroup(state.results, state.currentFile, state.currentLinks),
162
+ currentFile: file,
163
+ currentLinks: []
164
+ };
165
+ }
166
+ const linkMatch = linkPattern.exec(line);
167
+ if (linkMatch && state.currentFile) {
168
+ const link = linkMatch[1] ?? '';
169
+ return {
170
+ ...state,
171
+ currentLinks: [
172
+ ...state.currentLinks,
173
+ link
174
+ ]
175
+ };
176
+ }
177
+ return state;
178
+ }, {
179
+ results: [],
180
+ currentFile: null,
181
+ currentLinks: []
182
+ });
183
+ return flushGroup(acc.results, acc.currentFile, acc.currentLinks);
184
+ }
185
+ async function runBuildCheck(params) {
186
+ const { error, captured } = await captureOutput(()=>buildSiteForCheck({
187
+ config: params.config,
188
+ paths: params.paths
189
+ }));
190
+ if (error) {
191
+ const { repoRoot } = params.paths;
192
+ const deadlinks = parseDeadlinks(captured).map((info)=>({
193
+ file: node_path.relative(repoRoot, info.file),
194
+ links: info.links
195
+ }));
196
+ if (deadlinks.length > 0) return {
197
+ status: 'failed',
198
+ deadlinks
199
+ };
200
+ return {
201
+ status: 'error',
202
+ message: error.message
203
+ };
204
+ }
205
+ return {
206
+ status: 'passed'
207
+ };
208
+ }
209
+ const RED = '\u001B[31m';
210
+ const DIM = '\u001B[2m';
211
+ const RESET = '\u001B[0m';
212
+ function formatDeadlinkGroup(info) {
213
+ const header = ` ${RED}✖${RESET} ${info.file}`;
214
+ const links = info.links.map((link)=>` ${DIM}→${RESET} ${link}`);
215
+ return [
216
+ header,
217
+ ...links
218
+ ].join('\n');
219
+ }
220
+ function presentResults(params) {
221
+ const { configResult, buildResult, logger } = params;
222
+ if (configResult.passed) logger.success('Config valid');
223
+ else {
224
+ logger.error('Config validation failed:');
225
+ configResult.errors.map((err)=>{
226
+ logger.message(` ${err.message}`);
227
+ return null;
228
+ });
229
+ }
230
+ if ('passed' === buildResult.status) logger.success('No broken links');
231
+ else if ('skipped' === buildResult.status) ;
232
+ else if ('error' === buildResult.status) logger.error(`Build failed: ${buildResult.message}`);
233
+ else {
234
+ const totalLinks = buildResult.deadlinks.reduce((sum, info)=>sum + info.links.length, 0);
235
+ logger.error(`Found ${totalLinks} broken link(s):`);
236
+ const block = buildResult.deadlinks.map(formatDeadlinkGroup).join('\n');
237
+ logger.message(block);
238
+ }
239
+ return configResult.passed && 'passed' === buildResult.status;
240
+ }
69
241
  function cleanTargets(paths) {
70
242
  return [
71
243
  {
@@ -82,7 +254,7 @@ function cleanTargets(paths) {
82
254
  }
83
255
  ];
84
256
  }
85
- async function clean(paths) {
257
+ async function clean_clean(paths) {
86
258
  const results = await Promise.all(cleanTargets(paths).map(async ({ dir, label })=>{
87
259
  const exists = await promises.stat(dir).catch(()=>null);
88
260
  if (exists) {
@@ -101,7 +273,7 @@ const cleanCommand = command({
101
273
  handler: async (ctx)=>{
102
274
  const paths = createPaths(process.cwd());
103
275
  ctx.logger.intro('zpress clean');
104
- const removed = await clean(paths);
276
+ const removed = await clean_clean(paths);
105
277
  if (removed.length > 0) ctx.logger.success(`Removed: ${removed.join(', ')}`);
106
278
  else ctx.logger.info('Nothing to clean');
107
279
  ctx.logger.outro('Done');
@@ -111,14 +283,15 @@ const buildCommand = command({
111
283
  description: 'Run sync and build the Rspress site',
112
284
  args: z.object({
113
285
  quiet: z.boolean().optional().default(false),
114
- clean: z.boolean().optional().default(false)
286
+ clean: z.boolean().optional().default(false),
287
+ check: z.boolean().optional().default(true)
115
288
  }),
116
289
  handler: async (ctx)=>{
117
- const { quiet } = ctx.args;
290
+ const { quiet, check } = ctx.args;
118
291
  const paths = createPaths(process.cwd());
119
292
  ctx.logger.intro('zpress build');
120
293
  if (ctx.args.clean) {
121
- const removed = await clean(paths);
294
+ const removed = await clean_clean(paths);
122
295
  if (removed.length > 0 && !quiet) ctx.logger.info(`Cleaned: ${removed.join(', ')}`);
123
296
  }
124
297
  const [configErr, config] = await loadConfig(paths.repoRoot);
@@ -126,15 +299,89 @@ const buildCommand = command({
126
299
  ctx.logger.error(configErr.message);
127
300
  process.exit(1);
128
301
  }
129
- await sync(config, {
302
+ if (check) {
303
+ ctx.logger.step('Validating config...');
304
+ const configResult = runConfigCheck({
305
+ config,
306
+ loadError: configErr
307
+ });
308
+ ctx.logger.step('Syncing content...');
309
+ await sync(config, {
310
+ paths,
311
+ quiet: true
312
+ });
313
+ ctx.logger.step('Building & checking for broken links...');
314
+ const buildResult = await runBuildCheck({
315
+ config,
316
+ paths
317
+ });
318
+ const passed = presentResults({
319
+ configResult,
320
+ buildResult,
321
+ logger: ctx.logger
322
+ });
323
+ if (!passed) {
324
+ ctx.logger.outro('Build failed');
325
+ process.exit(1);
326
+ }
327
+ ctx.logger.outro('Done');
328
+ } else {
329
+ await sync(config, {
330
+ paths,
331
+ quiet
332
+ });
333
+ await buildSite({
334
+ config,
335
+ paths
336
+ });
337
+ ctx.logger.outro('Done');
338
+ }
339
+ }
340
+ });
341
+ const checkCommand = command({
342
+ description: 'Validate config and check for broken links',
343
+ handler: async (ctx)=>{
344
+ const paths = createPaths(process.cwd());
345
+ ctx.logger.intro('zpress check');
346
+ ctx.logger.step('Validating config...');
347
+ const [configErr, config] = await loadConfig(paths.repoRoot);
348
+ const configResult = runConfigCheck({
349
+ config,
350
+ loadError: configErr
351
+ });
352
+ if (configErr || !config) {
353
+ const buildResult = {
354
+ status: 'skipped'
355
+ };
356
+ presentResults({
357
+ configResult,
358
+ buildResult,
359
+ logger: ctx.logger
360
+ });
361
+ ctx.logger.outro('Checks failed');
362
+ process.exit(1);
363
+ }
364
+ ctx.logger.step('Syncing content...');
365
+ const syncResult = await sync(config, {
130
366
  paths,
131
- quiet
367
+ quiet: true
132
368
  });
133
- await buildSite({
369
+ ctx.logger.success(`Synced (${syncResult.pagesWritten} written, ${syncResult.pagesSkipped} unchanged)`);
370
+ ctx.logger.step('Checking for broken links...');
371
+ const buildResult = await runBuildCheck({
134
372
  config,
135
373
  paths
136
374
  });
137
- ctx.logger.outro('Done');
375
+ const passed = presentResults({
376
+ configResult,
377
+ buildResult,
378
+ logger: ctx.logger
379
+ });
380
+ if (passed) ctx.logger.outro('All checks passed');
381
+ else {
382
+ ctx.logger.outro('Checks failed');
383
+ process.exit(1);
384
+ }
138
385
  }
139
386
  });
140
387
  const devCommand = command({
@@ -148,7 +395,7 @@ const devCommand = command({
148
395
  const paths = createPaths(process.cwd());
149
396
  ctx.logger.intro('zpress dev');
150
397
  if (ctx.args.clean) {
151
- const removed = await clean(paths);
398
+ const removed = await clean_clean(paths);
152
399
  if (removed.length > 0 && !quiet) ctx.logger.info(`Cleaned: ${removed.join(', ')}`);
153
400
  }
154
401
  const [configErr, config] = await loadConfig(paths.repoRoot);
@@ -408,12 +655,13 @@ const syncCommand = command({
408
655
  });
409
656
  await cli({
410
657
  name: 'zpress',
411
- version: "0.1.4",
658
+ version: "0.2.0",
412
659
  description: 'CLI for building and serving documentation',
413
660
  commands: {
414
661
  sync: syncCommand,
415
662
  dev: devCommand,
416
663
  build: buildCommand,
664
+ check: checkCommand,
417
665
  serve: serveCommand,
418
666
  clean: cleanCommand,
419
667
  dump: dumpCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zpress/cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for building and serving zpress documentation sites",
5
5
  "keywords": [
6
6
  "cli",
@@ -40,8 +40,8 @@
40
40
  "es-toolkit": "^1.45.1",
41
41
  "ts-pattern": "^5.9.0",
42
42
  "zod": "^4.3.6",
43
- "@zpress/ui": "0.3.0",
44
- "@zpress/core": "0.3.0"
43
+ "@zpress/core": "0.4.0",
44
+ "@zpress/ui": "0.3.1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@rslib/core": "^0.20.0",