@zpress/cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Joggr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.mjs ADDED
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env node
2
+ import { cli, command } from "@kidd-cli/core";
3
+ import { createPaths, generateAssets, loadConfig, loadManifest, resolveEntries, sync } from "@zpress/core";
4
+ import { z } from "zod";
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import { platform } from "node:os";
7
+ import { build, dev, serve } from "@rspress/core";
8
+ import { createRspressConfig } from "@zpress/ui";
9
+ import { match as external_ts_pattern_match } from "ts-pattern";
10
+ import promises from "node:fs/promises";
11
+ import node_fs from "node:fs";
12
+ import node_path from "node:path";
13
+ const DEFAULT_PORT = 6174;
14
+ async function startDevServer(options) {
15
+ const rspressConfig = createRspressConfig(options);
16
+ await dev({
17
+ appDirectory: options.paths.repoRoot,
18
+ docDirectory: options.paths.contentDir,
19
+ config: rspressConfig,
20
+ configFilePath: '',
21
+ extraBuilderConfig: {
22
+ server: {
23
+ port: DEFAULT_PORT
24
+ }
25
+ }
26
+ });
27
+ }
28
+ async function buildSite(options) {
29
+ const rspressConfig = createRspressConfig(options);
30
+ await build({
31
+ docDirectory: options.paths.contentDir,
32
+ config: rspressConfig,
33
+ configFilePath: ''
34
+ });
35
+ }
36
+ async function serveSite(options) {
37
+ const rspressConfig = createRspressConfig(options);
38
+ await serve({
39
+ config: rspressConfig,
40
+ configFilePath: '',
41
+ port: DEFAULT_PORT
42
+ });
43
+ }
44
+ function openBrowser(url) {
45
+ const os = platform();
46
+ const { cmd, args } = external_ts_pattern_match(os).with('darwin', ()=>({
47
+ cmd: 'open',
48
+ args: [
49
+ url
50
+ ]
51
+ })).with('win32', ()=>({
52
+ cmd: 'cmd',
53
+ args: [
54
+ '/c',
55
+ 'start',
56
+ url
57
+ ]
58
+ })).otherwise(()=>({
59
+ cmd: 'xdg-open',
60
+ args: [
61
+ url
62
+ ]
63
+ }));
64
+ spawn(cmd, args, {
65
+ stdio: 'ignore',
66
+ detached: true
67
+ }).unref();
68
+ }
69
+ function cleanTargets(paths) {
70
+ return [
71
+ {
72
+ dir: paths.cacheDir,
73
+ label: 'cache'
74
+ },
75
+ {
76
+ dir: paths.contentDir,
77
+ label: 'content'
78
+ },
79
+ {
80
+ dir: paths.distDir,
81
+ label: 'dist'
82
+ }
83
+ ];
84
+ }
85
+ async function clean(paths) {
86
+ const results = await Promise.all(cleanTargets(paths).map(async ({ dir, label })=>{
87
+ const exists = await promises.stat(dir).catch(()=>null);
88
+ if (exists) {
89
+ await promises.rm(dir, {
90
+ recursive: true,
91
+ force: true
92
+ });
93
+ return label;
94
+ }
95
+ return null;
96
+ }));
97
+ return results.filter((label)=>null !== label);
98
+ }
99
+ const cleanCommand = command({
100
+ description: 'Remove build artifacts, synced content, and build cache',
101
+ handler: async (ctx)=>{
102
+ const paths = createPaths(process.cwd());
103
+ ctx.logger.intro('zpress clean');
104
+ const removed = await clean(paths);
105
+ if (removed.length > 0) ctx.logger.success(`Removed: ${removed.join(', ')}`);
106
+ else ctx.logger.info('Nothing to clean');
107
+ ctx.logger.outro('Done');
108
+ }
109
+ });
110
+ const buildCommand = command({
111
+ description: 'Run sync and build the Rspress site',
112
+ args: z.object({
113
+ quiet: z.boolean().optional().default(false),
114
+ clean: z.boolean().optional().default(false)
115
+ }),
116
+ handler: async (ctx)=>{
117
+ const { quiet } = ctx.args;
118
+ const paths = createPaths(process.cwd());
119
+ ctx.logger.intro('zpress build');
120
+ if (ctx.args.clean) {
121
+ const removed = await clean(paths);
122
+ if (removed.length > 0 && !quiet) ctx.logger.info(`Cleaned: ${removed.join(', ')}`);
123
+ }
124
+ const [configErr, config] = await loadConfig(paths.repoRoot);
125
+ if (configErr) {
126
+ ctx.logger.error(configErr.message);
127
+ process.exit(1);
128
+ }
129
+ await sync(config, {
130
+ paths,
131
+ quiet
132
+ });
133
+ await buildSite({
134
+ config,
135
+ paths
136
+ });
137
+ ctx.logger.outro('Done');
138
+ }
139
+ });
140
+ const devCommand = command({
141
+ description: 'Run sync + watcher and start Rspress dev server',
142
+ args: z.object({
143
+ quiet: z.boolean().optional().default(false),
144
+ clean: z.boolean().optional().default(false)
145
+ }),
146
+ handler: async (ctx)=>{
147
+ const { quiet } = ctx.args;
148
+ const paths = createPaths(process.cwd());
149
+ ctx.logger.intro('zpress dev');
150
+ if (ctx.args.clean) {
151
+ const removed = await clean(paths);
152
+ if (removed.length > 0 && !quiet) ctx.logger.info(`Cleaned: ${removed.join(', ')}`);
153
+ }
154
+ const [configErr, config] = await loadConfig(paths.repoRoot);
155
+ if (configErr) {
156
+ ctx.logger.error(configErr.message);
157
+ process.exit(1);
158
+ }
159
+ await sync(config, {
160
+ paths,
161
+ quiet
162
+ });
163
+ const { createWatcher } = await import("./watcher.mjs");
164
+ const watcher = createWatcher(config, paths);
165
+ function cleanup() {
166
+ if (watcher) watcher.close();
167
+ }
168
+ process.on('SIGINT', cleanup);
169
+ process.on('SIGTERM', cleanup);
170
+ await startDevServer({
171
+ config,
172
+ paths
173
+ });
174
+ }
175
+ });
176
+ function maybeLink(link) {
177
+ if (link) return {
178
+ link
179
+ };
180
+ return {};
181
+ }
182
+ function maybeCollapsible(collapsible) {
183
+ if (collapsible) return {
184
+ collapsible
185
+ };
186
+ return {};
187
+ }
188
+ function maybeHidden(hidden) {
189
+ if (hidden) return {
190
+ hidden
191
+ };
192
+ return {};
193
+ }
194
+ function maybeIsolated(isolated) {
195
+ if (isolated) return {
196
+ isolated
197
+ };
198
+ return {};
199
+ }
200
+ function maybeItems(items) {
201
+ if (items && items.length > 0) return {
202
+ items: toTree(items)
203
+ };
204
+ return {};
205
+ }
206
+ function toTree(entries) {
207
+ return entries.map(buildDumpEntry);
208
+ }
209
+ function buildDumpEntry(entry) {
210
+ return {
211
+ text: entry.text,
212
+ ...maybeLink(entry.link),
213
+ ...maybeCollapsible(entry.collapsible),
214
+ ...maybeHidden(entry.hidden),
215
+ ...maybeIsolated(entry.isolated),
216
+ ...maybeItems(entry.items)
217
+ };
218
+ }
219
+ const dumpCommand = command({
220
+ description: 'Resolve and print the full entry tree as JSON',
221
+ handler: async (ctx)=>{
222
+ const paths = createPaths(process.cwd());
223
+ const [configErr, config] = await loadConfig(paths.repoRoot);
224
+ if (configErr) {
225
+ ctx.logger.error(configErr.message);
226
+ process.exit(1);
227
+ }
228
+ const previousManifest = await loadManifest(paths.contentDir);
229
+ const syncCtx = {
230
+ repoRoot: paths.repoRoot,
231
+ outDir: paths.contentDir,
232
+ config,
233
+ previousManifest,
234
+ manifest: {
235
+ files: {},
236
+ timestamp: Date.now()
237
+ },
238
+ quiet: true
239
+ };
240
+ const [resolveErr, resolved] = await resolveEntries(config.sections, syncCtx);
241
+ if (resolveErr) {
242
+ ctx.logger.error(resolveErr.message);
243
+ process.exit(1);
244
+ }
245
+ const tree = toTree(resolved);
246
+ ctx.output.raw(`${JSON.stringify(tree, null, 2)}\n`);
247
+ }
248
+ });
249
+ function buildAssetConfig(config) {
250
+ if (!config.title) return null;
251
+ return {
252
+ title: config.title,
253
+ tagline: config.tagline
254
+ };
255
+ }
256
+ const generateCommand = command({
257
+ description: 'Generate banner and logo SVG assets from project title',
258
+ handler: async (ctx)=>{
259
+ ctx.logger.intro('zpress generate');
260
+ const paths = createPaths(process.cwd());
261
+ const [configErr, config] = await loadConfig(paths.repoRoot);
262
+ if (configErr) {
263
+ ctx.logger.error(configErr.message);
264
+ process.exit(1);
265
+ }
266
+ const assetConfig = buildAssetConfig(config);
267
+ if (!assetConfig) {
268
+ ctx.logger.warn('No title configured — skipping asset generation');
269
+ ctx.logger.outro('Done');
270
+ return;
271
+ }
272
+ await promises.mkdir(paths.publicDir, {
273
+ recursive: true
274
+ });
275
+ const [err, written] = await generateAssets({
276
+ config: assetConfig,
277
+ publicDir: paths.publicDir
278
+ });
279
+ if (err) {
280
+ ctx.logger.error(err.message);
281
+ ctx.logger.outro('Failed');
282
+ return;
283
+ }
284
+ if (0 === written.length) ctx.logger.info('All assets are user-customized — nothing to generate');
285
+ else ctx.logger.success(`Generated ${written.join(', ')}`);
286
+ ctx.logger.outro('Done');
287
+ }
288
+ });
289
+ const serveCommand = command({
290
+ description: 'Preview the built Rspress site',
291
+ args: z.object({
292
+ open: z.boolean().optional().default(true)
293
+ }),
294
+ handler: async (ctx)=>{
295
+ ctx.logger.intro('zpress serve');
296
+ const paths = createPaths(process.cwd());
297
+ const [configErr, config] = await loadConfig(paths.repoRoot);
298
+ if (configErr) {
299
+ ctx.logger.error(configErr.message);
300
+ process.exit(1);
301
+ }
302
+ if (ctx.args.open) setTimeout(()=>openBrowser(`http://localhost:${DEFAULT_PORT}`), 2000);
303
+ await serveSite({
304
+ config,
305
+ paths
306
+ });
307
+ }
308
+ });
309
+ const CONFIG_FILENAME = 'zpress.config.ts';
310
+ function extractGitRepoName(cwd) {
311
+ const url = execSilent('git', [
312
+ 'remote',
313
+ 'get-url',
314
+ 'origin'
315
+ ], cwd);
316
+ if (!url) return null;
317
+ const match = url.match(/[/:]([^/:]+?)(?:\.git)?$/);
318
+ if (!match) return null;
319
+ return match[1];
320
+ }
321
+ function execSilent(file, args, cwd) {
322
+ try {
323
+ return execFileSync(file, [
324
+ ...args
325
+ ], {
326
+ cwd,
327
+ stdio: 'pipe',
328
+ encoding: 'utf8'
329
+ }).trim();
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+ function deriveTitle(cwd) {
335
+ const repoName = extractGitRepoName(cwd);
336
+ if (repoName) return repoName;
337
+ return node_path.basename(cwd);
338
+ }
339
+ function buildConfigTemplate(title) {
340
+ const escaped = title.replaceAll("'", String.raw`\'`);
341
+ return `import { defineConfig } from 'zpress'
342
+
343
+ export default defineConfig({
344
+ title: '${escaped}',
345
+ sections: [
346
+ {
347
+ text: 'Getting Started',
348
+ prefix: '/getting-started',
349
+ from: 'docs/*.md',
350
+ },
351
+ ],
352
+ })
353
+ `;
354
+ }
355
+ const setupCommand = command({
356
+ description: 'Initialize a zpress config in the current project',
357
+ handler: async (ctx)=>{
358
+ const cwd = process.cwd();
359
+ const paths = createPaths(cwd);
360
+ const configPath = node_path.join(paths.repoRoot, CONFIG_FILENAME);
361
+ ctx.logger.intro('zpress setup');
362
+ if (node_fs.existsSync(configPath)) {
363
+ ctx.logger.warn(`${CONFIG_FILENAME} already exists — skipping`);
364
+ ctx.logger.outro('Done');
365
+ return;
366
+ }
367
+ const title = deriveTitle(cwd);
368
+ node_fs.writeFileSync(configPath, buildConfigTemplate(title), 'utf8');
369
+ ctx.logger.success(`Created ${CONFIG_FILENAME} (title: "${title}")`);
370
+ await promises.mkdir(paths.publicDir, {
371
+ recursive: true
372
+ });
373
+ const [assetErr, written] = await generateAssets({
374
+ config: {
375
+ title,
376
+ tagline: void 0
377
+ },
378
+ publicDir: paths.publicDir
379
+ });
380
+ if (assetErr) {
381
+ ctx.logger.error(`Asset generation failed: ${assetErr.message}`);
382
+ process.exit(1);
383
+ }
384
+ if (written.length > 0) ctx.logger.success(`Generated ${written.join(', ')}`);
385
+ ctx.logger.outro('Done');
386
+ }
387
+ });
388
+ const syncCommand = command({
389
+ description: 'Sync documentation sources into .zpress/',
390
+ args: z.object({
391
+ quiet: z.boolean().optional().default(false)
392
+ }),
393
+ handler: async (ctx)=>{
394
+ const { quiet } = ctx.args;
395
+ const paths = createPaths(process.cwd());
396
+ if (!quiet) ctx.logger.intro('zpress sync');
397
+ const [configErr, config] = await loadConfig(paths.repoRoot);
398
+ if (configErr) {
399
+ ctx.logger.error(configErr.message);
400
+ process.exit(1);
401
+ }
402
+ await sync(config, {
403
+ paths,
404
+ quiet
405
+ });
406
+ if (!quiet) ctx.logger.outro('Done');
407
+ }
408
+ });
409
+ await cli({
410
+ name: 'zpress',
411
+ version: "0.1.0",
412
+ description: 'CLI for building and serving documentation',
413
+ commands: {
414
+ sync: syncCommand,
415
+ dev: devCommand,
416
+ build: buildCommand,
417
+ serve: serveCommand,
418
+ clean: cleanCommand,
419
+ dump: dumpCommand,
420
+ setup: setupCommand,
421
+ generate: generateCommand
422
+ }
423
+ });
@@ -0,0 +1,153 @@
1
+ import { existsSync } from "node:fs";
2
+ import node_path from "node:path";
3
+ import { cliLogger } from "@kidd-cli/core/logger";
4
+ import { hasGlobChars, loadConfig, sync } from "@zpress/core";
5
+ import { watch } from "chokidar";
6
+ import { debounce } from "es-toolkit";
7
+ import { match } from "ts-pattern";
8
+ const CONFIG_EXTENSIONS = [
9
+ '.ts',
10
+ '.mts',
11
+ '.cts',
12
+ '.js',
13
+ '.mjs',
14
+ '.cjs',
15
+ '.json'
16
+ ];
17
+ const MARKDOWN_EXTENSIONS = [
18
+ '.md',
19
+ '.mdx'
20
+ ];
21
+ function isMarkdownFile(filePath) {
22
+ return MARKDOWN_EXTENSIONS.some((ext)=>filePath.endsWith(ext));
23
+ }
24
+ function createWatcher(initialConfig, paths) {
25
+ const { repoRoot } = paths;
26
+ const configFiles = CONFIG_EXTENSIONS.map((ext)=>node_path.resolve(repoRoot, `zpress.config${ext}`));
27
+ let config = initialConfig;
28
+ const planningDir = node_path.resolve(repoRoot, '.planning');
29
+ const watchPaths = [
30
+ ...extractWatchPaths(config.sections, repoRoot),
31
+ ...(()=>{
32
+ if (existsSync(planningDir)) return [
33
+ planningDir
34
+ ];
35
+ return [];
36
+ })(),
37
+ ...configFiles
38
+ ];
39
+ if (0 === watchPaths.length) return void cliLogger.warn('No source paths to watch');
40
+ cliLogger.info(`Watching ${watchPaths.length} paths: ${watchPaths.map((p)=>node_path.relative(repoRoot, p)).join(', ')}`);
41
+ let syncing = false;
42
+ let pendingReloadConfig = null;
43
+ let consecutiveFailures = 0;
44
+ const MAX_CONSECUTIVE_FAILURES = 5;
45
+ const watcher = watch(watchPaths, {
46
+ ignoreInitial: true,
47
+ ignored: [
48
+ '**/node_modules/**',
49
+ '**/.git/**',
50
+ '**/.zpress/**',
51
+ '**/bundle/**'
52
+ ],
53
+ awaitWriteFinish: {
54
+ stabilityThreshold: 100,
55
+ pollInterval: 50
56
+ }
57
+ });
58
+ async function triggerSync(reloadConfig) {
59
+ if (syncing) {
60
+ pendingReloadConfig = true === pendingReloadConfig || reloadConfig;
61
+ return;
62
+ }
63
+ syncing = true;
64
+ try {
65
+ if (reloadConfig) {
66
+ const [configErr, newConfig] = await loadConfig(paths.repoRoot);
67
+ if (configErr) return void cliLogger.error(`Config reload failed: ${configErr.message}`);
68
+ config = newConfig;
69
+ cliLogger.info('Config reloaded');
70
+ }
71
+ await sync(config, {
72
+ paths
73
+ });
74
+ consecutiveFailures = 0;
75
+ } catch (error) {
76
+ consecutiveFailures += 1;
77
+ const errorMessage = (()=>{
78
+ if (error instanceof Error) return error.message;
79
+ return String(error);
80
+ })();
81
+ cliLogger.error(`Sync error: ${errorMessage}`);
82
+ } finally{
83
+ syncing = false;
84
+ if (null !== pendingReloadConfig) if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
85
+ cliLogger.error(`Sync failed ${consecutiveFailures} consecutive times, dropping pending resync. Will retry on next file change.`);
86
+ pendingReloadConfig = null;
87
+ consecutiveFailures = 0;
88
+ } else {
89
+ const shouldReload = pendingReloadConfig;
90
+ pendingReloadConfig = null;
91
+ triggerSync(shouldReload);
92
+ }
93
+ }
94
+ }
95
+ const debouncedSync = debounce(()=>triggerSync(false), 150);
96
+ const debouncedConfigSync = debounce(()=>triggerSync(true), 150);
97
+ const configFileSet = new Set(configFiles);
98
+ function isConfigFile(filePath) {
99
+ return configFileSet.has(node_path.resolve(filePath));
100
+ }
101
+ watcher.on('change', (filePath)=>{
102
+ if (isConfigFile(filePath)) {
103
+ cliLogger.info(`Config changed: ${node_path.basename(filePath)}`);
104
+ debouncedConfigSync();
105
+ return;
106
+ }
107
+ if (!isMarkdownFile(filePath)) return;
108
+ cliLogger.step(`Changed: ${node_path.relative(repoRoot, filePath)}`);
109
+ debouncedSync();
110
+ });
111
+ watcher.on('add', (filePath)=>{
112
+ if (!isMarkdownFile(filePath)) return;
113
+ cliLogger.step(`Added: ${node_path.relative(repoRoot, filePath)}`);
114
+ debouncedSync();
115
+ });
116
+ watcher.on('unlink', (filePath)=>{
117
+ if (!isMarkdownFile(filePath)) return;
118
+ cliLogger.step(`Removed: ${node_path.relative(repoRoot, filePath)}`);
119
+ debouncedSync();
120
+ });
121
+ return watcher;
122
+ }
123
+ function extractWatchPaths(entries, repoRoot) {
124
+ const dirs = new Set();
125
+ const files = new Set();
126
+ function walk(items) {
127
+ items.map((entry)=>{
128
+ if (entry.from) if (hasGlobChars(entry.from)) {
129
+ const [beforeGlob] = entry.from.split('*');
130
+ const dir = match(beforeGlob.endsWith('/')).with(true, ()=>beforeGlob.slice(0, -1)).otherwise(()=>node_path.dirname(beforeGlob));
131
+ dirs.add(node_path.resolve(repoRoot, dir));
132
+ } else files.add(node_path.resolve(repoRoot, entry.from));
133
+ if (entry.items) walk(entry.items);
134
+ return null;
135
+ });
136
+ }
137
+ walk(entries);
138
+ const sortedDirs = [
139
+ ...dirs
140
+ ].toSorted();
141
+ const dedupedDirs = sortedDirs.filter((dir, index)=>{
142
+ const previousDirs = sortedDirs.slice(0, index);
143
+ return !previousDirs.some((parent)=>dir.startsWith(`${parent}${node_path.sep}`));
144
+ });
145
+ const extraFiles = [
146
+ ...files
147
+ ].filter((file)=>!dedupedDirs.some((dir)=>file.startsWith(dir + node_path.sep)));
148
+ return [
149
+ ...dedupedDirs,
150
+ ...extraFiles
151
+ ];
152
+ }
153
+ export { createWatcher };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@zpress/cli",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/joggrdocs/zpress.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "bin": {
11
+ "zpress": "./dist/index.mjs"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./src/index.ts",
20
+ "import": "./dist/index.mjs"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@kidd-cli/core": "^0.2.0",
28
+ "@rspress/core": "2.0.5",
29
+ "chokidar": "^5.0.0",
30
+ "es-toolkit": "^1.45.1",
31
+ "ts-pattern": "^5.9.0",
32
+ "zod": "^4.3.6",
33
+ "@zpress/core": "0.1.0",
34
+ "@zpress/ui": "0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@rslib/core": "^0.20.0",
38
+ "typescript": "^5.9.3"
39
+ },
40
+ "engines": {
41
+ "node": ">=24.0.0"
42
+ },
43
+ "scripts": {
44
+ "build": "rslib build",
45
+ "dev": "node ./dist/index.mjs dev --cwd ../..",
46
+ "typecheck": "tsc --noEmit"
47
+ }
48
+ }