@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.
@@ -0,0 +1,1054 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const rootDir = process.cwd();
7
+ const sourceDir = resolveEnvPath(['ZEROPRESS_BUILD_PAGES_SOURCE', 'ZEROPRESS_PUBLIC_DIR'], '.');
8
+ const defaultConfigPath = path.join(sourceDir, '.zeropress', 'config.json');
9
+ const configPath = resolveOptionalEnvPath(['ZEROPRESS_BUILD_PAGES_CONFIG'], defaultConfigPath);
10
+ const outDir = path.join(rootDir, '.zeropress');
11
+ const previewDataPath = path.join(outDir, 'preview-data.json');
12
+ const prebuildReportPath = path.join(outDir, 'prebuild-report.json');
13
+ const skipUntitledMarkdown = readBooleanEnv('ZEROPRESS_SKIP_UNTITLED_MARKDOWN');
14
+ const FRONT_PAGE_TYPES = new Set(['theme_index', 'markdown', 'html']);
15
+
16
+ class PrebuildMarkdownError extends Error {
17
+ constructor(sourcePath, reason, expected = '', code = 'invalid_markdown') {
18
+ super(reason);
19
+ this.name = 'PrebuildMarkdownError';
20
+ this.sourcePath = sourcePath;
21
+ this.reason = reason;
22
+ this.expected = expected;
23
+ this.code = code;
24
+ }
25
+ }
26
+
27
+ class PrebuildConfigError extends Error {
28
+ constructor(reason, expected = '') {
29
+ super(reason);
30
+ this.name = 'PrebuildConfigError';
31
+ this.reason = reason;
32
+ this.expected = expected;
33
+ }
34
+ }
35
+
36
+ main().catch(handlePrebuildError);
37
+
38
+ async function main() {
39
+ const config = await loadPrebuildConfig();
40
+ const frontPageConfig = await normalizeDefaultFrontPageConfig(
41
+ normalizeFrontPageConfig(config.front_page),
42
+ config.front_page,
43
+ );
44
+ const sourceFiles = await listMarkdownFiles(sourceDir);
45
+ const skippedMarkdown = [];
46
+ const pageInputs = [];
47
+
48
+ for (const sourcePath of sourceFiles) {
49
+ const rawMarkdown = await fs.readFile(sourcePath, 'utf8');
50
+ const title = extractTitleOrSkip(rawMarkdown, sourcePath, skippedMarkdown);
51
+ if (!title) {
52
+ continue;
53
+ }
54
+
55
+ pageInputs.push({
56
+ sourcePath,
57
+ rawMarkdown,
58
+ title,
59
+ route: buildPageRoute(sourcePath, {
60
+ allowRootIndex: shouldAllowRootMarkdownIndex(frontPageConfig),
61
+ }),
62
+ });
63
+ }
64
+
65
+ const routeBySourcePath = new Map(
66
+ pageInputs.map(({ sourcePath, route }) => [sourcePath, route]),
67
+ );
68
+
69
+ const pages = pageInputs.map(({ sourcePath, rawMarkdown, title, route }) => ({
70
+ title,
71
+ slug: route.slug,
72
+ path: route.path,
73
+ meta: {
74
+ source_markdown_url: buildSourceMarkdownUrl(sourcePath),
75
+ },
76
+ content: rewriteMarkdownLinks(rawMarkdown, sourcePath, routeBySourcePath),
77
+ document_type: 'markdown',
78
+ excerpt: extractExcerpt(rawMarkdown, title),
79
+ status: 'published',
80
+ }));
81
+
82
+ const frontPageResult = await buildFrontPageData(frontPageConfig, pageInputs, config);
83
+ if (frontPageResult.page) {
84
+ pages.push(frontPageResult.page);
85
+ }
86
+
87
+ const site = buildSiteData(config, frontPageResult.frontPage);
88
+ const menus = normalizeMenus(config.menus);
89
+ const customHtml = await buildCustomHtmlData(config.custom_html);
90
+
91
+ const previewData = {
92
+ version: '0.5',
93
+ generator: 'zeropress-build-pages',
94
+ generated_at: new Date().toISOString(),
95
+ site,
96
+ content: {
97
+ authors: [],
98
+ posts: [],
99
+ pages,
100
+ categories: [],
101
+ tags: [],
102
+ },
103
+ menus,
104
+ widgets: {},
105
+ };
106
+ if (customHtml) {
107
+ previewData.custom_html = customHtml;
108
+ }
109
+
110
+ await fs.mkdir(outDir, { recursive: true });
111
+ await fs.writeFile(previewDataPath, `${JSON.stringify(previewData, null, 2)}\n`, 'utf8');
112
+
113
+ const report = buildPrebuildReport({
114
+ sourceFiles,
115
+ pageInputs,
116
+ pages,
117
+ skippedMarkdown,
118
+ frontPageConfig,
119
+ frontPage: frontPageResult.frontPage,
120
+ customHtml,
121
+ });
122
+ await fs.writeFile(prebuildReportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
123
+
124
+ console.log(`Wrote ${path.relative(rootDir, previewDataPath)} with ${pages.length} pages`);
125
+ printPrebuildSummary(report);
126
+ }
127
+
128
+ function handlePrebuildError(error) {
129
+ if (error instanceof PrebuildMarkdownError) {
130
+ console.error(formatMarkdownError(error));
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+
135
+ if (error instanceof PrebuildConfigError) {
136
+ console.error(formatConfigError(error));
137
+ process.exitCode = 1;
138
+ return;
139
+ }
140
+
141
+ const reason = error instanceof Error ? error.message : String(error);
142
+ console.error(`[zeropress-build-pages] Unexpected prebuild failure.\nReason: ${reason}`);
143
+ process.exitCode = 1;
144
+ }
145
+
146
+ function formatMarkdownError(error) {
147
+ const lines = [
148
+ `[zeropress-build-pages] Invalid Markdown page: ${formatSourcePath(error.sourcePath)}`,
149
+ `Reason: ${error.reason}`,
150
+ ];
151
+
152
+ if (error.expected) {
153
+ lines.push(`Expected one of:\n${error.expected}`);
154
+ }
155
+
156
+ return lines.join('\n');
157
+ }
158
+
159
+ function formatConfigError(error) {
160
+ const lines = [
161
+ `[zeropress-build-pages] Invalid site config: ${formatSourcePath(configPath)}`,
162
+ `Reason: ${error.reason}`,
163
+ ];
164
+
165
+ if (error.expected) {
166
+ lines.push(`Expected:\n${error.expected}`);
167
+ }
168
+
169
+ return lines.join('\n');
170
+ }
171
+
172
+ async function loadPrebuildConfig() {
173
+ try {
174
+ const rawConfig = await fs.readFile(configPath, 'utf8');
175
+ const parsed = JSON.parse(rawConfig);
176
+ if (!isPlainObject(parsed)) {
177
+ throw new PrebuildConfigError('config.json must contain a JSON object.');
178
+ }
179
+ return parsed;
180
+ } catch (error) {
181
+ if (error?.code === 'ENOENT') {
182
+ return {};
183
+ }
184
+ if (error instanceof SyntaxError) {
185
+ throw new PrebuildConfigError(`config.json is not valid JSON: ${error.message}`);
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ function buildSiteData(config, frontPage) {
192
+ const configuredSite = isPlainObject(config.site) ? config.site : {};
193
+ const footer = normalizeFooter(configuredSite.footer);
194
+
195
+ const site = {
196
+ title: readConfigString(configuredSite.title, 'ZeroPress Site'),
197
+ description: readConfigString(configuredSite.description, 'Documentation built with ZeroPress.'),
198
+ url: readEnv('ZEROPRESS_SITE_URL', readConfigString(configuredSite.url, '')),
199
+ mediaBaseUrl: readConfigString(configuredSite.mediaBaseUrl, ''),
200
+ locale: readConfigString(configuredSite.locale, 'en-US'),
201
+ postsPerPage: readConfigInteger(configuredSite.postsPerPage, 10),
202
+ dateFormat: readConfigString(configuredSite.dateFormat, 'YYYY-MM-DD'),
203
+ timeFormat: readConfigString(configuredSite.timeFormat, 'HH:mm'),
204
+ timezone: readConfigString(configuredSite.timezone, 'UTC'),
205
+ permalinks: normalizePermalinks(configuredSite.permalinks),
206
+ front_page: frontPage,
207
+ post_index: {
208
+ enabled: false,
209
+ },
210
+ disallowComments: configuredSite.disallowComments !== false,
211
+ };
212
+
213
+ if (footer) {
214
+ site.footer = footer;
215
+ }
216
+
217
+ return site;
218
+ }
219
+
220
+ function normalizeFooter(value) {
221
+ if (!isPlainObject(value)) {
222
+ return undefined;
223
+ }
224
+
225
+ const footer = {};
226
+ const copyrightText = readConfigString(value.copyright_text, '');
227
+ if (copyrightText) {
228
+ footer.copyright_text = copyrightText;
229
+ }
230
+
231
+ if (isPlainObject(value.attribution) && typeof value.attribution.enabled === 'boolean') {
232
+ footer.attribution = {
233
+ enabled: value.attribution.enabled,
234
+ };
235
+ }
236
+
237
+ return Object.keys(footer).length ? footer : undefined;
238
+ }
239
+
240
+ async function buildFrontPageData(frontPageConfig, pageInputs, config) {
241
+ if (frontPageConfig.type === 'theme_index') {
242
+ return {
243
+ frontPage: {
244
+ type: 'theme_index',
245
+ },
246
+ };
247
+ }
248
+
249
+ if (frontPageConfig.type === 'markdown') {
250
+ const sourcePath = resolveConfiguredSourceFile(frontPageConfig.file, '.md', 'front_page.file');
251
+ const matchedPage = pageInputs.find((pageInput) => pageInput.sourcePath === sourcePath);
252
+ if (!matchedPage) {
253
+ throw new PrebuildConfigError(
254
+ `front_page.file was not discovered as a Markdown page: ${formatSourcePath(sourcePath)}`,
255
+ ' "front_page": { "type": "markdown", "file": "index.md" }',
256
+ );
257
+ }
258
+ assertUniquePageSlug(pageInputs, matchedPage.route.slug, sourcePath);
259
+
260
+ return {
261
+ frontPage: {
262
+ type: 'page',
263
+ page_slug: matchedPage.route.slug,
264
+ },
265
+ };
266
+ }
267
+
268
+ const sourcePath = resolveConfiguredSourceFile(frontPageConfig.file, '.html', 'front_page.file');
269
+ const html = await readRequiredSourceFile(sourcePath, 'front_page.file');
270
+
271
+ if (frontPageConfig.layout === false) {
272
+ return {
273
+ frontPage: {
274
+ type: 'standalone_html',
275
+ html,
276
+ },
277
+ };
278
+ }
279
+
280
+ const route = buildHtmlPageRoute(sourcePath, { allowRootIndex: true });
281
+ assertNoPageSlugConflict(pageInputs, route.slug, sourcePath);
282
+
283
+ return {
284
+ frontPage: {
285
+ type: 'page',
286
+ page_slug: route.slug,
287
+ },
288
+ page: {
289
+ title: readConfigString(config.site?.title, 'Home'),
290
+ slug: route.slug,
291
+ path: route.path,
292
+ content: html,
293
+ document_type: 'html',
294
+ excerpt: extractHtmlExcerpt(html) || readConfigString(config.site?.description, ''),
295
+ status: 'published',
296
+ },
297
+ };
298
+ }
299
+
300
+ function normalizeFrontPageConfig(value) {
301
+ if (value === undefined) {
302
+ return {
303
+ type: 'theme_index',
304
+ };
305
+ }
306
+ if (!isPlainObject(value)) {
307
+ throw new PrebuildConfigError(
308
+ 'front_page must be an object.',
309
+ ' "front_page": { "type": "theme_index" }',
310
+ );
311
+ }
312
+ const type = value.type;
313
+ if (typeof type !== 'string' || !FRONT_PAGE_TYPES.has(type)) {
314
+ throw new PrebuildConfigError(
315
+ 'front_page.type must be one of "theme_index", "markdown", or "html".',
316
+ ' "front_page": { "type": "theme_index" }\n "front_page": { "type": "markdown" }\n "front_page": { "type": "html" }',
317
+ );
318
+ }
319
+ if (type === 'theme_index') {
320
+ assertKnownConfigKeys(value, ['type'], 'front_page');
321
+ return {
322
+ type,
323
+ };
324
+ }
325
+ assertKnownConfigKeys(
326
+ value,
327
+ type === 'html' ? ['type', 'file', 'layout'] : ['type', 'file'],
328
+ 'front_page',
329
+ );
330
+ if (value.layout !== undefined && typeof value.layout !== 'boolean') {
331
+ throw new PrebuildConfigError('front_page.layout must be a boolean when provided.');
332
+ }
333
+
334
+ const file = normalizeSourceFilePath(defaultFrontPageFile(type, value.file), 'front_page.file');
335
+ const expectedExtension = type === 'markdown' ? '.md' : '.html';
336
+ if (!file.toLowerCase().endsWith(expectedExtension)) {
337
+ throw new PrebuildConfigError(
338
+ `front_page.file must end with ${expectedExtension} when front_page.type is "${type}".`,
339
+ ` "front_page": { "type": "${type}", "file": "${type === 'markdown' ? 'index.md' : '.zeropress/index.html'}" }`,
340
+ );
341
+ }
342
+ if (type === 'html' && !isZeropressHtmlFile(file)) {
343
+ throw new PrebuildConfigError(
344
+ 'front_page.file must be an HTML file inside .zeropress/ when front_page.type is "html".',
345
+ ' "front_page": { "type": "html", "file": ".zeropress/index.html" }\n "front_page": { "type": "html", "file": ".zeropress/campaign.html", "layout": false }',
346
+ );
347
+ }
348
+
349
+ const normalizedConfig = {
350
+ type,
351
+ file,
352
+ };
353
+ if (type === 'html') {
354
+ normalizedConfig.layout = value.layout !== false;
355
+ }
356
+
357
+ return normalizedConfig;
358
+ }
359
+
360
+ async function normalizeDefaultFrontPageConfig(frontPageConfig, rawFrontPageConfig) {
361
+ if (rawFrontPageConfig !== undefined || frontPageConfig.type !== 'theme_index') {
362
+ return frontPageConfig;
363
+ }
364
+
365
+ try {
366
+ const stat = await fs.stat(path.join(sourceDir, 'index.md'));
367
+ if (stat.isFile()) {
368
+ return {
369
+ type: 'markdown',
370
+ file: 'index.md',
371
+ };
372
+ }
373
+ } catch (error) {
374
+ if (error?.code !== 'ENOENT') {
375
+ throw error;
376
+ }
377
+ }
378
+
379
+ return frontPageConfig;
380
+ }
381
+
382
+ function defaultFrontPageFile(type, value) {
383
+ if (value !== undefined) {
384
+ return value;
385
+ }
386
+ return type === 'markdown' ? 'index.md' : '.zeropress/index.html';
387
+ }
388
+
389
+ function isZeropressHtmlFile(filePath) {
390
+ return filePath.startsWith('.zeropress/') && filePath.toLowerCase().endsWith('.html');
391
+ }
392
+
393
+ async function buildCustomHtmlData(value) {
394
+ if (value === undefined) {
395
+ return undefined;
396
+ }
397
+ if (!isPlainObject(value)) {
398
+ throw new PrebuildConfigError(
399
+ 'custom_html must be an object.',
400
+ ' "custom_html": { "head_end": { "file": ".zeropress/head-end.html" } }',
401
+ );
402
+ }
403
+ assertKnownConfigKeys(value, ['head_end', 'body_end'], 'custom_html');
404
+ if (value.head_end === undefined && value.body_end === undefined) {
405
+ throw new PrebuildConfigError(
406
+ 'custom_html must include head_end or body_end.',
407
+ ' "custom_html": { "body_end": { "file": ".zeropress/body-end.html" } }',
408
+ );
409
+ }
410
+
411
+ const customHtml = {};
412
+ if (value.head_end !== undefined) {
413
+ customHtml.head_end = await buildCustomHtmlSlotData(value.head_end, 'custom_html.head_end');
414
+ }
415
+ if (value.body_end !== undefined) {
416
+ customHtml.body_end = await buildCustomHtmlSlotData(value.body_end, 'custom_html.body_end');
417
+ }
418
+
419
+ return customHtml;
420
+ }
421
+
422
+ async function buildCustomHtmlSlotData(value, pathLabel) {
423
+ if (!isPlainObject(value)) {
424
+ throw new PrebuildConfigError(`${pathLabel} must be an object.`);
425
+ }
426
+ assertKnownConfigKeys(value, ['file'], pathLabel);
427
+ if (value.file === undefined) {
428
+ throw new PrebuildConfigError(
429
+ `${pathLabel}.file is required.`,
430
+ ` "${pathLabel.split('.').at(-1)}": { "file": ".zeropress/${pathLabel.endsWith('head_end') ? 'head-end' : 'body-end'}.html" }`,
431
+ );
432
+ }
433
+
434
+ const file = normalizeSourceFilePath(value.file, `${pathLabel}.file`);
435
+ if (!isZeropressHtmlFile(file)) {
436
+ throw new PrebuildConfigError(
437
+ `${pathLabel}.file must be an HTML file inside .zeropress/.`,
438
+ ` "${pathLabel.split('.').at(-1)}": { "file": ".zeropress/${pathLabel.endsWith('head_end') ? 'head-end' : 'body-end'}.html" }`,
439
+ );
440
+ }
441
+
442
+ const sourcePath = resolveConfiguredSourceFile(file, '.html', `${pathLabel}.file`);
443
+ return {
444
+ content: await readRequiredSourceFile(sourcePath, `${pathLabel}.file`),
445
+ };
446
+ }
447
+
448
+ function customHtmlSlots(customHtml) {
449
+ if (!customHtml) {
450
+ return [];
451
+ }
452
+ return ['head_end', 'body_end'].filter((slot) => customHtml[slot]);
453
+ }
454
+
455
+ function assertKnownConfigKeys(value, allowedKeys, pathLabel) {
456
+ const allowedKeySet = new Set(allowedKeys);
457
+ const unknownKeys = Object.keys(value).filter((key) => !allowedKeySet.has(key));
458
+ if (unknownKeys.length) {
459
+ throw new PrebuildConfigError(
460
+ `${pathLabel} contains unknown field "${unknownKeys[0]}".`,
461
+ `Allowed fields: ${allowedKeys.join(', ')}`,
462
+ );
463
+ }
464
+ }
465
+
466
+ function shouldAllowRootMarkdownIndex(frontPageConfig) {
467
+ return frontPageConfig.type === 'markdown' && frontPageConfig.file === 'index.md';
468
+ }
469
+
470
+ function resolveConfiguredSourceFile(filePath, expectedExtension, pathLabel) {
471
+ const normalizedPath = normalizeSourceFilePath(filePath, pathLabel);
472
+ if (!normalizedPath.toLowerCase().endsWith(expectedExtension)) {
473
+ throw new PrebuildConfigError(
474
+ `${pathLabel} must end with ${expectedExtension}.`,
475
+ ` "${pathLabel.split('.').at(-1)}": "index${expectedExtension}"`,
476
+ );
477
+ }
478
+
479
+ const sourcePath = path.resolve(sourceDir, normalizedPath);
480
+ if (!isPathInside(sourceDir, sourcePath)) {
481
+ throw new PrebuildConfigError(`${pathLabel} must stay inside ${formatSourcePath(sourceDir)}.`);
482
+ }
483
+
484
+ return sourcePath;
485
+ }
486
+
487
+ function normalizeSourceFilePath(value, pathLabel) {
488
+ if (typeof value !== 'string' || !value.trim()) {
489
+ throw new PrebuildConfigError(`${pathLabel} must be a non-empty string.`);
490
+ }
491
+
492
+ const normalizedPath = value.trim().replace(/\\/g, '/');
493
+ const segments = normalizedPath.split('/');
494
+ if (
495
+ path.isAbsolute(normalizedPath)
496
+ || normalizedPath.includes('?')
497
+ || normalizedPath.includes('#')
498
+ || segments.some((segment) => !segment || segment === '.' || segment === '..')
499
+ ) {
500
+ throw new PrebuildConfigError(
501
+ `${pathLabel} must be a safe source-root relative path.`,
502
+ ' "front_page": { "type": "markdown", "file": "index.md" }\n "front_page": { "type": "html", "file": ".zeropress/index.html", "layout": false }',
503
+ );
504
+ }
505
+
506
+ return normalizedPath;
507
+ }
508
+
509
+ async function readRequiredSourceFile(sourcePath, pathLabel) {
510
+ let content = '';
511
+ try {
512
+ content = await fs.readFile(sourcePath, 'utf8');
513
+ } catch (error) {
514
+ if (error?.code === 'ENOENT') {
515
+ throw new PrebuildConfigError(`${pathLabel} does not exist: ${formatSourcePath(sourcePath)}`);
516
+ }
517
+ throw error;
518
+ }
519
+
520
+ if (!content.trim()) {
521
+ throw new PrebuildConfigError(`${pathLabel} must not be empty: ${formatSourcePath(sourcePath)}`);
522
+ }
523
+
524
+ return content;
525
+ }
526
+
527
+ function assertUniquePageSlug(pageInputs, slug, sourcePath) {
528
+ const matchingPages = pageInputs.filter((pageInput) => pageInput.route.slug === slug);
529
+ if (matchingPages.length > 1) {
530
+ throw new PrebuildConfigError(
531
+ `front_page.file resolves to a duplicate page slug "${slug}": ${formatSourcePath(sourcePath)}`,
532
+ 'Choose a front page file whose generated slug is unique.',
533
+ );
534
+ }
535
+ }
536
+
537
+ function assertNoPageSlugConflict(pageInputs, slug, sourcePath) {
538
+ const matchingPage = pageInputs.find((pageInput) => pageInput.route.slug === slug);
539
+ if (matchingPage) {
540
+ throw new PrebuildConfigError(
541
+ `front_page.file resolves to page slug "${slug}", which conflicts with ${formatSourcePath(matchingPage.sourcePath)}.`,
542
+ `Move or rename ${formatSourcePath(sourcePath)}, or choose a different front_page.file.`,
543
+ );
544
+ }
545
+ }
546
+
547
+ function isPathInside(parentPath, childPath) {
548
+ const relativePath = path.relative(parentPath, childPath);
549
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
550
+ }
551
+
552
+ function normalizePermalinks(value) {
553
+ return {
554
+ output_style: readConfigString(value?.output_style, 'html-extension'),
555
+ posts: readConfigString(value?.posts, '/posts/:slug/'),
556
+ pages: readConfigString(value?.pages, '/:slug/'),
557
+ categories: readConfigString(value?.categories, '/categories/:slug/'),
558
+ tags: readConfigString(value?.tags, '/tags/:slug/'),
559
+ };
560
+ }
561
+
562
+ function normalizeMenus(value) {
563
+ if (value === undefined) {
564
+ return defaultMenus();
565
+ }
566
+ if (!isPlainObject(value)) {
567
+ throw new PrebuildConfigError('menus must be an object keyed by menu id.');
568
+ }
569
+
570
+ const menus = {};
571
+ for (const [menuId, menu] of Object.entries(value)) {
572
+ if (!isPlainObject(menu)) {
573
+ throw new PrebuildConfigError(`menus.${menuId} must be an object.`);
574
+ }
575
+ if (!Array.isArray(menu.items)) {
576
+ throw new PrebuildConfigError(`menus.${menuId}.items must be an array.`);
577
+ }
578
+ menus[menuId] = {
579
+ name: readConfigString(menu.name, menuId),
580
+ items: menu.items.map((item, index) => normalizeMenuItem(item, `menus.${menuId}.items[${index}]`)),
581
+ };
582
+ }
583
+
584
+ return menus;
585
+ }
586
+
587
+ function normalizeMenuItem(item, pathLabel) {
588
+ if (!isPlainObject(item)) {
589
+ throw new PrebuildConfigError(`${pathLabel} must be an object.`);
590
+ }
591
+ const title = readConfigString(item.title, '');
592
+ const url = readConfigString(item.url, '');
593
+ if (!title || !url) {
594
+ throw new PrebuildConfigError(`${pathLabel} must include non-empty title and url strings.`);
595
+ }
596
+
597
+ return {
598
+ title,
599
+ url,
600
+ type: readConfigString(item.type, 'custom'),
601
+ target: readConfigString(item.target, '_self'),
602
+ children: Array.isArray(item.children)
603
+ ? item.children.map((child, index) => normalizeMenuItem(child, `${pathLabel}.children[${index}]`))
604
+ : [],
605
+ };
606
+ }
607
+
608
+ function defaultMenus() {
609
+ return {
610
+ primary: {
611
+ name: 'Primary Menu',
612
+ items: [
613
+ menuItem('Home', '/'),
614
+ ],
615
+ },
616
+ };
617
+ }
618
+
619
+ function buildPrebuildReport({
620
+ sourceFiles,
621
+ pageInputs,
622
+ pages,
623
+ skippedMarkdown,
624
+ frontPageConfig,
625
+ frontPage,
626
+ customHtml,
627
+ }) {
628
+ return {
629
+ generated_at: new Date().toISOString(),
630
+ source_dir: formatSourcePath(sourceDir),
631
+ config_path: formatSourcePath(configPath),
632
+ preview_data_path: formatSourcePath(previewDataPath),
633
+ report_path: formatSourcePath(prebuildReportPath),
634
+ skip_untitled_markdown: skipUntitledMarkdown,
635
+ markdown: {
636
+ discovered: sourceFiles.length,
637
+ generated_pages: pageInputs.length,
638
+ skipped: skippedMarkdown.length,
639
+ skipped_files: skippedMarkdown,
640
+ },
641
+ pages: {
642
+ total: pages.length,
643
+ },
644
+ front_page: {
645
+ config: frontPageConfig,
646
+ preview_data: frontPage,
647
+ },
648
+ custom_html: customHtmlSlots(customHtml),
649
+ };
650
+ }
651
+
652
+ function printPrebuildSummary(report) {
653
+ const lines = [
654
+ 'ZeroPress prebuild report',
655
+ `- Public root: ${report.source_dir}`,
656
+ `- Markdown discovered: ${report.markdown.discovered}`,
657
+ `- Markdown pages generated: ${report.markdown.generated_pages}`,
658
+ `- Markdown skipped: ${report.markdown.skipped}`,
659
+ `- Total preview pages: ${report.pages.total}`,
660
+ `- Front page: ${formatFrontPageSummary(report.front_page)}`,
661
+ `- Custom HTML slots: ${report.custom_html.length ? report.custom_html.join(', ') : 'none'}`,
662
+ `- Report: ${report.report_path}`,
663
+ ];
664
+
665
+ console.log(lines.join('\n'));
666
+ }
667
+
668
+ function formatFrontPageSummary(frontPageReport) {
669
+ const config = frontPageReport.config;
670
+ const previewData = frontPageReport.preview_data;
671
+ if (config.type === 'theme_index') {
672
+ return 'theme_index -> /';
673
+ }
674
+ if (config.type === 'markdown') {
675
+ return `markdown ${config.file} -> / (${previewData.page_slug})`;
676
+ }
677
+ if (previewData.type === 'standalone_html') {
678
+ return `html ${config.file} -> / (standalone_html)`;
679
+ }
680
+ return `html ${config.file} -> / (${previewData.page_slug})`;
681
+ }
682
+
683
+ function extractTitleOrSkip(markdown, sourcePath, skippedMarkdown) {
684
+ try {
685
+ return extractTitle(markdown, sourcePath);
686
+ } catch (error) {
687
+ if (
688
+ skipUntitledMarkdown
689
+ && error instanceof PrebuildMarkdownError
690
+ && error.code === 'untitled_markdown'
691
+ ) {
692
+ console.warn(formatSkippedUntitledMarkdownWarning(error));
693
+ skippedMarkdown.push({
694
+ file: formatSourcePath(error.sourcePath),
695
+ reason: error.reason,
696
+ });
697
+ return '';
698
+ }
699
+
700
+ throw error;
701
+ }
702
+ }
703
+
704
+ function formatSkippedUntitledMarkdownWarning(error) {
705
+ return [
706
+ `[zeropress-build-pages] Skipped untitled Markdown: ${formatSourcePath(error.sourcePath)}`,
707
+ `Reason: ${error.reason}`,
708
+ 'This file was not added to preview-data pages.',
709
+ ].join('\n');
710
+ }
711
+
712
+ async function listMarkdownFiles(dir) {
713
+ const entries = await fs.readdir(dir, { withFileTypes: true });
714
+ const files = [];
715
+
716
+ for (const entry of entries) {
717
+ if (shouldIgnoreMarkdownDiscoverEntry(entry.name)) {
718
+ continue;
719
+ }
720
+
721
+ const entryPath = path.join(dir, entry.name);
722
+ if (entry.isDirectory()) {
723
+ files.push(...await listMarkdownFiles(entryPath));
724
+ continue;
725
+ }
726
+
727
+ if (entry.isFile() && entry.name.endsWith('.md')) {
728
+ files.push(entryPath);
729
+ }
730
+ }
731
+
732
+ return files.sort((left, right) => left.localeCompare(right));
733
+ }
734
+
735
+ function shouldIgnoreMarkdownDiscoverEntry(name) {
736
+ const basename = String(name || '');
737
+ const lowerName = basename.toLowerCase();
738
+ return (
739
+ lowerName === 'node_modules'
740
+ || lowerName === 'vendor'
741
+ || basename.startsWith('.')
742
+ || basename.startsWith('_')
743
+ || basename.startsWith('#')
744
+ || basename.endsWith('~')
745
+ );
746
+ }
747
+
748
+ function buildPageRoute(sourcePath, options = {}) {
749
+ const relativePath = path.relative(sourceDir, sourcePath).replace(/\\/g, '/');
750
+ const routePath = buildRoutePath(relativePath, sourcePath, options);
751
+ const slug = buildSlug(routePath);
752
+
753
+ if (!slug) {
754
+ throw new PrebuildMarkdownError(
755
+ sourcePath,
756
+ 'unable to derive a route slug from the file path.',
757
+ ' getting-started.md\n docs/index.md',
758
+ );
759
+ }
760
+
761
+ return pageRoute(slug, routePath);
762
+ }
763
+
764
+ function buildHtmlPageRoute(sourcePath, options = {}) {
765
+ const relativePath = path.relative(sourceDir, sourcePath).replace(/\\/g, '/');
766
+ const routePath = buildRoutePath(relativePath, sourcePath, {
767
+ ...options,
768
+ extensionPattern: /\.html$/i,
769
+ });
770
+ const slug = buildSlug(routePath);
771
+
772
+ if (!slug) {
773
+ throw new PrebuildConfigError(
774
+ `front_page.file cannot derive a route slug: ${formatSourcePath(sourcePath)}`,
775
+ 'Use a source-root relative file path such as .zeropress/index.html or .zeropress/landing.html.',
776
+ );
777
+ }
778
+
779
+ return pageRoute(slug, routePath);
780
+ }
781
+
782
+ function buildRoutePath(relativeSourcePath, sourcePath, options = {}) {
783
+ const extensionPattern = options.extensionPattern || /\.md$/i;
784
+ const withoutExtension = relativeSourcePath.replace(extensionPattern, '').toLowerCase();
785
+ const segments = withoutExtension
786
+ .split('/')
787
+ .map((segment) => sanitizePathSegment(segment))
788
+ .filter(Boolean);
789
+
790
+ const routePath = segments.join('/');
791
+ if (routePath === 'index' && options.allowRootIndex) {
792
+ return routePath;
793
+ }
794
+ if (!routePath || routePath === 'index') {
795
+ throw new PrebuildMarkdownError(
796
+ sourcePath,
797
+ 'root index Markdown is reserved for the theme home page.',
798
+ ' docs/index.md\n theme-authoring/index.md',
799
+ );
800
+ }
801
+
802
+ return routePath;
803
+ }
804
+
805
+ function buildSlug(routePath) {
806
+ const segments = routePath.split('/');
807
+ const rawSlug = segments.at(-1) === 'index' && segments.length > 1
808
+ ? segments.at(-2)
809
+ : segments.at(-1);
810
+ return sanitizePathSegment(rawSlug || '');
811
+ }
812
+
813
+ function sanitizePathSegment(segment) {
814
+ return segment
815
+ .replace(/[^a-z0-9.-]+/g, '-')
816
+ .replace(/^-+|-+$/g, '')
817
+ .replace(/-{2,}/g, '-');
818
+ }
819
+
820
+ function pageRoute(slug, routePath) {
821
+ return {
822
+ slug,
823
+ path: routePath,
824
+ url: buildPublicUrl(routePath),
825
+ };
826
+ }
827
+
828
+ function buildPublicUrl(routePath) {
829
+ if (routePath === 'index') {
830
+ return '/';
831
+ }
832
+ if (routePath.endsWith('/index')) {
833
+ return `/${routePath.slice(0, -'/index'.length)}/`;
834
+ }
835
+ return `/${routePath}`;
836
+ }
837
+
838
+ function buildSourceMarkdownUrl(sourcePath) {
839
+ const relativePath = path.relative(sourceDir, sourcePath).replace(/\\/g, '/');
840
+ const encodedPath = relativePath
841
+ .split('/')
842
+ .map((segment) => encodeURIComponent(segment))
843
+ .join('/');
844
+
845
+ return `/${encodedPath}`;
846
+ }
847
+
848
+ function extractTitle(markdown, sourcePath) {
849
+ if (!markdown.trim()) {
850
+ throw new PrebuildMarkdownError(
851
+ sourcePath,
852
+ 'empty Markdown file.',
853
+ expectedHeadingSyntax(),
854
+ 'untitled_markdown',
855
+ );
856
+ }
857
+
858
+ const lines = markdown.split(/\r?\n/);
859
+ const atxTitle = extractAtxH1(lines);
860
+ if (atxTitle) {
861
+ return atxTitle;
862
+ }
863
+
864
+ const setextTitle = extractSetextH1(lines);
865
+ if (setextTitle) {
866
+ return setextTitle;
867
+ }
868
+
869
+ throw new PrebuildMarkdownError(
870
+ sourcePath,
871
+ 'missing top-level heading.',
872
+ expectedHeadingSyntax(),
873
+ 'untitled_markdown',
874
+ );
875
+ }
876
+
877
+ function extractAtxH1(lines) {
878
+ for (const line of lines) {
879
+ const match = line.match(/^#\s+(.+?)\s*$/);
880
+ if (match) {
881
+ return match[1].trim();
882
+ }
883
+ }
884
+
885
+ return '';
886
+ }
887
+
888
+ function extractSetextH1(lines) {
889
+ for (let index = 0; index < lines.length - 1; index += 1) {
890
+ const titleLine = lines[index].trim();
891
+ const underline = lines[index + 1].trim();
892
+ if (titleLine && /^=+\s*$/.test(underline)) {
893
+ return titleLine;
894
+ }
895
+ }
896
+
897
+ return '';
898
+ }
899
+
900
+ function expectedHeadingSyntax() {
901
+ return [
902
+ ' # Page Title',
903
+ '',
904
+ ' Page Title',
905
+ ' ==========',
906
+ ].join('\n');
907
+ }
908
+
909
+ function extractExcerpt(markdown, title) {
910
+ const paragraphs = markdown
911
+ .split(/\n{2,}/)
912
+ .map((block) => block.trim())
913
+ .filter(Boolean)
914
+ .filter((block) => !isMarkdownHeadingBlock(block))
915
+ .filter((block) => !block.startsWith('```'));
916
+
917
+ const first = paragraphs[0] || title;
918
+ return first
919
+ .replace(/^>\s*/gm, '')
920
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
921
+ .replace(/[`*_]/g, '')
922
+ .replace(/\s+/g, ' ')
923
+ .trim()
924
+ .slice(0, 240);
925
+ }
926
+
927
+ function extractHtmlExcerpt(html) {
928
+ const text = html
929
+ .replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
930
+ .replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
931
+ .replace(/<[^>]+>/g, ' ')
932
+ .replace(/&nbsp;/gi, ' ')
933
+ .replace(/&amp;/gi, '&')
934
+ .replace(/&lt;/gi, '<')
935
+ .replace(/&gt;/gi, '>')
936
+ .replace(/&quot;/gi, '"')
937
+ .replace(/&#39;/gi, "'")
938
+ .replace(/\s+/g, ' ')
939
+ .trim();
940
+
941
+ return text.slice(0, 240);
942
+ }
943
+
944
+ function isMarkdownHeadingBlock(block) {
945
+ if (block.startsWith('#')) {
946
+ return true;
947
+ }
948
+
949
+ const lines = block.split(/\r?\n/);
950
+ return Boolean(
951
+ lines.length >= 2
952
+ && lines[0].trim()
953
+ && /^[=-]+$/.test(lines[1].trim()),
954
+ );
955
+ }
956
+
957
+ function rewriteMarkdownLinks(markdown, sourcePath, routes) {
958
+ return markdown.replace(/(\[[^\]]+\]\()([^)]+)(\))/g, (full, prefix, rawTarget, suffix) => {
959
+ const rewritten = rewriteLinkTarget(rawTarget.trim(), sourcePath, routes);
960
+ return rewritten === rawTarget.trim() ? full : `${prefix}${rewritten}${suffix}`;
961
+ });
962
+ }
963
+
964
+ function rewriteLinkTarget(target, sourcePath, routes) {
965
+ if (
966
+ !target ||
967
+ target.startsWith('#') ||
968
+ /^[a-z][a-z0-9+.-]*:/i.test(target) ||
969
+ target.startsWith('//')
970
+ ) {
971
+ return target;
972
+ }
973
+
974
+ const { pathname, suffix } = splitLinkTarget(target);
975
+ if (!pathname.endsWith('.md')) {
976
+ return target;
977
+ }
978
+
979
+ const resolvedPath = resolveMarkdownTarget(pathname, sourcePath);
980
+ const route = routes.get(resolvedPath);
981
+ return route ? `${route.url}${suffix}` : target;
982
+ }
983
+
984
+ function splitLinkTarget(target) {
985
+ const match = target.match(/^([^?#]*)([?#].*)?$/);
986
+ return {
987
+ pathname: match?.[1] || target,
988
+ suffix: match?.[2] || '',
989
+ };
990
+ }
991
+
992
+ function resolveMarkdownTarget(targetPath, sourcePath) {
993
+ if (targetPath.startsWith('/')) {
994
+ return path.normalize(path.join(sourceDir, targetPath.replace(/^\/+/, '')));
995
+ }
996
+
997
+ return path.normalize(path.resolve(path.dirname(sourcePath), targetPath));
998
+ }
999
+
1000
+ function menuItem(title, url) {
1001
+ return {
1002
+ title,
1003
+ url,
1004
+ type: 'custom',
1005
+ target: '_self',
1006
+ children: [],
1007
+ };
1008
+ }
1009
+
1010
+ function readEnv(name, fallback) {
1011
+ const value = process.env[name]?.trim();
1012
+ return value || fallback;
1013
+ }
1014
+
1015
+ function readConfigString(value, fallback) {
1016
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
1017
+ }
1018
+
1019
+ function readConfigInteger(value, fallback) {
1020
+ return Number.isInteger(value) && value > 0 ? value : fallback;
1021
+ }
1022
+
1023
+ function readBooleanEnv(name) {
1024
+ return process.env[name]?.trim().toLowerCase() === 'true';
1025
+ }
1026
+
1027
+ function resolveEnvPath(names, fallback) {
1028
+ const rawValue = names
1029
+ .map((name) => process.env[name]?.trim())
1030
+ .find(Boolean) || fallback;
1031
+
1032
+ return path.resolve(rootDir, rawValue);
1033
+ }
1034
+
1035
+ function resolveOptionalEnvPath(names, fallback) {
1036
+ const rawValue = names
1037
+ .map((name) => process.env[name]?.trim())
1038
+ .find(Boolean);
1039
+
1040
+ return rawValue ? path.resolve(rootDir, rawValue) : fallback;
1041
+ }
1042
+
1043
+ function isPlainObject(value) {
1044
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1045
+ }
1046
+
1047
+ function formatSourcePath(sourcePath) {
1048
+ const relativePath = path.relative(rootDir, sourcePath);
1049
+ if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
1050
+ return relativePath.replace(/\\/g, '/');
1051
+ }
1052
+
1053
+ return sourcePath.replace(/\\/g, '/');
1054
+ }