@zpress/cli 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/145.mjs ADDED
@@ -0,0 +1,1031 @@
1
+ import { createRequire } from "node:module";
2
+ import { cli, command } from "@kidd-cli/core";
3
+ import { configError, createPaths, generateAssets, hasGlobChars, loadConfig, loadManifest, normalizeInclude, resolveEntries, sync } from "@zpress/core";
4
+ import { z } from "zod";
5
+ import node_path from "node:path";
6
+ import { execFileSync, spawn } from "node:child_process";
7
+ import { once } from "node:events";
8
+ import { Server } from "node:http";
9
+ import { platform } from "node:os";
10
+ import { build, dev, serve } from "@rspress/core";
11
+ import { createRspressConfig } from "@zpress/ui";
12
+ import get_port, { portNumbers } from "get-port";
13
+ import { P, match as external_ts_pattern_match } from "ts-pattern";
14
+ import promises from "node:fs/promises";
15
+ import { compact, uniq } from "es-toolkit";
16
+ import { createRegistry, render, toSlug } from "@zpress/templates";
17
+ import node_fs from "node:fs";
18
+ globalThis.require = globalThis.require ?? createRequire(import.meta.url);
19
+ function toError(error) {
20
+ if (error instanceof Error) return error;
21
+ return new Error(String(error));
22
+ }
23
+ const DEV_PORT = 6174;
24
+ const DEV_PORT_RANGE = 5;
25
+ const SERVE_PORT = 8080;
26
+ async function startDevServer(options) {
27
+ const { paths } = options;
28
+ const preferred = options.port ?? DEV_PORT;
29
+ const port = await get_port({
30
+ port: portNumbers(preferred, preferred + DEV_PORT_RANGE)
31
+ });
32
+ let serverInstance = null;
33
+ async function startServer(config) {
34
+ const rspressConfig = createRspressConfig({
35
+ config,
36
+ paths,
37
+ vscode: options.vscode,
38
+ themeOverride: options.theme,
39
+ colorModeOverride: options.colorMode
40
+ });
41
+ try {
42
+ serverInstance = await dev({
43
+ appDirectory: paths.repoRoot,
44
+ docDirectory: paths.contentDir,
45
+ config: rspressConfig,
46
+ configFilePath: '',
47
+ extraBuilderConfig: {
48
+ server: {
49
+ port,
50
+ strictPort: true
51
+ }
52
+ }
53
+ });
54
+ return true;
55
+ } catch (error) {
56
+ process.stderr.write(`Dev server error: ${toError(error).message}\n`);
57
+ return false;
58
+ }
59
+ }
60
+ const started = await startServer(options.config);
61
+ if (!started) process.exit(1);
62
+ return async (newConfig)=>{
63
+ process.stdout.write('\nšŸ”„ Config changed — restarting dev server...\n');
64
+ if (serverInstance) {
65
+ const httpServer = getHttpServer(serverInstance);
66
+ const closeEvent = createCloseEvent(httpServer);
67
+ try {
68
+ await serverInstance.close();
69
+ } catch (error) {
70
+ process.stderr.write(`Error closing server: ${toError(error).message}\n`);
71
+ }
72
+ if (closeEvent) {
73
+ const PORT_RELEASE_TIMEOUT = 5000;
74
+ await Promise.race([
75
+ closeEvent,
76
+ new Promise((resolve)=>setTimeout(resolve, PORT_RELEASE_TIMEOUT))
77
+ ]);
78
+ }
79
+ serverInstance = null;
80
+ }
81
+ const restarted = await startServer(newConfig);
82
+ if (restarted) process.stdout.write('āœ… Dev server restarted\n\n');
83
+ else process.stderr.write('āš ļø Dev server failed to restart — fix the config and save again\n\n');
84
+ };
85
+ }
86
+ async function buildSite(options) {
87
+ const rspressConfig = createRspressConfig({
88
+ config: options.config,
89
+ paths: options.paths
90
+ });
91
+ await build({
92
+ docDirectory: options.paths.contentDir,
93
+ config: rspressConfig,
94
+ configFilePath: ''
95
+ });
96
+ }
97
+ async function buildSiteForCheck(options) {
98
+ const rspressConfig = createRspressConfig({
99
+ config: options.config,
100
+ paths: options.paths
101
+ });
102
+ await build({
103
+ docDirectory: options.paths.contentDir,
104
+ config: rspressConfig,
105
+ configFilePath: ''
106
+ });
107
+ }
108
+ async function serveSite(options) {
109
+ const rspressConfig = createRspressConfig({
110
+ config: options.config,
111
+ paths: options.paths,
112
+ vscode: options.vscode,
113
+ themeOverride: options.theme,
114
+ colorModeOverride: options.colorMode
115
+ });
116
+ const preferredPort = options.port ?? SERVE_PORT;
117
+ const port = await get_port({
118
+ port: portNumbers(preferredPort, preferredPort + DEV_PORT_RANGE)
119
+ });
120
+ await serve({
121
+ config: rspressConfig,
122
+ configFilePath: '',
123
+ port
124
+ });
125
+ return port;
126
+ }
127
+ function openBrowser(url) {
128
+ const os = platform();
129
+ const { cmd, args } = external_ts_pattern_match(os).with('darwin', ()=>({
130
+ cmd: 'open',
131
+ args: [
132
+ url
133
+ ]
134
+ })).with('win32', ()=>({
135
+ cmd: 'cmd',
136
+ args: [
137
+ '/c',
138
+ 'start',
139
+ url
140
+ ]
141
+ })).otherwise(()=>({
142
+ cmd: 'xdg-open',
143
+ args: [
144
+ url
145
+ ]
146
+ }));
147
+ spawn(cmd, args, {
148
+ stdio: 'ignore',
149
+ detached: true
150
+ }).unref();
151
+ }
152
+ function createCloseEvent(httpServer) {
153
+ if (null === httpServer) return null;
154
+ if (!httpServer.listening) return null;
155
+ return once(httpServer, 'close');
156
+ }
157
+ function getHttpServer(instance) {
158
+ const record = instance;
159
+ const value = record['httpServer'];
160
+ if (value instanceof Server) return value;
161
+ return null;
162
+ }
163
+ const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
164
+ const RED = '\u001B[31m';
165
+ const DIM = '\u001B[2m';
166
+ const RESET = '\u001B[0m';
167
+ function runConfigCheck(params) {
168
+ const { config, loadError } = params;
169
+ if (loadError) return {
170
+ passed: false,
171
+ errors: [
172
+ loadError
173
+ ]
174
+ };
175
+ if (!config) return {
176
+ passed: false,
177
+ errors: [
178
+ configError('empty_sections', 'Config is missing')
179
+ ]
180
+ };
181
+ return {
182
+ passed: true,
183
+ errors: []
184
+ };
185
+ }
186
+ async function runBuildCheck(params) {
187
+ const { error, captured } = await captureOutput(()=>buildSiteForCheck({
188
+ config: params.config,
189
+ paths: params.paths
190
+ }));
191
+ if (error) {
192
+ const { repoRoot } = params.paths;
193
+ const deadlinks = parseDeadlinks(captured).map((info)=>({
194
+ file: node_path.relative(repoRoot, info.file),
195
+ links: info.links
196
+ }));
197
+ if (deadlinks.length > 0) return {
198
+ status: 'failed',
199
+ deadlinks
200
+ };
201
+ return {
202
+ status: 'error',
203
+ message: error.message
204
+ };
205
+ }
206
+ return {
207
+ status: 'passed'
208
+ };
209
+ }
210
+ function presentResults(params) {
211
+ const { configResult, buildResult, logger } = params;
212
+ if (configResult.passed) logger.success('Config valid');
213
+ else {
214
+ logger.error('Config validation failed:');
215
+ configResult.errors.map((err)=>{
216
+ logger.message(` ${err.message}`);
217
+ return null;
218
+ });
219
+ }
220
+ if ('passed' === buildResult.status) logger.success('No broken links');
221
+ else if ('skipped' === buildResult.status) ;
222
+ else if ('error' === buildResult.status) logger.error(`Build failed: ${buildResult.message}`);
223
+ else {
224
+ const totalLinks = buildResult.deadlinks.reduce((sum, info)=>sum + info.links.length, 0);
225
+ logger.error(`Found ${totalLinks} broken link(s):`);
226
+ const block = buildResult.deadlinks.map(formatDeadlinkGroup).join('\n');
227
+ logger.message(block);
228
+ }
229
+ return configResult.passed && 'passed' === buildResult.status;
230
+ }
231
+ function stripAnsi(text) {
232
+ return text.replace(ANSI_PATTERN, '');
233
+ }
234
+ function chunkToString(chunk) {
235
+ if ('string' == typeof chunk) return chunk;
236
+ return Buffer.from(chunk).toString('utf8');
237
+ }
238
+ function createInterceptor(chunks) {
239
+ return function(chunk, encodingOrCb, maybeCb) {
240
+ const text = chunkToString(chunk);
241
+ chunks.push(text);
242
+ if ('function' == typeof encodingOrCb) encodingOrCb();
243
+ else if ('function' == typeof maybeCb) maybeCb();
244
+ return true;
245
+ };
246
+ }
247
+ async function captureOutput(fn) {
248
+ const chunks = [];
249
+ const originalStdoutWrite = process.stdout.write;
250
+ const originalStderrWrite = process.stderr.write;
251
+ process.stdout.write = createInterceptor(chunks);
252
+ process.stderr.write = createInterceptor(chunks);
253
+ try {
254
+ const result = await fn();
255
+ return {
256
+ result,
257
+ error: null,
258
+ captured: chunks.join('')
259
+ };
260
+ } catch (error) {
261
+ return {
262
+ result: null,
263
+ error: toError(error),
264
+ captured: chunks.join('')
265
+ };
266
+ } finally{
267
+ process.stdout.write = originalStdoutWrite;
268
+ process.stderr.write = originalStderrWrite;
269
+ }
270
+ }
271
+ function flushGroup(results, file, links) {
272
+ if (file && links.length > 0) return [
273
+ ...results,
274
+ {
275
+ file,
276
+ links
277
+ }
278
+ ];
279
+ return results;
280
+ }
281
+ function parseDeadlinks(stderr) {
282
+ const clean = stripAnsi(stderr);
283
+ const lines = clean.split('\n');
284
+ const headerPattern = /Dead links found in (.+?):\s*$/;
285
+ const linkPattern = /"\[\.\.]\(([^)]+)\)"/;
286
+ const acc = lines.reduce((state, line)=>{
287
+ const headerMatch = headerPattern.exec(line);
288
+ if (headerMatch) {
289
+ const file = headerMatch[1] ?? '';
290
+ return {
291
+ results: flushGroup(state.results, state.currentFile, state.currentLinks),
292
+ currentFile: file,
293
+ currentLinks: []
294
+ };
295
+ }
296
+ const linkMatch = linkPattern.exec(line);
297
+ if (linkMatch && state.currentFile) {
298
+ const link = linkMatch[1] ?? '';
299
+ return {
300
+ ...state,
301
+ currentLinks: [
302
+ ...state.currentLinks,
303
+ link
304
+ ]
305
+ };
306
+ }
307
+ return state;
308
+ }, {
309
+ results: [],
310
+ currentFile: null,
311
+ currentLinks: []
312
+ });
313
+ return flushGroup(acc.results, acc.currentFile, acc.currentLinks);
314
+ }
315
+ function formatDeadlinkGroup(info) {
316
+ const header = ` ${RED}āœ–${RESET} ${info.file}`;
317
+ const links = info.links.map((link)=>` ${DIM}→${RESET} ${link}`);
318
+ return [
319
+ header,
320
+ ...links
321
+ ].join('\n');
322
+ }
323
+ async function clean_clean(paths) {
324
+ const results = await Promise.all(cleanTargets(paths).map(async ({ dir, label })=>{
325
+ const exists = await promises.stat(dir).catch(()=>null);
326
+ if (exists) {
327
+ await promises.rm(dir, {
328
+ recursive: true,
329
+ force: true
330
+ });
331
+ return label;
332
+ }
333
+ return null;
334
+ }));
335
+ return compact(results);
336
+ }
337
+ const cleanCommand = command({
338
+ description: 'Remove build artifacts, synced content, and build cache',
339
+ handler: async (ctx)=>{
340
+ const paths = createPaths(process.cwd());
341
+ ctx.logger.intro('zpress clean');
342
+ const removed = await clean_clean(paths);
343
+ if (removed.length > 0) ctx.logger.success(`Removed: ${removed.join(', ')}`);
344
+ else ctx.logger.info('Nothing to clean');
345
+ ctx.logger.outro('Done');
346
+ }
347
+ });
348
+ function cleanTargets(paths) {
349
+ return [
350
+ {
351
+ dir: paths.cacheDir,
352
+ label: 'cache'
353
+ },
354
+ {
355
+ dir: paths.contentDir,
356
+ label: 'content'
357
+ },
358
+ {
359
+ dir: paths.distDir,
360
+ label: 'dist'
361
+ }
362
+ ];
363
+ }
364
+ const buildCommand = command({
365
+ description: 'Run sync and build the Rspress site',
366
+ options: z.object({
367
+ quiet: z.boolean().optional().default(false),
368
+ clean: z.boolean().optional().default(false),
369
+ check: z.boolean().optional().default(true)
370
+ }),
371
+ handler: async (ctx)=>{
372
+ const { quiet, check } = ctx.args;
373
+ const paths = createPaths(process.cwd());
374
+ ctx.logger.intro('zpress build');
375
+ if (ctx.args.clean) {
376
+ const removed = await clean_clean(paths);
377
+ if (removed.length > 0 && !quiet) ctx.logger.info(`Cleaned: ${removed.join(', ')}`);
378
+ }
379
+ const [configErr, config] = await loadConfig(paths.repoRoot);
380
+ if (configErr) {
381
+ ctx.logger.error(configErr.message);
382
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.map((err)=>{
383
+ const path = err.path.join('.');
384
+ return ctx.logger.error(` ${path}: ${err.message}`);
385
+ });
386
+ process.exit(1);
387
+ }
388
+ if (check) {
389
+ ctx.logger.step('Validating config...');
390
+ const configResult = runConfigCheck({
391
+ config,
392
+ loadError: configErr
393
+ });
394
+ ctx.logger.step('Syncing content...');
395
+ await sync(config, {
396
+ paths,
397
+ quiet: true
398
+ });
399
+ ctx.logger.step('Building & checking for broken links...');
400
+ const buildResult = await runBuildCheck({
401
+ config,
402
+ paths
403
+ });
404
+ const passed = presentResults({
405
+ configResult,
406
+ buildResult,
407
+ logger: ctx.logger
408
+ });
409
+ if (!passed) {
410
+ ctx.logger.outro('Build failed');
411
+ process.exit(1);
412
+ }
413
+ ctx.logger.outro('Done');
414
+ } else {
415
+ await sync(config, {
416
+ paths,
417
+ quiet
418
+ });
419
+ await buildSite({
420
+ config,
421
+ paths
422
+ });
423
+ ctx.logger.outro('Done');
424
+ }
425
+ }
426
+ });
427
+ const checkCommand = command({
428
+ description: 'Validate config and check for broken links',
429
+ handler: async (ctx)=>{
430
+ const paths = createPaths(process.cwd());
431
+ ctx.logger.intro('zpress check');
432
+ ctx.logger.step('Validating config...');
433
+ const [configErr, config] = await loadConfig(paths.repoRoot);
434
+ const configResult = runConfigCheck({
435
+ config,
436
+ loadError: configErr
437
+ });
438
+ if (configErr || !config) {
439
+ const buildResult = {
440
+ status: 'skipped'
441
+ };
442
+ presentResults({
443
+ configResult,
444
+ buildResult,
445
+ logger: ctx.logger
446
+ });
447
+ ctx.logger.outro('Checks failed');
448
+ process.exit(1);
449
+ }
450
+ ctx.logger.step('Syncing content...');
451
+ const syncResult = await sync(config, {
452
+ paths,
453
+ quiet: true
454
+ });
455
+ ctx.logger.success(`Synced (${syncResult.pagesWritten} written, ${syncResult.pagesSkipped} unchanged)`);
456
+ ctx.logger.step('Checking for broken links...');
457
+ const buildResult = await runBuildCheck({
458
+ config,
459
+ paths
460
+ });
461
+ const passed = presentResults({
462
+ configResult,
463
+ buildResult,
464
+ logger: ctx.logger
465
+ });
466
+ if (passed) ctx.logger.outro('All checks passed');
467
+ else {
468
+ ctx.logger.outro('Checks failed');
469
+ process.exit(1);
470
+ }
471
+ }
472
+ });
473
+ const devCommand = command({
474
+ description: 'Run sync + watcher and start Rspress dev server',
475
+ options: z.object({
476
+ quiet: z.boolean().optional().default(false),
477
+ clean: z.boolean().optional().default(false),
478
+ port: z.number().optional(),
479
+ theme: z.string().optional(),
480
+ colorMode: z.string().optional(),
481
+ vscode: z.boolean().optional().default(false)
482
+ }),
483
+ handler: async (ctx)=>{
484
+ const { quiet } = ctx.args;
485
+ const paths = createPaths(process.cwd());
486
+ ctx.logger.intro('zpress dev');
487
+ if (ctx.args.clean) {
488
+ const removed = await clean_clean(paths);
489
+ if (removed.length > 0 && !quiet) ctx.logger.info(`Cleaned: ${removed.join(', ')}`);
490
+ }
491
+ const [configErr, config] = await loadConfig(paths.repoRoot);
492
+ if (configErr) {
493
+ ctx.logger.error(configErr.message);
494
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.forEach((err)=>{
495
+ const path = err.path.join('.');
496
+ ctx.logger.error(` ${path}: ${err.message}`);
497
+ });
498
+ process.exit(1);
499
+ }
500
+ await sync(config, {
501
+ paths,
502
+ quiet
503
+ });
504
+ const onConfigReload = await startDevServer({
505
+ config,
506
+ paths,
507
+ port: ctx.args.port,
508
+ theme: ctx.args.theme,
509
+ colorMode: ctx.args.colorMode,
510
+ vscode: ctx.args.vscode
511
+ });
512
+ const { createWatcher } = await import("./watcher.mjs");
513
+ const watcher = createWatcher(config, paths, onConfigReload);
514
+ function cleanup() {
515
+ watcher.close();
516
+ }
517
+ process.on('SIGINT', cleanup);
518
+ process.on('SIGTERM', cleanup);
519
+ }
520
+ });
521
+ const CONFIG_GLOBS = [
522
+ 'zpress.config.ts',
523
+ 'zpress.config.mts',
524
+ 'zpress.config.cts',
525
+ 'zpress.config.js',
526
+ 'zpress.config.mjs',
527
+ 'zpress.config.cjs',
528
+ 'zpress.config.json'
529
+ ];
530
+ const diffCommand = command({
531
+ description: 'Show changed files in configured source directories',
532
+ options: z.object({
533
+ pretty: z.boolean().optional().default(false)
534
+ }),
535
+ handler: async (ctx)=>{
536
+ const { pretty } = ctx.args;
537
+ const paths = createPaths(process.cwd());
538
+ const [configErr, config] = await loadConfig(paths.repoRoot);
539
+ if (configErr) {
540
+ if (pretty) {
541
+ ctx.logger.intro('zpress diff');
542
+ ctx.logger.error(configErr.message);
543
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.forEach((err)=>{
544
+ const p = err.path.join('.');
545
+ ctx.logger.error(` ${p}: ${err.message}`);
546
+ });
547
+ }
548
+ process.exit(1);
549
+ }
550
+ const dirs = collectWatchPaths(config);
551
+ if (0 === dirs.length) {
552
+ if (pretty) {
553
+ ctx.logger.intro('zpress diff');
554
+ ctx.logger.warn('No source directories found in config');
555
+ ctx.logger.outro('Done');
556
+ }
557
+ return;
558
+ }
559
+ const [gitErr, changed] = gitChangedFiles({
560
+ repoRoot: paths.repoRoot,
561
+ dirs
562
+ });
563
+ if (gitErr) {
564
+ if (pretty) {
565
+ ctx.logger.intro('zpress diff');
566
+ ctx.logger.error(`Git failed: ${gitErr.message}`);
567
+ ctx.logger.outro('Done');
568
+ }
569
+ process.exit(1);
570
+ }
571
+ if (0 === changed.length) {
572
+ if (pretty) {
573
+ ctx.logger.intro('zpress diff');
574
+ ctx.logger.success('No changes detected');
575
+ ctx.logger.outro('Done');
576
+ }
577
+ return;
578
+ }
579
+ if (pretty) {
580
+ ctx.logger.intro('zpress diff');
581
+ ctx.logger.step(`Watching ${dirs.length} path(s)`);
582
+ ctx.logger.note(changed.join('\n'), `${changed.length} changed file(s)`);
583
+ ctx.logger.outro('Done');
584
+ return;
585
+ }
586
+ process.stdout.write(`${changed.join(' ')}\n`);
587
+ }
588
+ });
589
+ function collectWatchPaths(config) {
590
+ const dirs = flattenIncludePaths(config.sections);
591
+ return uniq([
592
+ ...dirs.map(toDirectory),
593
+ ...CONFIG_GLOBS
594
+ ]);
595
+ }
596
+ function flattenIncludePaths(sections) {
597
+ return sections.flatMap(flattenSection);
598
+ }
599
+ function flattenSection(section) {
600
+ const includes = normalizeInclude(section.include);
601
+ if (includes.length > 0 && section.items) return [
602
+ ...includes,
603
+ ...flattenIncludePaths(section.items)
604
+ ];
605
+ if (includes.length > 0) return includes;
606
+ if (section.items) return flattenIncludePaths(section.items);
607
+ return [];
608
+ }
609
+ function toDirectory(from) {
610
+ if (hasGlobChars(from)) {
611
+ const normalized = from.replaceAll('\\', '/');
612
+ const segments = normalized.split('/');
613
+ const dirSegments = segments.filter((s)=>!hasGlobChars(s));
614
+ return dirSegments.join('/') || '.';
615
+ }
616
+ return from;
617
+ }
618
+ const RENAME_SEPARATOR = ' -> ';
619
+ function gitChangedFiles(params) {
620
+ const [err, output] = execSilent({
621
+ file: 'git',
622
+ args: [
623
+ 'status',
624
+ '--short',
625
+ '--',
626
+ ...params.dirs
627
+ ],
628
+ cwd: params.repoRoot
629
+ });
630
+ if (err) return [
631
+ err,
632
+ null
633
+ ];
634
+ if (!output) return [
635
+ null,
636
+ []
637
+ ];
638
+ const files = output.split('\n').filter((line)=>line.length > 0).map(parseStatusLine).filter((p)=>p.length > 0);
639
+ return [
640
+ null,
641
+ files
642
+ ];
643
+ }
644
+ function parseStatusLine(line) {
645
+ const filePart = line.slice(3);
646
+ const renameIdx = filePart.indexOf(RENAME_SEPARATOR);
647
+ if (-1 !== renameIdx) return stripQuotes(filePart.slice(renameIdx + RENAME_SEPARATOR.length));
648
+ return stripQuotes(filePart);
649
+ }
650
+ function stripQuotes(value) {
651
+ const trimmed = value.trim();
652
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1);
653
+ return trimmed;
654
+ }
655
+ function execSilent(params) {
656
+ try {
657
+ const output = execFileSync(params.file, [
658
+ ...params.args
659
+ ], {
660
+ cwd: params.cwd,
661
+ stdio: 'pipe',
662
+ encoding: 'utf8'
663
+ }).trimEnd();
664
+ return [
665
+ null,
666
+ output
667
+ ];
668
+ } catch (error) {
669
+ if (error instanceof Error) return [
670
+ error,
671
+ null
672
+ ];
673
+ return [
674
+ new Error(String(error)),
675
+ null
676
+ ];
677
+ }
678
+ }
679
+ const registry = createRegistry();
680
+ const draftCommand = command({
681
+ description: 'Scaffold a new documentation file from a template',
682
+ options: z.object({
683
+ type: z.string().optional(),
684
+ title: z.string().optional(),
685
+ out: z.string().optional().default('.')
686
+ }),
687
+ handler: async (ctx)=>{
688
+ ctx.logger.intro('zpress draft');
689
+ const typeArg = ctx.args.type;
690
+ const hasValidType = external_ts_pattern_match(typeArg).with(P.string.minLength(1), (t)=>registry.has(t)).otherwise(()=>false);
691
+ const selectedType = await external_ts_pattern_match(hasValidType).with(true, ()=>Promise.resolve(typeArg)).otherwise(()=>ctx.prompts.select({
692
+ message: 'Select a doc type',
693
+ options: registry.list().map((t)=>({
694
+ value: t.type,
695
+ label: t.label,
696
+ hint: t.hint
697
+ }))
698
+ }));
699
+ const template = registry.get(selectedType);
700
+ if (!template) return void ctx.logger.error(`Unknown template type: ${selectedType}`);
701
+ const title = await external_ts_pattern_match(ctx.args.title).with(P.string.minLength(1), (t)=>Promise.resolve(t)).otherwise(()=>ctx.prompts.text({
702
+ message: 'Document title',
703
+ placeholder: 'e.g. Authentication',
704
+ validate: (value)=>{
705
+ if (!value || 0 === value.trim().length) return 'Title is required';
706
+ }
707
+ }));
708
+ const slug = toSlug(title);
709
+ if (0 === slug.length) return void ctx.logger.error('Title must include at least one letter or number');
710
+ const content = render(template, {
711
+ title
712
+ });
713
+ const filename = `${slug}.md`;
714
+ const outDir = node_path.resolve(process.cwd(), ctx.args.out);
715
+ const filePath = node_path.join(outDir, filename);
716
+ const exists = await promises.access(filePath).then(()=>true).catch(()=>false);
717
+ if (exists) return void ctx.logger.error(`File already exists: ${node_path.relative(process.cwd(), filePath)}`);
718
+ await promises.mkdir(outDir, {
719
+ recursive: true
720
+ });
721
+ await promises.writeFile(filePath, content, 'utf8');
722
+ ctx.logger.success(`Created ${node_path.relative(process.cwd(), filePath)}`);
723
+ ctx.logger.outro('Done');
724
+ }
725
+ });
726
+ const dumpCommand = command({
727
+ description: 'Resolve and print the full entry tree as JSON',
728
+ handler: async (ctx)=>{
729
+ const paths = createPaths(process.cwd());
730
+ const [configErr, config] = await loadConfig(paths.repoRoot);
731
+ if (configErr) {
732
+ ctx.logger.error(configErr.message);
733
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.map((err)=>{
734
+ const path = err.path.join('.');
735
+ return ctx.logger.error(` ${path}: ${err.message}`);
736
+ });
737
+ process.exit(1);
738
+ }
739
+ const previousManifest = await loadManifest(paths.contentDir);
740
+ const syncCtx = {
741
+ repoRoot: paths.repoRoot,
742
+ outDir: paths.contentDir,
743
+ config,
744
+ previousManifest,
745
+ manifest: {
746
+ files: {},
747
+ timestamp: Date.now()
748
+ },
749
+ quiet: true
750
+ };
751
+ const [resolveErr, resolved] = await resolveEntries(config.sections, syncCtx);
752
+ if (resolveErr) {
753
+ ctx.logger.error(resolveErr.message);
754
+ process.exit(1);
755
+ }
756
+ const tree = toTree(resolved);
757
+ process.stdout.write(`${JSON.stringify(tree, null, 2)}\n`);
758
+ }
759
+ });
760
+ function maybeLink(link) {
761
+ if (link) return {
762
+ link
763
+ };
764
+ return {};
765
+ }
766
+ function maybeCollapsible(collapsible) {
767
+ if (collapsible) return {
768
+ collapsible
769
+ };
770
+ return {};
771
+ }
772
+ function maybeHidden(hidden) {
773
+ if (hidden) return {
774
+ hidden
775
+ };
776
+ return {};
777
+ }
778
+ function maybeStandalone(standalone) {
779
+ if (standalone) return {
780
+ standalone
781
+ };
782
+ return {};
783
+ }
784
+ function maybeItems(items) {
785
+ if (items && items.length > 0) return {
786
+ items: toTree(items)
787
+ };
788
+ return {};
789
+ }
790
+ function toTree(entries) {
791
+ return entries.map(buildDumpEntry);
792
+ }
793
+ function buildDumpEntry(entry) {
794
+ return {
795
+ text: entry.title,
796
+ ...maybeLink(entry.link),
797
+ ...maybeCollapsible(entry.collapsible),
798
+ ...maybeHidden(entry.hidden),
799
+ ...maybeStandalone(entry.standalone),
800
+ ...maybeItems(entry.items)
801
+ };
802
+ }
803
+ const generateCommand = command({
804
+ description: 'Generate banner, logo, and icon SVG assets from project title',
805
+ handler: async (ctx)=>{
806
+ ctx.logger.intro('zpress generate');
807
+ const paths = createPaths(process.cwd());
808
+ const [configErr, config] = await loadConfig(paths.repoRoot);
809
+ if (configErr) {
810
+ ctx.logger.error(configErr.message);
811
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.map((err)=>{
812
+ const path = err.path.join('.');
813
+ return ctx.logger.error(` ${path}: ${err.message}`);
814
+ });
815
+ process.exit(1);
816
+ }
817
+ const assetConfig = buildAssetConfig(config);
818
+ if (!assetConfig) {
819
+ ctx.logger.warn('No title configured — skipping asset generation');
820
+ ctx.logger.outro('Done');
821
+ return;
822
+ }
823
+ await promises.mkdir(paths.publicDir, {
824
+ recursive: true
825
+ });
826
+ const [err, written] = await generateAssets({
827
+ config: assetConfig,
828
+ publicDir: paths.publicDir
829
+ });
830
+ if (err) {
831
+ ctx.logger.error(err.message);
832
+ ctx.logger.outro('Failed');
833
+ return;
834
+ }
835
+ if (0 === written.length) ctx.logger.info('All assets are user-customized — nothing to generate');
836
+ else ctx.logger.success(`Generated ${written.join(', ')}`);
837
+ ctx.logger.outro('Done');
838
+ }
839
+ });
840
+ function buildAssetConfig(config) {
841
+ if (!config.title) return null;
842
+ return {
843
+ title: config.title,
844
+ tagline: config.tagline
845
+ };
846
+ }
847
+ const serveCommand = command({
848
+ description: 'Preview the built Rspress site',
849
+ options: z.object({
850
+ open: z.boolean().optional().default(true),
851
+ port: z.number().optional(),
852
+ theme: z.string().optional(),
853
+ colorMode: z.string().optional(),
854
+ vscode: z.boolean().optional().default(false)
855
+ }),
856
+ handler: async (ctx)=>{
857
+ ctx.logger.intro('zpress serve');
858
+ const paths = createPaths(process.cwd());
859
+ const [configErr, config] = await loadConfig(paths.repoRoot);
860
+ if (configErr) {
861
+ ctx.logger.error(configErr.message);
862
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.map((err)=>{
863
+ const path = err.path.join('.');
864
+ return ctx.logger.error(` ${path}: ${err.message}`);
865
+ });
866
+ process.exit(1);
867
+ }
868
+ const port = await serveSite({
869
+ config,
870
+ paths,
871
+ port: ctx.args.port,
872
+ theme: ctx.args.theme,
873
+ colorMode: ctx.args.colorMode,
874
+ vscode: ctx.args.vscode
875
+ });
876
+ if (ctx.args.open) openBrowser(`http://localhost:${port}`);
877
+ }
878
+ });
879
+ const CONFIG_FILENAME = 'zpress.config.ts';
880
+ const setupCommand = command({
881
+ description: 'Initialize a zpress config in the current project',
882
+ handler: async (ctx)=>{
883
+ const cwd = process.cwd();
884
+ const paths = createPaths(cwd);
885
+ const configPath = node_path.join(paths.repoRoot, CONFIG_FILENAME);
886
+ ctx.logger.intro('zpress setup');
887
+ if (node_fs.existsSync(configPath)) {
888
+ ctx.logger.warn(`${CONFIG_FILENAME} already exists — skipping`);
889
+ ctx.logger.outro('Done');
890
+ return;
891
+ }
892
+ const title = deriveTitle(cwd);
893
+ node_fs.writeFileSync(configPath, buildConfigTemplate(title), 'utf8');
894
+ ctx.logger.success(`Created ${CONFIG_FILENAME} (title: "${title}")`);
895
+ await ensureGitignore(paths, ctx.logger);
896
+ await promises.mkdir(paths.publicDir, {
897
+ recursive: true
898
+ });
899
+ const [assetErr, written] = await generateAssets({
900
+ config: {
901
+ title,
902
+ tagline: void 0
903
+ },
904
+ publicDir: paths.publicDir
905
+ });
906
+ if (assetErr) {
907
+ ctx.logger.error(`Asset generation failed: ${assetErr.message}`);
908
+ process.exit(1);
909
+ }
910
+ if (written.length > 0) ctx.logger.success(`Generated ${written.join(', ')}`);
911
+ ctx.logger.outro('Done');
912
+ }
913
+ });
914
+ function extractGitRepoName(cwd) {
915
+ const url = setup_execSilent('git', [
916
+ 'remote',
917
+ 'get-url',
918
+ 'origin'
919
+ ], cwd);
920
+ if (!url) return null;
921
+ const match = url.match(/[/:]([^/:]+?)(?:\.git)?$/);
922
+ if (!match) return null;
923
+ return match[1];
924
+ }
925
+ function setup_execSilent(file, args, cwd) {
926
+ try {
927
+ return execFileSync(file, [
928
+ ...args
929
+ ], {
930
+ cwd,
931
+ stdio: 'pipe',
932
+ encoding: 'utf8'
933
+ }).trim();
934
+ } catch {
935
+ return null;
936
+ }
937
+ }
938
+ function deriveTitle(cwd) {
939
+ const repoName = extractGitRepoName(cwd);
940
+ if (repoName) return repoName;
941
+ return node_path.basename(cwd);
942
+ }
943
+ function buildConfigTemplate(title) {
944
+ const escaped = title.replaceAll("'", String.raw`\'`);
945
+ return `import { defineConfig } from '@zpress/kit'
946
+
947
+ export default defineConfig({
948
+ title: '${escaped}',
949
+ sections: [
950
+ {
951
+ title: 'Getting Started',
952
+ path: '/getting-started',
953
+ include: 'docs/*.md',
954
+ },
955
+ ],
956
+ })
957
+ `;
958
+ }
959
+ const ZPRESS_GITIGNORE_ENTRY = '.zpress/';
960
+ const NESTED_GITIGNORE_CONTENT = `# managed by zpress — ignore everything by default
961
+ *
962
+
963
+ # to track custom assets (e.g. banners, logos), uncomment the following lines:
964
+ # !public/
965
+ # !public/**
966
+ `;
967
+ async function ensureGitignore(paths, logger) {
968
+ const rootGitignore = node_path.join(paths.repoRoot, '.gitignore');
969
+ if (node_fs.existsSync(rootGitignore)) {
970
+ const content = node_fs.readFileSync(rootGitignore, 'utf8');
971
+ const lines = content.split('\n');
972
+ const alreadyIgnored = lines.some((line)=>line.trim() === ZPRESS_GITIGNORE_ENTRY || '.zpress' === line.trim());
973
+ if (alreadyIgnored) return;
974
+ const suffix = (()=>{
975
+ if (content.endsWith('\n')) return '';
976
+ return '\n';
977
+ })();
978
+ node_fs.writeFileSync(rootGitignore, `${content}${suffix}\n# zpress\n${ZPRESS_GITIGNORE_ENTRY}\n`, 'utf8');
979
+ logger.success('Added .zpress/ to .gitignore');
980
+ return;
981
+ }
982
+ await promises.mkdir(paths.outputRoot, {
983
+ recursive: true
984
+ });
985
+ await promises.writeFile(node_path.join(paths.outputRoot, '.gitignore'), NESTED_GITIGNORE_CONTENT, 'utf8');
986
+ logger.success('Created .zpress/.gitignore');
987
+ }
988
+ const syncCommand = command({
989
+ description: 'Sync documentation sources into .zpress/',
990
+ options: z.object({
991
+ quiet: z.boolean().optional().default(false)
992
+ }),
993
+ handler: async (ctx)=>{
994
+ const { quiet } = ctx.args;
995
+ const paths = createPaths(process.cwd());
996
+ if (!quiet) ctx.logger.intro('zpress sync');
997
+ const [configErr, config] = await loadConfig(paths.repoRoot);
998
+ if (configErr) {
999
+ ctx.logger.error(configErr.message);
1000
+ if (configErr.errors && configErr.errors.length > 0) configErr.errors.map((err)=>{
1001
+ const path = err.path.join('.');
1002
+ return ctx.logger.error(` ${path}: ${err.message}`);
1003
+ });
1004
+ process.exit(1);
1005
+ }
1006
+ await sync(config, {
1007
+ paths,
1008
+ quiet
1009
+ });
1010
+ if (!quiet) ctx.logger.outro('Done');
1011
+ }
1012
+ });
1013
+ await cli({
1014
+ name: 'zpress',
1015
+ version: "0.4.0",
1016
+ description: 'CLI for building and serving documentation',
1017
+ commands: {
1018
+ sync: syncCommand,
1019
+ dev: devCommand,
1020
+ diff: diffCommand,
1021
+ build: buildCommand,
1022
+ check: checkCommand,
1023
+ draft: draftCommand,
1024
+ serve: serveCommand,
1025
+ clean: cleanCommand,
1026
+ dump: dumpCommand,
1027
+ setup: setupCommand,
1028
+ generate: generateCommand
1029
+ }
1030
+ });
1031
+ export { toError };