@zeropress/build-pages 0.5.1

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/src/index.js ADDED
@@ -0,0 +1,267 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { runBuild } from '@zeropress/build/src/index.js';
6
+ import { checkInternalLinks } from './check-links.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const packageDir = path.resolve(__dirname, '..');
10
+ const prebuildScript = path.join(packageDir, 'src', 'prebuild.js');
11
+ const PREVIEW_DATA_PATH = '.zeropress/preview-data.json';
12
+ const STAGING_DIR = '.zeropress/build-pages-public';
13
+ const DEFAULT_THEME = 'docs';
14
+
15
+ export async function runCli(argv = process.argv.slice(2)) {
16
+ try {
17
+ if (argv.includes('--help') || argv.includes('-h')) {
18
+ printHelp();
19
+ return;
20
+ }
21
+ if (argv.includes('--version') || argv.includes('-v')) {
22
+ const packageJson = JSON.parse(await fs.readFile(path.join(packageDir, 'package.json'), 'utf8'));
23
+ console.log(packageJson.version);
24
+ return;
25
+ }
26
+
27
+ const options = parseArgs(argv, process.env);
28
+ await runBuildPages(options);
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ console.error(message);
32
+ process.exitCode = 1;
33
+ }
34
+ }
35
+
36
+ export async function runBuildPages(options) {
37
+ const cwd = path.resolve(options.cwd || process.cwd());
38
+ const sourceDir = path.resolve(cwd, options.source);
39
+ const destinationDir = path.resolve(cwd, options.destination);
40
+ const generatedDir = path.join(cwd, '.zeropress');
41
+ const stagingDir = path.join(cwd, STAGING_DIR);
42
+ const previewDataPath = path.join(cwd, PREVIEW_DATA_PATH);
43
+ const themeDir = resolveThemeDir(cwd, options);
44
+
45
+ await assertDirectory(sourceDir, 'Source directory');
46
+ await fs.rm(generatedDir, { recursive: true, force: true });
47
+ await fs.mkdir(generatedDir, { recursive: true });
48
+
49
+ const env = {
50
+ ...process.env,
51
+ ZEROPRESS_BUILD_PAGES_SOURCE: sourceDir,
52
+ ZEROPRESS_PUBLIC_DIR: sourceDir,
53
+ ZEROPRESS_SKIP_UNTITLED_MARKDOWN: String(Boolean(options.skipUntitledMarkdown)),
54
+ };
55
+ if (options.config) {
56
+ env.ZEROPRESS_BUILD_PAGES_CONFIG = path.resolve(cwd, options.config);
57
+ }
58
+ if (options.siteUrl) {
59
+ env.ZEROPRESS_SITE_URL = options.siteUrl;
60
+ }
61
+
62
+ const prebuild = spawnSync(process.execPath, [prebuildScript], {
63
+ cwd,
64
+ env,
65
+ encoding: 'utf8',
66
+ });
67
+ process.stdout.write(prebuild.stdout || '');
68
+ process.stderr.write(prebuild.stderr || '');
69
+ if (prebuild.status !== 0) {
70
+ throw new Error('Build pages prebuild failed.');
71
+ }
72
+
73
+ const previewData = JSON.parse(await fs.readFile(previewDataPath, 'utf8'));
74
+ await fs.rm(destinationDir, { recursive: true, force: true });
75
+ await fs.rm(stagingDir, { recursive: true, force: true });
76
+ await fs.mkdir(stagingDir, { recursive: true });
77
+ await copyPublicStaging(sourceDir, stagingDir, {
78
+ excludePaths: [destinationDir, themeDir, generatedDir],
79
+ });
80
+
81
+ const previousPublicDir = process.env.ZEROPRESS_PUBLIC_DIR;
82
+ process.env.ZEROPRESS_PUBLIC_DIR = stagingDir;
83
+ try {
84
+ const result = await runBuild(themeDir, previewData, destinationDir);
85
+ console.log('Built ZeroPress Pages site successfully');
86
+ console.log(`Files: ${result.files.length}`);
87
+ console.log(`Output: ${formatPath(cwd, destinationDir)}`);
88
+ } finally {
89
+ if (previousPublicDir === undefined) {
90
+ delete process.env.ZEROPRESS_PUBLIC_DIR;
91
+ } else {
92
+ process.env.ZEROPRESS_PUBLIC_DIR = previousPublicDir;
93
+ }
94
+ }
95
+
96
+ if (options.checkLinks) {
97
+ const result = await checkInternalLinks(destinationDir);
98
+ if (result.brokenLinks.length) {
99
+ console.warn('Warning: broken internal links found:');
100
+ for (const link of result.brokenLinks) {
101
+ console.warn(`- ${link}`);
102
+ }
103
+ }
104
+ console.log(`Checked ${result.htmlFiles.length} HTML files for internal links`);
105
+ }
106
+ }
107
+
108
+ export function parseArgs(argv, env = process.env) {
109
+ const flags = {};
110
+
111
+ for (let index = 0; index < argv.length; index += 1) {
112
+ const arg = argv[index];
113
+ if (arg === '--skip-untitled-markdown') {
114
+ flags.skipUntitledMarkdown = true;
115
+ continue;
116
+ }
117
+ if (arg === '--check-links') {
118
+ flags.checkLinks = true;
119
+ continue;
120
+ }
121
+ if (arg === '--no-check-links') {
122
+ flags.checkLinks = false;
123
+ continue;
124
+ }
125
+
126
+ const valueOptions = new Set([
127
+ '--source',
128
+ '--destination',
129
+ '--out',
130
+ '--theme',
131
+ '--theme-path',
132
+ '--config',
133
+ '--site-url',
134
+ ]);
135
+ if (valueOptions.has(arg)) {
136
+ const value = argv[index + 1];
137
+ if (!value || value.startsWith('--')) {
138
+ throw new Error(`Invalid arguments: ${arg} requires a value`);
139
+ }
140
+ flags[arg.slice(2)] = value;
141
+ index += 1;
142
+ continue;
143
+ }
144
+
145
+ throw new Error(`Invalid arguments: unknown option ${arg}`);
146
+ }
147
+
148
+ return {
149
+ source: flags.source || env.ZEROPRESS_PUBLIC_DIR || '.',
150
+ destination: flags.destination || flags.out || env.ZEROPRESS_OUT_DIR || '_site',
151
+ theme: flags.theme || DEFAULT_THEME,
152
+ themePath: flags['theme-path'] || env.ZEROPRESS_THEME_DIR || '',
153
+ config: flags.config || env.ZEROPRESS_BUILD_PAGES_CONFIG || '',
154
+ siteUrl: flags['site-url'] || env.ZEROPRESS_SITE_URL || '',
155
+ skipUntitledMarkdown: flags.skipUntitledMarkdown ?? env.ZEROPRESS_SKIP_UNTITLED_MARKDOWN === 'true',
156
+ checkLinks: flags.checkLinks ?? true,
157
+ };
158
+ }
159
+
160
+ function printHelp() {
161
+ console.log(`zeropress-build-pages - Build ZeroPress static output for modern hosting platforms
162
+
163
+ Usage:
164
+ zeropress-build-pages [options]
165
+
166
+ Options:
167
+ --source <dir> Source directory (default: .)
168
+ --destination <dir> Output directory (default: _site)
169
+ --out <dir> Alias for --destination
170
+ --theme docs Bundled theme name (default: docs)
171
+ --theme-path <dir> Custom ZeroPress theme directory
172
+ --config <path> Config file (default: <source>/.zeropress/config.json)
173
+ --site-url <url> Canonical site URL override
174
+ --skip-untitled-markdown Skip Markdown files without an H1
175
+ --check-links Warn about broken internal links (default)
176
+ --no-check-links Skip internal link checking
177
+ --help, -h Show help
178
+ --version, -v Show version`);
179
+ }
180
+
181
+ function resolveThemeDir(cwd, options) {
182
+ if (options.themePath) {
183
+ return path.resolve(cwd, options.themePath);
184
+ }
185
+ if (options.theme === DEFAULT_THEME) {
186
+ return path.join(packageDir, 'themes', DEFAULT_THEME);
187
+ }
188
+ throw new Error(`Unknown bundled theme: ${options.theme}`);
189
+ }
190
+
191
+ async function assertDirectory(dir, label) {
192
+ let stat;
193
+ try {
194
+ stat = await fs.stat(dir);
195
+ } catch (error) {
196
+ if (error?.code === 'ENOENT') {
197
+ throw new Error(`${label} not found: ${dir}`);
198
+ }
199
+ throw error;
200
+ }
201
+ if (!stat.isDirectory()) {
202
+ throw new Error(`${label} is not a directory: ${dir}`);
203
+ }
204
+ }
205
+
206
+ async function copyPublicStaging(sourceDir, targetDir, options) {
207
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
208
+
209
+ for (const entry of entries) {
210
+ if (shouldIgnorePublicEntry(entry.name) || entry.isSymbolicLink()) {
211
+ continue;
212
+ }
213
+
214
+ const sourcePath = path.join(sourceDir, entry.name);
215
+ if (isExcludedPath(sourcePath, options.excludePaths)) {
216
+ continue;
217
+ }
218
+
219
+ const targetPath = path.join(targetDir, entry.name);
220
+ if (entry.isDirectory()) {
221
+ await fs.mkdir(targetPath, { recursive: true });
222
+ await copyPublicStaging(sourcePath, targetPath, options);
223
+ continue;
224
+ }
225
+
226
+ if (!entry.isFile()) {
227
+ continue;
228
+ }
229
+
230
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
231
+ await fs.copyFile(sourcePath, targetPath);
232
+ }
233
+ }
234
+
235
+ function shouldIgnorePublicEntry(name) {
236
+ const basename = String(name || '');
237
+ const lowerName = basename.toLowerCase();
238
+ return (
239
+ basename.startsWith('.')
240
+ || lowerName === 'node_modules'
241
+ || lowerName === 'thumbs.db'
242
+ || lowerName.endsWith('.key')
243
+ || lowerName.endsWith('.pem')
244
+ );
245
+ }
246
+
247
+ function isExcludedPath(candidate, excludePaths) {
248
+ return excludePaths.some((excludePath) => pathsOverlap(candidate, excludePath));
249
+ }
250
+
251
+ function pathsOverlap(firstPath, secondPath) {
252
+ const first = path.resolve(firstPath);
253
+ const second = path.resolve(secondPath);
254
+ return first === second || isPathInside(first, second) || isPathInside(second, first);
255
+ }
256
+
257
+ function isPathInside(parentPath, childPath) {
258
+ const relativePath = path.relative(parentPath, childPath);
259
+ return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
260
+ }
261
+
262
+ function formatPath(cwd, targetPath) {
263
+ const relativePath = path.relative(cwd, targetPath);
264
+ return relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
265
+ ? relativePath.replace(/\\/g, '/')
266
+ : targetPath.replace(/\\/g, '/');
267
+ }