@zeropress/build-pages 0.6.2 → 0.6.4

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 CHANGED
@@ -10,12 +10,21 @@ const packageDir = path.resolve(__dirname, '..');
10
10
  const prebuildScript = __dirname === path.join(packageDir, 'dist')
11
11
  ? path.join(__dirname, 'prebuild.js')
12
12
  : path.join(packageDir, 'src', 'prebuild.js');
13
- const PREVIEW_DATA_PATH = '.zeropress/preview-data.json';
14
- const STAGING_DIR = '.zeropress/public-assets';
13
+ const INTERNAL_WORK_DIR = '.zeropress-build-page';
14
+ const PREVIEW_DATA_PATH = `${INTERNAL_WORK_DIR}/preview-data.json`;
15
+ const STAGING_DIR = `${INTERNAL_WORK_DIR}/public-assets`;
15
16
  const DEFAULT_THEME = 'docs';
17
+ const BUNDLED_THEME_ALIASES = new Map([
18
+ ['docs', 'docs'],
19
+ ['docs1', 'docs'],
20
+ ]);
16
21
 
17
22
  export async function runCli(argv = process.argv.slice(2)) {
18
23
  try {
24
+ if (argv.length === 0) {
25
+ printHelp();
26
+ return;
27
+ }
19
28
  if (argv.includes('--help') || argv.includes('-h')) {
20
29
  printHelp();
21
30
  return;
@@ -30,7 +39,7 @@ export async function runCli(argv = process.argv.slice(2)) {
30
39
  await runBuildPages(options);
31
40
  } catch (error) {
32
41
  const message = error instanceof Error ? error.message : String(error);
33
- console.error(message);
42
+ console.error(colorizeError(prefixError(message)));
34
43
  process.exitCode = 1;
35
44
  }
36
45
  }
@@ -42,7 +51,7 @@ export async function runBuildPages(options) {
42
51
  const publicDirExplicit = hasExplicitPublicDir(options);
43
52
  const publicDir = publicDirExplicit ? path.resolve(cwd, options.publicDir) : sourceDir;
44
53
  const destinationDir = path.resolve(cwd, options.destination);
45
- const generatedDir = path.join(cwd, '.zeropress');
54
+ const generatedDir = path.join(cwd, INTERNAL_WORK_DIR);
46
55
  const stagingDir = path.join(cwd, STAGING_DIR);
47
56
  const previewDataPath = path.join(cwd, PREVIEW_DATA_PATH);
48
57
  const themeDir = resolveThemeDir(cwd, options);
@@ -57,6 +66,7 @@ export async function runBuildPages(options) {
57
66
  generatedDir,
58
67
  });
59
68
  await assertDirectory(sourceDir, 'Source directory');
69
+ await assertDirectory(themeDir, 'Theme directory');
60
70
  await assertPublicDirectory(publicDir, publicDirExplicit);
61
71
  await assertDestinationPath(destinationDir);
62
72
  await fs.rm(generatedDir, { recursive: true, force: true });
@@ -104,7 +114,7 @@ export async function runBuildPages(options) {
104
114
  process.env.ZEROPRESS_PUBLIC_DIR = stagingDir;
105
115
  try {
106
116
  const result = await runBuild(themeDir, previewData, destinationDir);
107
- console.log('Built ZeroPress Pages site successfully');
117
+ console.log(formatBuildPagesSuccessMessage());
108
118
  console.log(`Files: ${result.files.length}`);
109
119
  console.log(`Output: ${formatPath(cwd, destinationDir)}`);
110
120
  } finally {
@@ -164,6 +174,10 @@ export function parseArgs(argv) {
164
174
  continue;
165
175
  }
166
176
 
177
+ if (!arg.startsWith('--')) {
178
+ throw new Error(`Invalid arguments: unexpected positional argument: ${arg}. Use --source <dir> and --destination <dir>.`);
179
+ }
180
+
167
181
  throw new Error(`Invalid arguments: unknown option ${arg}`);
168
182
  }
169
183
 
@@ -200,7 +214,7 @@ Options:
200
214
  --source <dir> Dedicated source directory (required)
201
215
  --public-dir <dir> Public passthrough directory (default: source)
202
216
  --destination <dir> Output directory (required)
203
- --theme docs Bundled theme name (default: docs)
217
+ --theme docs Bundled theme name (default: docs; docs1 aliases docs)
204
218
  --theme-path <dir> Custom ZeroPress theme directory
205
219
  --config <path> Config file (default: <source>/.zeropress/config.json)
206
220
  --site-url <url> Canonical site URL override
@@ -211,14 +225,58 @@ Options:
211
225
  --version, -v Show version`);
212
226
  }
213
227
 
228
+ function prefixError(message) {
229
+ if (message.startsWith('[zeropress-build-pages]')) {
230
+ return message;
231
+ }
232
+ return `[zeropress-build-pages] ${message}`;
233
+ }
234
+
235
+ export function formatBuildPagesSuccessMessage(stream = process.stdout) {
236
+ return createColor(stream).green('Built ZeroPress Pages site successfully');
237
+ }
238
+
239
+ function colorizeError(message) {
240
+ if (!colorsEnabled(process.stderr)) {
241
+ return message;
242
+ }
243
+
244
+ return message
245
+ .replace(/^(\[zeropress-build-pages\].*)/m, '\x1b[31m$1\x1b[0m')
246
+ .replace(/\bERROR\b/g, '\x1b[31mERROR\x1b[0m')
247
+ .replace(/\bWARN\b/g, '\x1b[33mWARN\x1b[0m')
248
+ .replace(/\bHint:/g, '\x1b[1mHint:\x1b[0m');
249
+ }
250
+
251
+ function createColor(stream) {
252
+ const enabled = colorsEnabled(stream);
253
+ const wrap = (code, value) => (enabled ? `\x1b[${code}m${value}\x1b[0m` : value);
254
+ return {
255
+ red: (value) => wrap('31', value),
256
+ yellow: (value) => wrap('33', value),
257
+ green: (value) => wrap('32', value),
258
+ };
259
+ }
260
+
261
+ function colorsEnabled(stream) {
262
+ if (process.env.NO_COLOR) {
263
+ return false;
264
+ }
265
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') {
266
+ return true;
267
+ }
268
+ return Boolean(stream?.isTTY);
269
+ }
270
+
214
271
  function resolveThemeDir(cwd, options) {
215
272
  if (options.themePath) {
216
273
  return path.resolve(cwd, options.themePath);
217
274
  }
218
- if (options.theme === DEFAULT_THEME) {
219
- return path.join(packageDir, 'themes', DEFAULT_THEME);
275
+ const canonicalTheme = BUNDLED_THEME_ALIASES.get(options.theme);
276
+ if (canonicalTheme) {
277
+ return path.join(packageDir, 'themes', canonicalTheme);
220
278
  }
221
- throw new Error(`Unknown bundled theme: ${options.theme}`);
279
+ throw new Error(`Unknown bundled theme: ${options.theme}. Supported bundled themes: ${Array.from(BUNDLED_THEME_ALIASES.keys()).join(', ')}`);
222
280
  }
223
281
 
224
282
  function hasExplicitPublicDir(options) {
@@ -303,11 +361,11 @@ function assertBuildPagesPathLayout({
303
361
  );
304
362
  }
305
363
 
306
- assertNoPathOverlap(cwd, 'Source directory', sourceDir, 'internal .zeropress working directory', generatedDir);
307
- assertNoPathOverlap(cwd, 'Destination directory', destinationDir, 'internal .zeropress working directory', generatedDir);
308
- assertNoPathOverlap(cwd, 'Theme directory', themeDir, 'internal .zeropress working directory', generatedDir);
364
+ assertNoPathOverlap(cwd, 'Source directory', sourceDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
365
+ assertNoPathOverlap(cwd, 'Destination directory', destinationDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
366
+ assertNoPathOverlap(cwd, 'Theme directory', themeDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
309
367
  if (!samePath(publicDir, sourceDir)) {
310
- assertNoPathOverlap(cwd, 'Public directory', publicDir, 'internal .zeropress working directory', generatedDir);
368
+ assertNoPathOverlap(cwd, 'Public directory', publicDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
311
369
  assertNoPathOverlap(cwd, 'Public directory', publicDir, 'destination directory', destinationDir);
312
370
  assertNoPathOverlap(cwd, 'Public directory', publicDir, 'theme directory', themeDir);
313
371
  }
package/src/prebuild.js CHANGED
@@ -1,28 +1,32 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
2
3
  import path from 'node:path';
4
+ import { promisify } from 'node:util';
3
5
  import { fileURLToPath } from 'node:url';
4
6
  import matter from 'gray-matter';
5
7
 
6
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const execFileAsync = promisify(execFile);
7
10
  const rootDir = process.cwd();
8
11
  const sourceDir = resolveEnvPath(['ZEROPRESS_BUILD_PAGES_SOURCE'], 'docs');
9
12
  const publicDir = resolveEnvPath(['ZEROPRESS_BUILD_PAGES_PUBLIC_DIR'], sourceDir);
10
13
  const defaultConfigPath = path.join(sourceDir, '.zeropress', 'config.json');
11
14
  const configPath = resolveOptionalEnvPath(['ZEROPRESS_BUILD_PAGES_CONFIG'], defaultConfigPath);
12
- const outDir = path.join(rootDir, '.zeropress');
15
+ const outDir = path.join(rootDir, '.zeropress-build-page');
13
16
  const buildPagesConfigPath = path.join(outDir, 'build-pages-config.json');
14
17
  const previewDataPath = path.join(outDir, 'preview-data.json');
15
18
  const buildReportPath = path.join(outDir, 'build-report.json');
16
19
  const skipUntitledMarkdown = readBooleanEnv('ZEROPRESS_SKIP_UNTITLED_MARKDOWN');
17
20
  const copyMarkdownSource = readBooleanEnv('ZEROPRESS_COPY_MARKDOWN_SOURCE', true);
18
21
  const FRONT_PAGE_TYPES = new Set(['theme_index', 'markdown', 'html']);
19
- const BUILD_PAGES_CONFIG_SCHEMA_URL = 'https://zeropress.dev/schemas/zeropress-build-pages.config.v0.1.schema.json';
20
- const PREVIEW_DATA_SCHEMA_URL = 'https://zeropress.dev/schemas/preview-data.v0.6.schema.json';
22
+ const BUILD_PAGES_CONFIG_SCHEMA_URL = 'https://schemas.zeropress.dev/build-pages-config/v0.1/schema.json';
23
+ const PREVIEW_DATA_SCHEMA_URL = 'https://schemas.zeropress.dev/preview-data/v0.6/schema.json';
21
24
  const FRONT_MATTER_DATA_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*$/;
22
25
  const FRONT_MATTER_DATA_MAX_DEPTH = 4;
23
26
  const FRONT_MATTER_DATA_MAX_KEYS = 64;
24
27
  const FRONT_MATTER_DATA_MAX_ARRAY_LENGTH = 256;
25
28
  const FRONT_MATTER_DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
29
+ const MARKDOWN_LAST_UPDATED_VALUES = new Set(['none', 'git']);
26
30
  const markdownDiscoverExcludeRoots = buildMarkdownDiscoverExcludeRoots();
27
31
 
28
32
  class PrebuildMarkdownError extends Error {
@@ -55,10 +59,12 @@ async function main() {
55
59
  );
56
60
  const menus = normalizeMenus(config.menus);
57
61
  const customHtmlConfig = normalizeCustomHtmlConfig(config.custom_html);
62
+ const markdownConfig = normalizeMarkdownConfig(config.markdown);
58
63
  const resolvedConfig = buildResolvedConfig(config, {
59
64
  frontPageConfig,
60
65
  menus,
61
66
  customHtmlConfig,
67
+ markdownConfig,
62
68
  });
63
69
  const sourceFiles = await listMarkdownFiles(sourceDir);
64
70
  const skippedMarkdown = [];
@@ -97,22 +103,30 @@ async function main() {
97
103
  const routeBySourcePath = new Map(
98
104
  pageInputs.map(({ sourcePath, route }) => [sourcePath, route]),
99
105
  );
106
+ const collections = normalizeCollections(config.collections, pageInputs, skippedMarkdown);
107
+ if (Object.keys(collections).length > 0) {
108
+ resolvedConfig.collections = collections;
109
+ }
100
110
 
101
- const pages = pageInputs.map(({ sourcePath, bodyMarkdown, frontMatter, title, route }) => ({
102
- title,
103
- slug: route.slug,
104
- path: route.path,
105
- meta: {
106
- ...frontMatter.meta,
107
- ...(copyMarkdownSource ? { source_markdown_url: buildSourceMarkdownUrl(sourcePath) } : {}),
108
- },
109
- ...(frontMatter.data !== undefined ? { data: frontMatter.data } : {}),
110
- ...(frontMatter.discoverability !== 'default' ? { discoverability: frontMatter.discoverability } : {}),
111
- content: rewriteMarkdownLinks(bodyMarkdown, sourcePath, routeBySourcePath),
112
- document_type: 'markdown',
113
- excerpt: frontMatter.description || extractExcerpt(bodyMarkdown, title),
114
- status: 'published',
115
- }));
111
+ const pages = [];
112
+ for (const { sourcePath, bodyMarkdown, frontMatter, title, route } of pageInputs) {
113
+ const meta = await buildPageMeta(sourcePath, frontMatter, markdownConfig);
114
+ pages.push({
115
+ title,
116
+ slug: route.slug,
117
+ path: route.path,
118
+ meta: {
119
+ ...meta,
120
+ ...(copyMarkdownSource ? { source_markdown_url: buildSourceMarkdownUrl(sourcePath) } : {}),
121
+ },
122
+ ...(frontMatter.data !== undefined ? { data: frontMatter.data } : {}),
123
+ ...(frontMatter.discoverability !== 'default' ? { discoverability: frontMatter.discoverability } : {}),
124
+ content: rewriteMarkdownLinks(bodyMarkdown, sourcePath, routeBySourcePath),
125
+ document_type: 'markdown',
126
+ excerpt: frontMatter.description || extractExcerpt(bodyMarkdown, title),
127
+ status: 'published',
128
+ });
129
+ }
116
130
 
117
131
  const frontPageResult = await buildFrontPageData(frontPageConfig, pageInputs, resolvedConfig);
118
132
  if (frontPageResult.page) {
@@ -138,6 +152,10 @@ async function main() {
138
152
  menus,
139
153
  widgets: {},
140
154
  };
155
+
156
+ if (Object.keys(collections).length > 0) {
157
+ previewData.collections = collections;
158
+ }
141
159
  if (customHtml) {
142
160
  previewData.custom_html = customHtml;
143
161
  }
@@ -232,7 +250,7 @@ function buildSiteData(config, frontPage) {
232
250
  description: configuredSite.description,
233
251
  url: configuredSite.url,
234
252
  media_base_url: '',
235
- locale: 'en-US',
253
+ locale: configuredSite.locale,
236
254
  posts_per_page: 10,
237
255
  datetime_display: 'static',
238
256
  date_style: 'medium',
@@ -249,6 +267,14 @@ function buildSiteData(config, frontPage) {
249
267
  indexing: configuredSite.indexing !== false,
250
268
  };
251
269
 
270
+ if (configuredSite.logo) {
271
+ site.logo = configuredSite.logo;
272
+ }
273
+
274
+ if (configuredSite.meta !== undefined) {
275
+ site.meta = configuredSite.meta;
276
+ }
277
+
252
278
  if (configuredSite.footer) {
253
279
  site.footer = configuredSite.footer;
254
280
  }
@@ -256,11 +282,12 @@ function buildSiteData(config, frontPage) {
256
282
  return site;
257
283
  }
258
284
 
259
- function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig }) {
285
+ function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig, markdownConfig }) {
260
286
  const resolvedConfig = {
261
287
  $schema: BUILD_PAGES_CONFIG_SCHEMA_URL,
262
288
  version: '0.1',
263
289
  site: normalizeSiteConfig(config.site),
290
+ markdown: markdownConfig,
264
291
  front_page: frontPageConfig,
265
292
  menus,
266
293
  };
@@ -272,6 +299,25 @@ function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig
272
299
  return resolvedConfig;
273
300
  }
274
301
 
302
+ function normalizeMarkdownConfig(value) {
303
+ if (value === undefined) {
304
+ return {
305
+ last_updated: 'none',
306
+ };
307
+ }
308
+ if (!isPlainObject(value)) {
309
+ throw new PrebuildConfigError(
310
+ 'markdown must be an object.',
311
+ ' "markdown": { "last_updated": "git" }',
312
+ );
313
+ }
314
+ assertKnownConfigKeys(value, ['last_updated'], 'markdown');
315
+
316
+ return {
317
+ last_updated: normalizeLastUpdatedPolicy(value.last_updated, 'markdown.last_updated', PrebuildConfigError),
318
+ };
319
+ }
320
+
275
321
  function normalizeSiteConfig(value) {
276
322
  if (value !== undefined && !isPlainObject(value)) {
277
323
  throw new PrebuildConfigError(
@@ -281,24 +327,98 @@ function normalizeSiteConfig(value) {
281
327
  }
282
328
 
283
329
  const configuredSite = isPlainObject(value) ? value : {};
284
- assertKnownConfigKeys(configuredSite, ['title', 'description', 'url', 'expose_generator', 'search', 'indexing', 'footer'], 'site');
330
+ assertKnownConfigKeys(configuredSite, ['title', 'description', 'url', 'logo', 'locale', 'expose_generator', 'search', 'indexing', 'footer', 'meta'], 'site');
285
331
  const site = {
286
332
  title: readConfigString(configuredSite.title, 'Documentation'),
287
333
  description: readConfigString(configuredSite.description, 'A documentation site.'),
288
334
  url: readEnv('ZEROPRESS_SITE_URL', readConfigString(configuredSite.url, '')),
335
+ locale: normalizeSiteLocale(configuredSite.locale),
289
336
  expose_generator: readConfigBoolean(configuredSite.expose_generator, true, 'site.expose_generator'),
290
337
  search: readConfigBoolean(configuredSite.search, true, 'site.search'),
291
338
  indexing: readConfigBoolean(configuredSite.indexing, true, 'site.indexing'),
292
339
  };
293
340
 
341
+ const logo = normalizeSiteLogo(configuredSite.logo);
342
+ if (logo) {
343
+ site.logo = logo;
344
+ }
345
+
294
346
  const footer = normalizeFooter(configuredSite.footer);
295
347
  if (footer) {
296
348
  site.footer = footer;
297
349
  }
298
350
 
351
+ if (configuredSite.meta !== undefined) {
352
+ site.meta = normalizeSiteMeta(configuredSite.meta, 'site.meta');
353
+ }
354
+
299
355
  return site;
300
356
  }
301
357
 
358
+ function normalizeSiteLocale(value) {
359
+ if (value === undefined) {
360
+ return 'en-US';
361
+ }
362
+ if (typeof value !== 'string') {
363
+ throw new PrebuildConfigError('site.locale must be a string when provided.');
364
+ }
365
+
366
+ const locale = value.trim();
367
+ if (locale.length < 2) {
368
+ throw new PrebuildConfigError('site.locale must be a non-empty locale string such as "en-US" or "ko-KR".');
369
+ }
370
+
371
+ return locale;
372
+ }
373
+
374
+ function normalizeSiteLogo(value) {
375
+ if (value === undefined) {
376
+ return undefined;
377
+ }
378
+ if (!isPlainObject(value)) {
379
+ throw new PrebuildConfigError('site.logo must be an object when provided.');
380
+ }
381
+ assertKnownConfigKeys(value, ['src', 'alt'], 'site.logo');
382
+
383
+ const src = readConfigString(value.src, '');
384
+ if (!src) {
385
+ throw new PrebuildConfigError(
386
+ 'site.logo.src must be a non-empty URL-like string.',
387
+ ' "logo": { "src": "/logo.svg", "alt": "My Site" }',
388
+ );
389
+ }
390
+ validateUrlLikeString(src, 'site.logo.src');
391
+
392
+ const logo = { src };
393
+ if (value.alt !== undefined) {
394
+ if (typeof value.alt !== 'string') {
395
+ throw new PrebuildConfigError('site.logo.alt must be a string when provided.');
396
+ }
397
+ const alt = value.alt.trim();
398
+ if (alt) {
399
+ logo.alt = alt;
400
+ }
401
+ }
402
+
403
+ return logo;
404
+ }
405
+
406
+ function normalizeSiteMeta(value, pathLabel) {
407
+ if (!isPlainObject(value)) {
408
+ throw new PrebuildConfigError(`${pathLabel} must be an object when provided.`);
409
+ }
410
+
411
+ const meta = {};
412
+ for (const [key, metaValue] of Object.entries(value)) {
413
+ if (!isPreviewMetaValue(metaValue)) {
414
+ throw new PrebuildConfigError(`${pathLabel}.${key} must be a string, number, boolean, or null.`);
415
+ }
416
+ meta[key] = metaValue;
417
+ }
418
+
419
+ return meta;
420
+ }
421
+
302
422
  function normalizeFooter(value) {
303
423
  if (value === undefined) {
304
424
  return undefined;
@@ -324,6 +444,25 @@ function normalizeFooter(value) {
324
444
  return Object.keys(footer).length ? footer : undefined;
325
445
  }
326
446
 
447
+ function validateUrlLikeString(value, pathLabel) {
448
+ if (value.startsWith('//')) {
449
+ throw new PrebuildConfigError(`${pathLabel} must be an absolute URL or a safe relative path.`);
450
+ }
451
+
452
+ if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) {
453
+ return;
454
+ }
455
+
456
+ try {
457
+ const url = new URL(value);
458
+ if (!url.protocol || !url.hostname) {
459
+ throw new Error('missing host');
460
+ }
461
+ } catch {
462
+ throw new PrebuildConfigError(`${pathLabel} must be an absolute URL or a safe relative path.`);
463
+ }
464
+ }
465
+
327
466
  function readConfigBoolean(value, fallback, pathName) {
328
467
  if (value === undefined) {
329
468
  return fallback;
@@ -752,6 +891,79 @@ function defaultMenus() {
752
891
  };
753
892
  }
754
893
 
894
+ function normalizeCollections(value, pageInputs, skippedMarkdown) {
895
+ if (value === undefined) {
896
+ return {};
897
+ }
898
+ if (!isPlainObject(value)) {
899
+ throw new PrebuildConfigError('collections must be an object keyed by collection id.');
900
+ }
901
+
902
+ const pageBySourcePath = new Map(pageInputs.map((pageInput) => [pageInput.sourcePath, pageInput]));
903
+ const skippedByFile = new Map(
904
+ skippedMarkdown.map((entry) => [path.resolve(rootDir, entry.file), entry.reason]),
905
+ );
906
+ const collections = {};
907
+
908
+ for (const [collectionId, collection] of Object.entries(value)) {
909
+ validateConfigId(collectionId, `collections.${collectionId}`);
910
+ if (!isPlainObject(collection)) {
911
+ throw new PrebuildConfigError(`collections.${collectionId} must be an object.`);
912
+ }
913
+ assertKnownConfigKeys(collection, ['title', 'description', 'items'], `collections.${collectionId}`);
914
+ if (!Array.isArray(collection.items)) {
915
+ throw new PrebuildConfigError(`collections.${collectionId}.items must be an array of Markdown source paths.`);
916
+ }
917
+
918
+ const seenSourcePaths = new Set();
919
+ const items = collection.items.map((item, index) => {
920
+ const pathLabel = `collections.${collectionId}.items[${index}]`;
921
+ const normalizedPath = resolveCollectionSourcePath(item, pathLabel);
922
+ const sourcePath = path.resolve(sourceDir, normalizedPath);
923
+ if (seenSourcePaths.has(sourcePath)) {
924
+ throw new PrebuildConfigError(`${pathLabel} duplicates ${normalizedPath} in collections.${collectionId}.`);
925
+ }
926
+ seenSourcePaths.add(sourcePath);
927
+
928
+ const pageInput = pageBySourcePath.get(sourcePath);
929
+ if (!pageInput) {
930
+ const skippedReason = skippedByFile.get(sourcePath);
931
+ if (skippedReason) {
932
+ throw new PrebuildConfigError(`${pathLabel} references skipped Markdown ${normalizedPath}: ${skippedReason}`);
933
+ }
934
+ throw new PrebuildConfigError(`${pathLabel} was not discovered as a Markdown page: ${normalizedPath}`);
935
+ }
936
+
937
+ return {
938
+ type: 'page',
939
+ slug: pageInput.route.slug,
940
+ };
941
+ });
942
+
943
+ collections[collectionId] = {
944
+ title: readConfigString(collection.title, collectionId),
945
+ ...(collection.description !== undefined ? { description: readConfigString(collection.description, '') } : {}),
946
+ items,
947
+ };
948
+ }
949
+
950
+ return collections;
951
+ }
952
+
953
+ function resolveCollectionSourcePath(value, pathLabel) {
954
+ const normalizedPath = normalizeSourceFilePath(value, pathLabel);
955
+ if (!normalizedPath.toLowerCase().endsWith('.md')) {
956
+ throw new PrebuildConfigError(`${pathLabel} must be a Markdown source path ending in .md.`);
957
+ }
958
+ return normalizedPath;
959
+ }
960
+
961
+ function validateConfigId(value, pathLabel) {
962
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(value)) {
963
+ throw new PrebuildConfigError(`${pathLabel} must use a lowercase config id such as "docs" or "reference-guides".`);
964
+ }
965
+ }
966
+
755
967
  function buildPrebuildReport({
756
968
  sourceFiles,
757
969
  pageInputs,
@@ -869,12 +1081,42 @@ function normalizePublishedFrontMatter(frontMatter, sourcePath) {
869
1081
  title: normalizeFrontMatterTitle(frontMatter.title, sourcePath),
870
1082
  description: normalizeFrontMatterDescription(frontMatter.description, sourcePath),
871
1083
  path: normalizeFrontMatterRoutePath(frontMatter.path, sourcePath),
1084
+ last_updated: normalizeFrontMatterLastUpdated(frontMatter.last_updated, sourcePath),
872
1085
  discoverability: normalizeFrontMatterDiscoverability(frontMatter.discoverability, sourcePath),
873
1086
  meta: normalizeFrontMatterMeta(frontMatter.meta, sourcePath),
874
1087
  data: normalizeFrontMatterData(frontMatter.data, sourcePath),
875
1088
  };
876
1089
  }
877
1090
 
1091
+ function normalizeLastUpdatedPolicy(value, pathLabel, ErrorClass, sourcePath = null) {
1092
+ if (value === undefined) {
1093
+ return 'none';
1094
+ }
1095
+ if (typeof value === 'string' && MARKDOWN_LAST_UPDATED_VALUES.has(value)) {
1096
+ return value;
1097
+ }
1098
+
1099
+ if (ErrorClass === PrebuildMarkdownError) {
1100
+ throw new ErrorClass(
1101
+ sourcePath,
1102
+ `${pathLabel} must be one of: ${Array.from(MARKDOWN_LAST_UPDATED_VALUES).join(', ')}.`,
1103
+ ' last_updated: none\n last_updated: git',
1104
+ );
1105
+ }
1106
+
1107
+ throw new ErrorClass(
1108
+ `${pathLabel} must be one of: ${Array.from(MARKDOWN_LAST_UPDATED_VALUES).join(', ')}.`,
1109
+ ' "markdown": { "last_updated": "none" }\n "markdown": { "last_updated": "git" }',
1110
+ );
1111
+ }
1112
+
1113
+ function normalizeFrontMatterLastUpdated(value, sourcePath) {
1114
+ if (value === undefined) {
1115
+ return undefined;
1116
+ }
1117
+ return normalizeLastUpdatedPolicy(value, 'front matter last_updated', PrebuildMarkdownError, sourcePath);
1118
+ }
1119
+
878
1120
  function normalizeFrontMatterTitle(value, sourcePath) {
879
1121
  if (value === undefined) {
880
1122
  return '';
@@ -981,6 +1223,87 @@ function normalizeFrontMatterMeta(value, sourcePath) {
981
1223
  return meta;
982
1224
  }
983
1225
 
1226
+ async function buildPageMeta(sourcePath, frontMatter, markdownConfig) {
1227
+ const meta = {
1228
+ ...frontMatter.meta,
1229
+ };
1230
+
1231
+ if (hasManualLastUpdatedMeta(meta)) {
1232
+ return meta;
1233
+ }
1234
+
1235
+ const lastUpdatedPolicy = frontMatter.last_updated || markdownConfig.last_updated;
1236
+ if (lastUpdatedPolicy !== 'git') {
1237
+ return meta;
1238
+ }
1239
+
1240
+ const lastUpdatedIso = await readGitLastUpdatedIso(sourcePath);
1241
+ if (!lastUpdatedIso) {
1242
+ return meta;
1243
+ }
1244
+
1245
+ return {
1246
+ ...meta,
1247
+ last_updated_iso: lastUpdatedIso,
1248
+ last_updated: lastUpdatedIso.slice(0, 10),
1249
+ };
1250
+ }
1251
+
1252
+ function hasManualLastUpdatedMeta(meta) {
1253
+ return (
1254
+ Object.hasOwn(meta, 'last_updated')
1255
+ || Object.hasOwn(meta, 'last_updated_iso')
1256
+ );
1257
+ }
1258
+
1259
+ async function readGitLastUpdatedIso(sourcePath) {
1260
+ const realSourcePath = await resolveRealPath(sourcePath);
1261
+ const realRootDir = await resolveRealPath(rootDir);
1262
+ const gitPath = path.relative(realRootDir, realSourcePath);
1263
+ try {
1264
+ const { stdout } = await execFileAsync('git', [
1265
+ '-C',
1266
+ realRootDir,
1267
+ 'log',
1268
+ '-1',
1269
+ '--format=%cI',
1270
+ '--',
1271
+ gitPath,
1272
+ ], {
1273
+ encoding: 'utf8',
1274
+ });
1275
+
1276
+ const value = stdout.trim();
1277
+ if (!value) {
1278
+ warnGitLastUpdated(sourcePath, 'no commit date was found for this file.');
1279
+ return '';
1280
+ }
1281
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) {
1282
+ warnGitLastUpdated(sourcePath, `unexpected git date output: ${value}`);
1283
+ return '';
1284
+ }
1285
+ return value;
1286
+ } catch (error) {
1287
+ warnGitLastUpdated(sourcePath, error instanceof Error ? error.message : String(error));
1288
+ return '';
1289
+ }
1290
+ }
1291
+
1292
+ async function resolveRealPath(value) {
1293
+ try {
1294
+ return await fs.realpath(value);
1295
+ } catch {
1296
+ return value;
1297
+ }
1298
+ }
1299
+
1300
+ function warnGitLastUpdated(sourcePath, reason) {
1301
+ console.warn([
1302
+ `[zeropress-build-pages] Warning: could not read git last_updated for ${formatSourcePath(sourcePath)}.`,
1303
+ `Reason: ${reason}`,
1304
+ ].join('\n'));
1305
+ }
1306
+
984
1307
  function isPreviewMetaValue(value) {
985
1308
  return (
986
1309
  value === null
@@ -1257,11 +1580,11 @@ function buildRoutePath(relativeSourcePath, sourcePath, options = {}) {
1257
1580
  }
1258
1581
 
1259
1582
  function buildSlug(routePath) {
1260
- const segments = routePath.split('/');
1261
- const rawSlug = segments.at(-1) === 'index' && segments.length > 1
1262
- ? segments.at(-2)
1263
- : segments.at(-1);
1264
- return sanitizePathSegment(rawSlug || '');
1583
+ const segments = routePath.split('/').filter(Boolean);
1584
+ if (segments.length > 1 && segments.at(-1) === 'index') {
1585
+ segments.pop();
1586
+ }
1587
+ return sanitizePathSegment(segments.join('-') || 'index');
1265
1588
  }
1266
1589
 
1267
1590
  function sanitizePathSegment(segment) {
@@ -1,6 +1,17 @@
1
1
  <section class="shell not-found">
2
- <p class="eyebrow">404</p>
2
+ <p class="eyebrow eyebrow--error">
3
+ <span class="eyebrow__dot" aria-hidden="true"></span>
4
+ 404
5
+ </p>
3
6
  <h1>Page not found</h1>
4
7
  <p class="lede">The page you are looking for is not part of the current documentation build.</p>
5
- <a class="button-link" href="/">Return home</a>
8
+ <div class="hero__actions">
9
+ <a class="button-link" href="/">
10
+ Return home
11
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
12
+ <line x1="5" y1="12" x2="19" y2="12"></line>
13
+ <polyline points="12 5 19 12 12 19"></polyline>
14
+ </svg>
15
+ </a>
16
+ </div>
6
17
  </section>