@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.
package/README.md CHANGED
@@ -19,7 +19,7 @@ public directory
19
19
  |
20
20
  v
21
21
  @zeropress/build-pages
22
- generates .zeropress/preview-data.json
22
+ generates .zeropress-build-page/preview-data.json
23
23
  stages public files
24
24
  |
25
25
  v
@@ -40,7 +40,7 @@ flowchart TD
40
40
  config --> buildPages
41
41
  publicFiles --> buildPages
42
42
 
43
- buildPages --> previewData[".zeropress/preview-data.json<br/>internal generated build input"]
43
+ buildPages --> previewData[".zeropress-build-page/preview-data.json<br/>internal generated build input"]
44
44
  buildPages --> stagedPublic["Staged public files"]
45
45
 
46
46
  previewData --> build["@zeropress/build"]
@@ -154,7 +154,7 @@ In the action inputs:
154
154
  - `source` is the directory that contains your Markdown pages and optional `.zeropress/config.json`. The default is `./docs`.
155
155
  - `public-dir` is the directory copied as public passthrough files. The default is `source`. If you set it explicitly, the directory must exist.
156
156
  - `destination` is the directory where the generated static site is written. The default is `./_site`.
157
- - `theme` is the bundled theme name. The default is `docs`.
157
+ - `theme` is the bundled theme name. The default is `docs`; `docs1` is an alias for `docs`.
158
158
  - `theme-path` is a custom local ZeroPress theme directory. It takes precedence over `theme`.
159
159
  - `config` is the config file path. The default is `<source>/.zeropress/config.json`.
160
160
  - `site-url` overrides the canonical site URL from config.
@@ -235,7 +235,7 @@ The CLI requires explicit input and output paths. The GitHub Action keeps safe d
235
235
  | `--source <dir>` | required | Dedicated source directory containing Markdown and optional config |
236
236
  | `--public-dir <dir>` | source | Public passthrough directory. Explicit paths must exist. |
237
237
  | `--destination <dir>` | required | Output directory |
238
- | `--theme <name>` | `docs` | Bundled theme name |
238
+ | `--theme <name>` | `docs` | Bundled theme name. `docs1` aliases `docs`. |
239
239
  | `--theme-path <dir>` | none | Custom ZeroPress theme directory |
240
240
  | `--config <path>` | `<source>/.zeropress/config.json` | Build Pages config |
241
241
  | `--site-url <url>` | config `site.url` | Canonical URL override |
@@ -270,7 +270,7 @@ Root-level public files named `favicon.ico`, `favicon.svg`, `favicon.png`, and `
270
270
 
271
271
  A root-level public `sitemap.xsl` is copied to the destination. When ZeroPress generates `sitemap.xml`, it auto-discovers that file and adds an XML stylesheet processing instruction for `/sitemap.xsl`.
272
272
 
273
- The source directory must not overlap the destination directory, the selected theme directory, or the internal `.zeropress/` working directory. An explicit public directory must be an existing dedicated directory and must not be a file, symlink, repository root, destination directory, selected theme directory, or internal `.zeropress/` working directory.
273
+ The source directory must not overlap the destination directory, the selected theme directory, or the internal `.zeropress-build-page/` working directory. An explicit public directory must be an existing dedicated directory and must not be a file, symlink, repository root, destination directory, selected theme directory, or internal `.zeropress-build-page/` working directory.
274
274
 
275
275
  If `public-dir` is inside `source`, Build Pages excludes that public subtree from Markdown page discovery.
276
276
 
@@ -312,6 +312,7 @@ description: Build a static docs site from Markdown.
312
312
  path: guides/install
313
313
  status: published
314
314
  discoverability: default
315
+ last_updated: none
315
316
  meta:
316
317
  source: docs
317
318
  data:
@@ -337,6 +338,7 @@ Supported front matter fields:
337
338
  | `path` | Generated route path, such as `guides/install` for `/guides/install`. |
338
339
  | `status` | `published` includes the page. `draft` skips the page. Other values warn and skip. |
339
340
  | `discoverability` | `default`, `noindex`, or `delist`. Missing is `default`. |
341
+ | `last_updated` | `none` or `git`. Overrides config `markdown.last_updated` for this page. |
340
342
  | `meta` | Optional scalar/null metadata copied to the generated page. |
341
343
  | `data` | Optional structured JSON-style data for theme-facing lists, facts, galleries, timelines, or swatches. |
342
344
 
@@ -352,6 +354,8 @@ Unknown front matter fields are ignored to make migration from existing Markdown
352
354
 
353
355
  `delist` is not a security or permission feature. Direct links, explicit menus, explicit collections, and body links can still expose the page.
354
356
 
357
+ `last_updated` controls optional Git-based page metadata. If config uses `markdown.last_updated: "git"`, set `last_updated: none` on landing, index, or promotional pages that should not show an update date. If config uses `none`, set `last_updated: git` on a specific information page to opt in.
358
+
355
359
  Use `meta` for small scalar flags and metadata. Use `data` when a theme should iterate structured content:
356
360
 
357
361
  ```html
@@ -411,7 +415,7 @@ progressive enhancement owned by the theme or site.
411
415
 
412
416
  Build Pages reads `<source>/.zeropress/config.json` when present. Missing config falls back to defaults.
413
417
 
414
- See the public config reference at [zeropress.dev/build-pages-config](https://zeropress.dev/build-pages-config/).
418
+ See the public config reference at [zeropress.dev/build-pages-config](https://zeropress.dev/build-pages/config/).
415
419
 
416
420
  ```json
417
421
  {
@@ -438,6 +442,9 @@ See the public config reference at [zeropress.dev/build-pages-config](https://ze
438
442
  "show_sponsor_banner": false
439
443
  }
440
444
  },
445
+ "markdown": {
446
+ "last_updated": "git"
447
+ },
441
448
  "front_page": {
442
449
  "type": "markdown"
443
450
  },
@@ -457,6 +464,15 @@ See the public config reference at [zeropress.dev/build-pages-config](https://ze
457
464
  ]
458
465
  }
459
466
  },
467
+ "collections": {
468
+ "guides": {
469
+ "title": "Guides",
470
+ "items": [
471
+ "getting-started/index.md",
472
+ "deployment/index.md"
473
+ ]
474
+ }
475
+ },
460
476
  "custom_html": {
461
477
  "head_end": { "file": ".zeropress/head-end.html" },
462
478
  "body_end": { "file": ".zeropress/body-end.html" }
@@ -475,6 +491,22 @@ HTML front page and `custom_html` files must stay inside `.zeropress/`.
475
491
 
476
492
  Menu item `meta` is optional scalar display metadata copied into generated preview-data for themes that manually iterate menus. Use it for small values such as `icon`, `badge`, or `accent`; arrays and objects are not accepted.
477
493
 
494
+ `collections` defines group-level reading order from Markdown source paths. Build Pages converts each source-relative `.md` path into preview-data collection items such as `{ "type": "page", "slug": "deployment" }`. Collection prev/next cursors stop at collection boundaries, so the last item in `collections.guides` does not continue into another collection.
495
+
496
+ `markdown.last_updated` is optional and accepts `none` or `git`. Missing or `none` keeps current behavior and generates no update date. `git` reads each Markdown file's latest Git commit date and adds `page.meta.last_updated_iso` plus `page.meta.last_updated` to generated preview-data when the page does not already define those meta keys. `last_updated_iso` keeps the Git ISO timestamp; `last_updated` is a stable `YYYY-MM-DD` fallback display string. For accurate history in GitHub Actions, configure checkout with `fetch-depth: 0`.
497
+
498
+ Themes can render the generated value with normal escaped interpolation:
499
+
500
+ ```html
501
+ {{#if page.meta.last_updated_iso}}
502
+ <time datetime="{{page.meta.last_updated_iso}}" data-zp-local-date>
503
+ {{page.meta.last_updated}}
504
+ </time>
505
+ {{/if}}
506
+ ```
507
+
508
+ Client-side progressive enhancement may replace the fallback text with a localized date. The fallback remains useful when JavaScript is unavailable.
509
+
478
510
  `site.footer.copyright_text` is rendered by the bundled docs theme when present. If it is omitted, the bundled docs theme falls back to `site.title`. ZeroPress does not add a copyright symbol automatically.
479
511
 
480
512
  The bundled docs theme shows `Published with ZeroPress.` by default. Set `site.footer.attribution` to `false` to hide it.
@@ -509,12 +541,12 @@ cp ./_site/_zeropress/search_pagefind.js ./_site/_zeropress/search.js
509
541
  rm ./_site/_zeropress/search.json
510
542
  ```
511
543
 
512
- ## Workspace Internal `.zeropress/` Files
544
+ ## Workspace Internal `.zeropress-build-page/` Files
513
545
 
514
- Build Pages reads optional site config from `<source>/.zeropress/config.json`. Separately, it writes internal working files to `.zeropress/` in the current working directory. These generated working files are not the final deploy output. The final static site is written to the `destination` directory.
546
+ Build Pages reads optional user-authored site config from `<source>/.zeropress/config.json`. Separately, it writes generated internal working files to `.zeropress-build-page/` in the current working directory. These generated working files are not the final deploy output. The final static site is written to the `destination` directory.
515
547
 
516
548
  ```txt
517
- .zeropress/
549
+ .zeropress-build-page/
518
550
  build-pages-config.json
519
551
  preview-data.json
520
552
  build-report.json
package/action.yml CHANGED
@@ -17,7 +17,7 @@ inputs:
17
17
  required: false
18
18
  default: ./_site
19
19
  theme:
20
- description: Bundled theme name. Currently supports "docs".
20
+ description: Bundled theme name. Supports "docs" and alias "docs1".
21
21
  required: false
22
22
  default: docs
23
23
  theme-path:
package/dist/action.js CHANGED
@@ -53696,7 +53696,7 @@ async function validateThemeFiles(fileMap, options2 = {}) {
53696
53696
  }
53697
53697
  const content = getText(files.get(templatePath));
53698
53698
  templateContents.set(templatePath, content);
53699
- validateTemplateSyntax(templatePath, content, { errors, runtime: manifest?.runtime || DEFAULT_RUNTIME });
53699
+ validateTemplateSyntax(templatePath, content, { errors, warnings, runtime: manifest?.runtime || DEFAULT_RUNTIME });
53700
53700
  }
53701
53701
  for (const [filePath, value] of files.entries()) {
53702
53702
  if (!filePath.startsWith("partials/") || !filePath.endsWith(".html")) {
@@ -53705,7 +53705,7 @@ async function validateThemeFiles(fileMap, options2 = {}) {
53705
53705
  const partialName = filePath.slice("partials/".length, -".html".length);
53706
53706
  const content = getText(value);
53707
53707
  partialContents.set(partialName, content);
53708
- validateTemplateSyntax(filePath, content, { errors, runtime: manifest?.runtime || DEFAULT_RUNTIME });
53708
+ validateTemplateSyntax(filePath, content, { errors, warnings, runtime: manifest?.runtime || DEFAULT_RUNTIME });
53709
53709
  }
53710
53710
  validatePartialReferences(templateContents, partialContents, { errors, runtime: manifest?.runtime || DEFAULT_RUNTIME });
53711
53711
  return {
@@ -53922,10 +53922,22 @@ function validateManifest(themeJson) {
53922
53922
  return { errors, manifest: errors.length > 0 ? void 0 : manifest };
53923
53923
  }
53924
53924
  function validateTemplateSyntax(templatePath, content, context) {
53925
- const { errors } = context;
53925
+ const { errors, warnings = [] } = context;
53926
53926
  const slotRegex = /\{\{slot:([a-zA-Z0-9_-]+)\}\}/g;
53927
53927
  const contentSlotMatches = content.match(/\{\{slot:content\}\}/g) || [];
53928
53928
  if (templatePath === "layout.html") {
53929
+ if (!startsWithHtmlDoctype(content)) {
53930
+ warnings.push(issue2(
53931
+ "MISSING_DOCTYPE",
53932
+ "layout.html",
53933
+ "layout.html should start with <!doctype html> to keep browsers in standards mode",
53934
+ "warning",
53935
+ {
53936
+ category: "theme_validation",
53937
+ hint: "Add <!doctype html> before the opening <html> tag."
53938
+ }
53939
+ ));
53940
+ }
53929
53941
  if (contentSlotMatches.length !== 1) {
53930
53942
  errors.push(issue2("INVALID_LAYOUT_SLOT", "layout.html", "layout.html must contain exactly one {{slot:content}}", "error"));
53931
53943
  }
@@ -53957,6 +53969,9 @@ function validateTemplateSyntax(templatePath, content, context) {
53957
53969
  }
53958
53970
  validateRuntimeV05TemplateSyntax(templatePath, content, errors);
53959
53971
  }
53972
+ function startsWithHtmlDoctype(content) {
53973
+ return /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype\s+html\s*>/i.test(content);
53974
+ }
53960
53975
  function validateRuntimeV05TemplateSyntax(templatePath, content, errors) {
53961
53976
  const stack = [];
53962
53977
  let index = 0;
@@ -61874,8 +61889,12 @@ function attachCollectionCursors(posts, pages, collections) {
61874
61889
  if (!target) {
61875
61890
  return;
61876
61891
  }
61892
+ const cursor = buildCollectionCursor(collectionId, collection, items, index);
61877
61893
  target.collection_cursors = target.collection_cursors || {};
61878
- target.collection_cursors[collectionId] = buildCollectionCursor(collectionId, collection, items, index);
61894
+ target.collection_cursors[collectionId] = cursor;
61895
+ if (!target.collection_cursor) {
61896
+ target.collection_cursor = cursor;
61897
+ }
61879
61898
  });
61880
61899
  }
61881
61900
  }
@@ -64222,9 +64241,13 @@ async function linkExists(siteDir, link2) {
64222
64241
  var __dirname = path3.dirname(fileURLToPath(import.meta.url));
64223
64242
  var packageDir = path3.resolve(__dirname, "..");
64224
64243
  var prebuildScript = __dirname === path3.join(packageDir, "dist") ? path3.join(__dirname, "prebuild.js") : path3.join(packageDir, "src", "prebuild.js");
64225
- var PREVIEW_DATA_PATH = ".zeropress/preview-data.json";
64226
- var STAGING_DIR = ".zeropress/public-assets";
64227
- var DEFAULT_THEME = "docs";
64244
+ var INTERNAL_WORK_DIR = ".zeropress-build-page";
64245
+ var PREVIEW_DATA_PATH = `${INTERNAL_WORK_DIR}/preview-data.json`;
64246
+ var STAGING_DIR = `${INTERNAL_WORK_DIR}/public-assets`;
64247
+ var BUNDLED_THEME_ALIASES = /* @__PURE__ */ new Map([
64248
+ ["docs", "docs"],
64249
+ ["docs1", "docs"]
64250
+ ]);
64228
64251
  async function runBuildPages(options2) {
64229
64252
  const cwd = path3.resolve(options2.cwd || process.cwd());
64230
64253
  const copyMarkdownSource = options2.copyMarkdownSource !== false;
@@ -64232,7 +64255,7 @@ async function runBuildPages(options2) {
64232
64255
  const publicDirExplicit = hasExplicitPublicDir(options2);
64233
64256
  const publicDir = publicDirExplicit ? path3.resolve(cwd, options2.publicDir) : sourceDir;
64234
64257
  const destinationDir = path3.resolve(cwd, options2.destination);
64235
- const generatedDir = path3.join(cwd, ".zeropress");
64258
+ const generatedDir = path3.join(cwd, INTERNAL_WORK_DIR);
64236
64259
  const stagingDir = path3.join(cwd, STAGING_DIR);
64237
64260
  const previewDataPath = path3.join(cwd, PREVIEW_DATA_PATH);
64238
64261
  const themeDir = resolveThemeDir(cwd, options2);
@@ -64246,6 +64269,7 @@ async function runBuildPages(options2) {
64246
64269
  generatedDir
64247
64270
  });
64248
64271
  await assertDirectory(sourceDir, "Source directory");
64272
+ await assertDirectory(themeDir, "Theme directory");
64249
64273
  await assertPublicDirectory(publicDir, publicDirExplicit);
64250
64274
  await assertDestinationPath(destinationDir);
64251
64275
  await fs3.rm(generatedDir, { recursive: true, force: true });
@@ -64289,7 +64313,7 @@ async function runBuildPages(options2) {
64289
64313
  process.env.ZEROPRESS_PUBLIC_DIR = stagingDir;
64290
64314
  try {
64291
64315
  const result = await runBuild(themeDir, previewData, destinationDir);
64292
- console.log("Built ZeroPress Pages site successfully");
64316
+ console.log(formatBuildPagesSuccessMessage());
64293
64317
  console.log(`Files: ${result.files.length}`);
64294
64318
  console.log(`Output: ${formatPath(cwd, destinationDir)}`);
64295
64319
  } finally {
@@ -64310,14 +64334,36 @@ async function runBuildPages(options2) {
64310
64334
  console.log(`Checked ${result.htmlFiles.length} HTML files for internal links`);
64311
64335
  }
64312
64336
  }
64337
+ function formatBuildPagesSuccessMessage(stream = process.stdout) {
64338
+ return createColor2(stream).green("Built ZeroPress Pages site successfully");
64339
+ }
64340
+ function createColor2(stream) {
64341
+ const enabled = colorsEnabled(stream);
64342
+ const wrap = (code2, value) => enabled ? `\x1B[${code2}m${value}\x1B[0m` : value;
64343
+ return {
64344
+ red: (value) => wrap("31", value),
64345
+ yellow: (value) => wrap("33", value),
64346
+ green: (value) => wrap("32", value)
64347
+ };
64348
+ }
64349
+ function colorsEnabled(stream) {
64350
+ if (process.env.NO_COLOR) {
64351
+ return false;
64352
+ }
64353
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
64354
+ return true;
64355
+ }
64356
+ return Boolean(stream?.isTTY);
64357
+ }
64313
64358
  function resolveThemeDir(cwd, options2) {
64314
64359
  if (options2.themePath) {
64315
64360
  return path3.resolve(cwd, options2.themePath);
64316
64361
  }
64317
- if (options2.theme === DEFAULT_THEME) {
64318
- return path3.join(packageDir, "themes", DEFAULT_THEME);
64362
+ const canonicalTheme = BUNDLED_THEME_ALIASES.get(options2.theme);
64363
+ if (canonicalTheme) {
64364
+ return path3.join(packageDir, "themes", canonicalTheme);
64319
64365
  }
64320
- throw new Error(`Unknown bundled theme: ${options2.theme}`);
64366
+ throw new Error(`Unknown bundled theme: ${options2.theme}. Supported bundled themes: ${Array.from(BUNDLED_THEME_ALIASES.keys()).join(", ")}`);
64321
64367
  }
64322
64368
  function hasExplicitPublicDir(options2) {
64323
64369
  return typeof options2.publicDir === "string" && Boolean(options2.publicDir.trim());
@@ -64389,11 +64435,11 @@ function assertBuildPagesPathLayout({
64389
64435
  `Public directory must be a dedicated asset directory, not the current working directory. Received: ${formatPath(cwd, publicDir)}`
64390
64436
  );
64391
64437
  }
64392
- assertNoPathOverlap(cwd, "Source directory", sourceDir, "internal .zeropress working directory", generatedDir);
64393
- assertNoPathOverlap(cwd, "Destination directory", destinationDir, "internal .zeropress working directory", generatedDir);
64394
- assertNoPathOverlap(cwd, "Theme directory", themeDir, "internal .zeropress working directory", generatedDir);
64438
+ assertNoPathOverlap(cwd, "Source directory", sourceDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
64439
+ assertNoPathOverlap(cwd, "Destination directory", destinationDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
64440
+ assertNoPathOverlap(cwd, "Theme directory", themeDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
64395
64441
  if (!samePath(publicDir, sourceDir)) {
64396
- assertNoPathOverlap(cwd, "Public directory", publicDir, "internal .zeropress working directory", generatedDir);
64442
+ assertNoPathOverlap(cwd, "Public directory", publicDir, `internal ${INTERNAL_WORK_DIR} working directory`, generatedDir);
64397
64443
  assertNoPathOverlap(cwd, "Public directory", publicDir, "destination directory", destinationDir);
64398
64444
  assertNoPathOverlap(cwd, "Public directory", publicDir, "theme directory", themeDir);
64399
64445
  }
package/dist/prebuild.js CHANGED
@@ -3519,15 +3519,18 @@ var require_gray_matter = __commonJS({
3519
3519
  // src/prebuild.js
3520
3520
  var import_gray_matter = __toESM(require_gray_matter(), 1);
3521
3521
  import fs from "node:fs/promises";
3522
+ import { execFile } from "node:child_process";
3522
3523
  import path from "node:path";
3524
+ import { promisify } from "node:util";
3523
3525
  import { fileURLToPath } from "node:url";
3524
3526
  var __dirname = path.dirname(fileURLToPath(import.meta.url));
3527
+ var execFileAsync = promisify(execFile);
3525
3528
  var rootDir = process.cwd();
3526
3529
  var sourceDir = resolveEnvPath(["ZEROPRESS_BUILD_PAGES_SOURCE"], "docs");
3527
3530
  var publicDir = resolveEnvPath(["ZEROPRESS_BUILD_PAGES_PUBLIC_DIR"], sourceDir);
3528
3531
  var defaultConfigPath = path.join(sourceDir, ".zeropress", "config.json");
3529
3532
  var configPath = resolveOptionalEnvPath(["ZEROPRESS_BUILD_PAGES_CONFIG"], defaultConfigPath);
3530
- var outDir = path.join(rootDir, ".zeropress");
3533
+ var outDir = path.join(rootDir, ".zeropress-build-page");
3531
3534
  var buildPagesConfigPath = path.join(outDir, "build-pages-config.json");
3532
3535
  var previewDataPath = path.join(outDir, "preview-data.json");
3533
3536
  var buildReportPath = path.join(outDir, "build-report.json");
@@ -3541,6 +3544,7 @@ var FRONT_MATTER_DATA_MAX_DEPTH = 4;
3541
3544
  var FRONT_MATTER_DATA_MAX_KEYS = 64;
3542
3545
  var FRONT_MATTER_DATA_MAX_ARRAY_LENGTH = 256;
3543
3546
  var FRONT_MATTER_DISCOVERABILITY_VALUES = /* @__PURE__ */ new Set(["default", "noindex", "delist"]);
3547
+ var MARKDOWN_LAST_UPDATED_VALUES = /* @__PURE__ */ new Set(["none", "git"]);
3544
3548
  var markdownDiscoverExcludeRoots = buildMarkdownDiscoverExcludeRoots();
3545
3549
  var PrebuildMarkdownError = class extends Error {
3546
3550
  constructor(sourcePath, reason, expected = "", code = "invalid_markdown") {
@@ -3569,10 +3573,12 @@ async function main() {
3569
3573
  );
3570
3574
  const menus = normalizeMenus(config.menus);
3571
3575
  const customHtmlConfig = normalizeCustomHtmlConfig(config.custom_html);
3576
+ const markdownConfig = normalizeMarkdownConfig(config.markdown);
3572
3577
  const resolvedConfig = buildResolvedConfig(config, {
3573
3578
  frontPageConfig,
3574
3579
  menus,
3575
- customHtmlConfig
3580
+ customHtmlConfig,
3581
+ markdownConfig
3576
3582
  });
3577
3583
  const sourceFiles = await listMarkdownFiles(sourceDir);
3578
3584
  const skippedMarkdown = [];
@@ -3607,21 +3613,29 @@ async function main() {
3607
3613
  const routeBySourcePath = new Map(
3608
3614
  pageInputs.map(({ sourcePath, route }) => [sourcePath, route])
3609
3615
  );
3610
- const pages = pageInputs.map(({ sourcePath, bodyMarkdown, frontMatter, title, route }) => ({
3611
- title,
3612
- slug: route.slug,
3613
- path: route.path,
3614
- meta: {
3615
- ...frontMatter.meta,
3616
- ...copyMarkdownSource ? { source_markdown_url: buildSourceMarkdownUrl(sourcePath) } : {}
3617
- },
3618
- ...frontMatter.data !== void 0 ? { data: frontMatter.data } : {},
3619
- ...frontMatter.discoverability !== "default" ? { discoverability: frontMatter.discoverability } : {},
3620
- content: rewriteMarkdownLinks(bodyMarkdown, sourcePath, routeBySourcePath),
3621
- document_type: "markdown",
3622
- excerpt: frontMatter.description || extractExcerpt(bodyMarkdown, title),
3623
- status: "published"
3624
- }));
3616
+ const collections = normalizeCollections(config.collections, pageInputs, skippedMarkdown);
3617
+ if (Object.keys(collections).length > 0) {
3618
+ resolvedConfig.collections = collections;
3619
+ }
3620
+ const pages = [];
3621
+ for (const { sourcePath, bodyMarkdown, frontMatter, title, route } of pageInputs) {
3622
+ const meta = await buildPageMeta(sourcePath, frontMatter, markdownConfig);
3623
+ pages.push({
3624
+ title,
3625
+ slug: route.slug,
3626
+ path: route.path,
3627
+ meta: {
3628
+ ...meta,
3629
+ ...copyMarkdownSource ? { source_markdown_url: buildSourceMarkdownUrl(sourcePath) } : {}
3630
+ },
3631
+ ...frontMatter.data !== void 0 ? { data: frontMatter.data } : {},
3632
+ ...frontMatter.discoverability !== "default" ? { discoverability: frontMatter.discoverability } : {},
3633
+ content: rewriteMarkdownLinks(bodyMarkdown, sourcePath, routeBySourcePath),
3634
+ document_type: "markdown",
3635
+ excerpt: frontMatter.description || extractExcerpt(bodyMarkdown, title),
3636
+ status: "published"
3637
+ });
3638
+ }
3625
3639
  const frontPageResult = await buildFrontPageData(frontPageConfig, pageInputs, resolvedConfig);
3626
3640
  if (frontPageResult.page) {
3627
3641
  pages.push(frontPageResult.page);
@@ -3644,6 +3658,9 @@ async function main() {
3644
3658
  menus,
3645
3659
  widgets: {}
3646
3660
  };
3661
+ if (Object.keys(collections).length > 0) {
3662
+ previewData.collections = collections;
3663
+ }
3647
3664
  if (customHtml) {
3648
3665
  previewData.custom_html = customHtml;
3649
3666
  }
@@ -3756,11 +3773,12 @@ function buildSiteData(config, frontPage) {
3756
3773
  }
3757
3774
  return site;
3758
3775
  }
3759
- function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig }) {
3776
+ function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig, markdownConfig }) {
3760
3777
  const resolvedConfig = {
3761
3778
  $schema: BUILD_PAGES_CONFIG_SCHEMA_URL,
3762
3779
  version: "0.1",
3763
3780
  site: normalizeSiteConfig(config.site),
3781
+ markdown: markdownConfig,
3764
3782
  front_page: frontPageConfig,
3765
3783
  menus
3766
3784
  };
@@ -3769,6 +3787,23 @@ function buildResolvedConfig(config, { frontPageConfig, menus, customHtmlConfig
3769
3787
  }
3770
3788
  return resolvedConfig;
3771
3789
  }
3790
+ function normalizeMarkdownConfig(value) {
3791
+ if (value === void 0) {
3792
+ return {
3793
+ last_updated: "none"
3794
+ };
3795
+ }
3796
+ if (!isPlainObject(value)) {
3797
+ throw new PrebuildConfigError(
3798
+ "markdown must be an object.",
3799
+ ' "markdown": { "last_updated": "git" }'
3800
+ );
3801
+ }
3802
+ assertKnownConfigKeys(value, ["last_updated"], "markdown");
3803
+ return {
3804
+ last_updated: normalizeLastUpdatedPolicy(value.last_updated, "markdown.last_updated", PrebuildConfigError)
3805
+ };
3806
+ }
3772
3807
  function normalizeSiteConfig(value) {
3773
3808
  if (value !== void 0 && !isPlainObject(value)) {
3774
3809
  throw new PrebuildConfigError(
@@ -4260,6 +4295,69 @@ function defaultMenus() {
4260
4295
  }
4261
4296
  };
4262
4297
  }
4298
+ function normalizeCollections(value, pageInputs, skippedMarkdown) {
4299
+ if (value === void 0) {
4300
+ return {};
4301
+ }
4302
+ if (!isPlainObject(value)) {
4303
+ throw new PrebuildConfigError("collections must be an object keyed by collection id.");
4304
+ }
4305
+ const pageBySourcePath = new Map(pageInputs.map((pageInput) => [pageInput.sourcePath, pageInput]));
4306
+ const skippedByFile = new Map(
4307
+ skippedMarkdown.map((entry) => [path.resolve(rootDir, entry.file), entry.reason])
4308
+ );
4309
+ const collections = {};
4310
+ for (const [collectionId, collection] of Object.entries(value)) {
4311
+ validateConfigId(collectionId, `collections.${collectionId}`);
4312
+ if (!isPlainObject(collection)) {
4313
+ throw new PrebuildConfigError(`collections.${collectionId} must be an object.`);
4314
+ }
4315
+ assertKnownConfigKeys(collection, ["title", "description", "items"], `collections.${collectionId}`);
4316
+ if (!Array.isArray(collection.items)) {
4317
+ throw new PrebuildConfigError(`collections.${collectionId}.items must be an array of Markdown source paths.`);
4318
+ }
4319
+ const seenSourcePaths = /* @__PURE__ */ new Set();
4320
+ const items = collection.items.map((item, index) => {
4321
+ const pathLabel = `collections.${collectionId}.items[${index}]`;
4322
+ const normalizedPath = resolveCollectionSourcePath(item, pathLabel);
4323
+ const sourcePath = path.resolve(sourceDir, normalizedPath);
4324
+ if (seenSourcePaths.has(sourcePath)) {
4325
+ throw new PrebuildConfigError(`${pathLabel} duplicates ${normalizedPath} in collections.${collectionId}.`);
4326
+ }
4327
+ seenSourcePaths.add(sourcePath);
4328
+ const pageInput = pageBySourcePath.get(sourcePath);
4329
+ if (!pageInput) {
4330
+ const skippedReason = skippedByFile.get(sourcePath);
4331
+ if (skippedReason) {
4332
+ throw new PrebuildConfigError(`${pathLabel} references skipped Markdown ${normalizedPath}: ${skippedReason}`);
4333
+ }
4334
+ throw new PrebuildConfigError(`${pathLabel} was not discovered as a Markdown page: ${normalizedPath}`);
4335
+ }
4336
+ return {
4337
+ type: "page",
4338
+ slug: pageInput.route.slug
4339
+ };
4340
+ });
4341
+ collections[collectionId] = {
4342
+ title: readConfigString(collection.title, collectionId),
4343
+ ...collection.description !== void 0 ? { description: readConfigString(collection.description, "") } : {},
4344
+ items
4345
+ };
4346
+ }
4347
+ return collections;
4348
+ }
4349
+ function resolveCollectionSourcePath(value, pathLabel) {
4350
+ const normalizedPath = normalizeSourceFilePath(value, pathLabel);
4351
+ if (!normalizedPath.toLowerCase().endsWith(".md")) {
4352
+ throw new PrebuildConfigError(`${pathLabel} must be a Markdown source path ending in .md.`);
4353
+ }
4354
+ return normalizedPath;
4355
+ }
4356
+ function validateConfigId(value, pathLabel) {
4357
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(value)) {
4358
+ throw new PrebuildConfigError(`${pathLabel} must use a lowercase config id such as "docs" or "reference-guides".`);
4359
+ }
4360
+ }
4263
4361
  function buildPrebuildReport({
4264
4362
  sourceFiles,
4265
4363
  pageInputs,
@@ -4368,11 +4466,37 @@ function normalizePublishedFrontMatter(frontMatter, sourcePath) {
4368
4466
  title: normalizeFrontMatterTitle(frontMatter.title, sourcePath),
4369
4467
  description: normalizeFrontMatterDescription(frontMatter.description, sourcePath),
4370
4468
  path: normalizeFrontMatterRoutePath(frontMatter.path, sourcePath),
4469
+ last_updated: normalizeFrontMatterLastUpdated(frontMatter.last_updated, sourcePath),
4371
4470
  discoverability: normalizeFrontMatterDiscoverability(frontMatter.discoverability, sourcePath),
4372
4471
  meta: normalizeFrontMatterMeta(frontMatter.meta, sourcePath),
4373
4472
  data: normalizeFrontMatterData(frontMatter.data, sourcePath)
4374
4473
  };
4375
4474
  }
4475
+ function normalizeLastUpdatedPolicy(value, pathLabel, ErrorClass, sourcePath = null) {
4476
+ if (value === void 0) {
4477
+ return "none";
4478
+ }
4479
+ if (typeof value === "string" && MARKDOWN_LAST_UPDATED_VALUES.has(value)) {
4480
+ return value;
4481
+ }
4482
+ if (ErrorClass === PrebuildMarkdownError) {
4483
+ throw new ErrorClass(
4484
+ sourcePath,
4485
+ `${pathLabel} must be one of: ${Array.from(MARKDOWN_LAST_UPDATED_VALUES).join(", ")}.`,
4486
+ " last_updated: none\n last_updated: git"
4487
+ );
4488
+ }
4489
+ throw new ErrorClass(
4490
+ `${pathLabel} must be one of: ${Array.from(MARKDOWN_LAST_UPDATED_VALUES).join(", ")}.`,
4491
+ ' "markdown": { "last_updated": "none" }\n "markdown": { "last_updated": "git" }'
4492
+ );
4493
+ }
4494
+ function normalizeFrontMatterLastUpdated(value, sourcePath) {
4495
+ if (value === void 0) {
4496
+ return void 0;
4497
+ }
4498
+ return normalizeLastUpdatedPolicy(value, "front matter last_updated", PrebuildMarkdownError, sourcePath);
4499
+ }
4376
4500
  function normalizeFrontMatterTitle(value, sourcePath) {
4377
4501
  if (value === void 0) {
4378
4502
  return "";
@@ -4456,6 +4580,74 @@ function normalizeFrontMatterMeta(value, sourcePath) {
4456
4580
  }
4457
4581
  return meta;
4458
4582
  }
4583
+ async function buildPageMeta(sourcePath, frontMatter, markdownConfig) {
4584
+ const meta = {
4585
+ ...frontMatter.meta
4586
+ };
4587
+ if (hasManualLastUpdatedMeta(meta)) {
4588
+ return meta;
4589
+ }
4590
+ const lastUpdatedPolicy = frontMatter.last_updated || markdownConfig.last_updated;
4591
+ if (lastUpdatedPolicy !== "git") {
4592
+ return meta;
4593
+ }
4594
+ const lastUpdatedIso = await readGitLastUpdatedIso(sourcePath);
4595
+ if (!lastUpdatedIso) {
4596
+ return meta;
4597
+ }
4598
+ return {
4599
+ ...meta,
4600
+ last_updated_iso: lastUpdatedIso,
4601
+ last_updated: lastUpdatedIso.slice(0, 10)
4602
+ };
4603
+ }
4604
+ function hasManualLastUpdatedMeta(meta) {
4605
+ return Object.hasOwn(meta, "last_updated") || Object.hasOwn(meta, "last_updated_iso");
4606
+ }
4607
+ async function readGitLastUpdatedIso(sourcePath) {
4608
+ const realSourcePath = await resolveRealPath(sourcePath);
4609
+ const realRootDir = await resolveRealPath(rootDir);
4610
+ const gitPath = path.relative(realRootDir, realSourcePath);
4611
+ try {
4612
+ const { stdout } = await execFileAsync("git", [
4613
+ "-C",
4614
+ realRootDir,
4615
+ "log",
4616
+ "-1",
4617
+ "--format=%cI",
4618
+ "--",
4619
+ gitPath
4620
+ ], {
4621
+ encoding: "utf8"
4622
+ });
4623
+ const value = stdout.trim();
4624
+ if (!value) {
4625
+ warnGitLastUpdated(sourcePath, "no commit date was found for this file.");
4626
+ return "";
4627
+ }
4628
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) {
4629
+ warnGitLastUpdated(sourcePath, `unexpected git date output: ${value}`);
4630
+ return "";
4631
+ }
4632
+ return value;
4633
+ } catch (error) {
4634
+ warnGitLastUpdated(sourcePath, error instanceof Error ? error.message : String(error));
4635
+ return "";
4636
+ }
4637
+ }
4638
+ async function resolveRealPath(value) {
4639
+ try {
4640
+ return await fs.realpath(value);
4641
+ } catch {
4642
+ return value;
4643
+ }
4644
+ }
4645
+ function warnGitLastUpdated(sourcePath, reason) {
4646
+ console.warn([
4647
+ `[zeropress-build-pages] Warning: could not read git last_updated for ${formatSourcePath(sourcePath)}.`,
4648
+ `Reason: ${reason}`
4649
+ ].join("\n"));
4650
+ }
4459
4651
  function isPreviewMetaValue(value) {
4460
4652
  return value === null || typeof value === "string" || typeof value === "number" && Number.isFinite(value) || typeof value === "boolean";
4461
4653
  }
@@ -4673,9 +4865,11 @@ function buildRoutePath(relativeSourcePath, sourcePath, options2 = {}) {
4673
4865
  return routePath;
4674
4866
  }
4675
4867
  function buildSlug(routePath) {
4676
- const segments = routePath.split("/");
4677
- const rawSlug = segments.at(-1) === "index" && segments.length > 1 ? segments.at(-2) : segments.at(-1);
4678
- return sanitizePathSegment(rawSlug || "");
4868
+ const segments = routePath.split("/").filter(Boolean);
4869
+ if (segments.length > 1 && segments.at(-1) === "index") {
4870
+ segments.pop();
4871
+ }
4872
+ return sanitizePathSegment(segments.join("-") || "index");
4679
4873
  }
4680
4874
  function sanitizePathSegment(segment) {
4681
4875
  return segment.replace(/[^a-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-pages",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "ZeroPress Markdown build action and CLI for static hosting",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,7 +40,7 @@
40
40
  "node": ">=18.18.0"
41
41
  },
42
42
  "dependencies": {
43
- "@zeropress/build": "0.6.3",
43
+ "@zeropress/build": "0.6.4",
44
44
  "gray-matter": "4.0.3"
45
45
  },
46
46
  "devDependencies": {