@zeropress/build-pages 0.6.3 → 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.
@@ -21,6 +21,9 @@
21
21
  "site": {
22
22
  "$ref": "#/$defs/site"
23
23
  },
24
+ "markdown": {
25
+ "$ref": "#/$defs/markdown"
26
+ },
24
27
  "front_page": {
25
28
  "$ref": "#/$defs/frontPage"
26
29
  },
@@ -29,6 +32,9 @@
29
32
  },
30
33
  "menus": {
31
34
  "$ref": "#/$defs/menus"
35
+ },
36
+ "collections": {
37
+ "$ref": "#/$defs/collections"
32
38
  }
33
39
  },
34
40
  "$defs": {
@@ -124,6 +130,21 @@
124
130
  }
125
131
  }
126
132
  },
133
+ "markdown": {
134
+ "type": "object",
135
+ "additionalProperties": false,
136
+ "description": "Markdown source processing options.",
137
+ "markdownDescription": "Markdown source processing options.",
138
+ "properties": {
139
+ "last_updated": {
140
+ "type": "string",
141
+ "enum": ["none", "git"],
142
+ "default": "none",
143
+ "description": "Optional page meta enrichment. none does not generate last_updated values; git reads the latest commit date for each Markdown file.",
144
+ "markdownDescription": "Optional page meta enrichment. `none` does not generate `last_updated` values; `git` reads the latest commit date for each Markdown file."
145
+ }
146
+ }
147
+ },
127
148
  "frontPage": {
128
149
  "type": "object",
129
150
  "additionalProperties": false,
@@ -319,6 +340,40 @@
319
340
  "default": []
320
341
  }
321
342
  }
343
+ },
344
+ "collections": {
345
+ "type": "object",
346
+ "description": "Reading-order collections generated from Markdown source paths.",
347
+ "markdownDescription": "Reading-order collections generated from Markdown source paths.",
348
+ "propertyNames": {
349
+ "type": "string",
350
+ "pattern": "^[a-z][a-z0-9_-]{0,63}$"
351
+ },
352
+ "additionalProperties": {
353
+ "$ref": "#/$defs/collection"
354
+ }
355
+ },
356
+ "collection": {
357
+ "type": "object",
358
+ "additionalProperties": false,
359
+ "required": ["items"],
360
+ "properties": {
361
+ "title": {
362
+ "type": "string",
363
+ "minLength": 1
364
+ },
365
+ "description": {
366
+ "type": "string"
367
+ },
368
+ "items": {
369
+ "type": "array",
370
+ "items": {
371
+ "type": "string",
372
+ "minLength": 1,
373
+ "pattern": "^[^\\\\?#]+\\.md$"
374
+ }
375
+ }
376
+ }
322
377
  }
323
378
  },
324
379
  "examples": [
@@ -344,6 +399,9 @@
344
399
  "front_page": {
345
400
  "type": "markdown"
346
401
  },
402
+ "markdown": {
403
+ "last_updated": "git"
404
+ },
347
405
  "custom_html": {
348
406
  "head_end": {
349
407
  "file": ".zeropress/head-end.html"
@@ -367,6 +425,15 @@
367
425
  }
368
426
  ]
369
427
  }
428
+ },
429
+ "collections": {
430
+ "guides": {
431
+ "title": "Guides",
432
+ "items": [
433
+ "getting-started/index.md",
434
+ "deployment/index.md"
435
+ ]
436
+ }
370
437
  }
371
438
  }
372
439
  ]
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,15 +1,18 @@
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');
@@ -23,6 +26,7 @@ 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
  }
@@ -264,11 +282,12 @@ function buildSiteData(config, frontPage) {
264
282
  return site;
265
283
  }
266
284
 
267
- function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig }) {
285
+ function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig, markdownConfig }) {
268
286
  const resolvedConfig = {
269
287
  $schema: BUILD_PAGES_CONFIG_SCHEMA_URL,
270
288
  version: '0.1',
271
289
  site: normalizeSiteConfig(config.site),
290
+ markdown: markdownConfig,
272
291
  front_page: frontPageConfig,
273
292
  menus,
274
293
  };
@@ -280,6 +299,25 @@ function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig
280
299
  return resolvedConfig;
281
300
  }
282
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
+
283
321
  function normalizeSiteConfig(value) {
284
322
  if (value !== undefined && !isPlainObject(value)) {
285
323
  throw new PrebuildConfigError(
@@ -853,6 +891,79 @@ function defaultMenus() {
853
891
  };
854
892
  }
855
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
+
856
967
  function buildPrebuildReport({
857
968
  sourceFiles,
858
969
  pageInputs,
@@ -970,12 +1081,42 @@ function normalizePublishedFrontMatter(frontMatter, sourcePath) {
970
1081
  title: normalizeFrontMatterTitle(frontMatter.title, sourcePath),
971
1082
  description: normalizeFrontMatterDescription(frontMatter.description, sourcePath),
972
1083
  path: normalizeFrontMatterRoutePath(frontMatter.path, sourcePath),
1084
+ last_updated: normalizeFrontMatterLastUpdated(frontMatter.last_updated, sourcePath),
973
1085
  discoverability: normalizeFrontMatterDiscoverability(frontMatter.discoverability, sourcePath),
974
1086
  meta: normalizeFrontMatterMeta(frontMatter.meta, sourcePath),
975
1087
  data: normalizeFrontMatterData(frontMatter.data, sourcePath),
976
1088
  };
977
1089
  }
978
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
+
979
1120
  function normalizeFrontMatterTitle(value, sourcePath) {
980
1121
  if (value === undefined) {
981
1122
  return '';
@@ -1082,6 +1223,87 @@ function normalizeFrontMatterMeta(value, sourcePath) {
1082
1223
  return meta;
1083
1224
  }
1084
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
+
1085
1307
  function isPreviewMetaValue(value) {
1086
1308
  return (
1087
1309
  value === null
@@ -1358,11 +1580,11 @@ function buildRoutePath(relativeSourcePath, sourcePath, options = {}) {
1358
1580
  }
1359
1581
 
1360
1582
  function buildSlug(routePath) {
1361
- const segments = routePath.split('/');
1362
- const rawSlug = segments.at(-1) === 'index' && segments.length > 1
1363
- ? segments.at(-2)
1364
- : segments.at(-1);
1365
- 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');
1366
1588
  }
1367
1589
 
1368
1590
  function sanitizePathSegment(segment) {
@@ -200,14 +200,9 @@ a:hover {
200
200
  top: 0;
201
201
  z-index: 20;
202
202
  background: var(--header-bg);
203
- border-bottom: 1px solid transparent;
203
+ border-bottom: 1px solid var(--line);
204
204
  backdrop-filter: saturate(140%) blur(14px);
205
205
  -webkit-backdrop-filter: saturate(140%) blur(14px);
206
- transition: border-color 200ms var(--ease), background 200ms var(--ease);
207
- }
208
-
209
- .site-header.is-scrolled {
210
- border-bottom-color: var(--line);
211
206
  }
212
207
 
213
208
  .site-header__inner {
@@ -451,7 +446,11 @@ a:hover {
451
446
  transition: border-color 120ms var(--ease), background 120ms var(--ease), color 120ms var(--ease);
452
447
  }
453
448
 
454
- .theme-toggle:hover {
449
+ .theme-toggle:disabled {
450
+ cursor: not-allowed;
451
+ }
452
+
453
+ .theme-toggle:not(:disabled):hover {
455
454
  border-color: var(--line-strong);
456
455
  color: var(--accent);
457
456
  }