@zeropress/build-pages 0.5.6 → 0.6.2

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/dist/prebuild.js CHANGED
@@ -3524,6 +3524,7 @@ import { fileURLToPath } from "node:url";
3524
3524
  var __dirname = path.dirname(fileURLToPath(import.meta.url));
3525
3525
  var rootDir = process.cwd();
3526
3526
  var sourceDir = resolveEnvPath(["ZEROPRESS_BUILD_PAGES_SOURCE"], "docs");
3527
+ var publicDir = resolveEnvPath(["ZEROPRESS_BUILD_PAGES_PUBLIC_DIR"], sourceDir);
3527
3528
  var defaultConfigPath = path.join(sourceDir, ".zeropress", "config.json");
3528
3529
  var configPath = resolveOptionalEnvPath(["ZEROPRESS_BUILD_PAGES_CONFIG"], defaultConfigPath);
3529
3530
  var outDir = path.join(rootDir, ".zeropress");
@@ -3534,7 +3535,13 @@ var skipUntitledMarkdown = readBooleanEnv("ZEROPRESS_SKIP_UNTITLED_MARKDOWN");
3534
3535
  var copyMarkdownSource = readBooleanEnv("ZEROPRESS_COPY_MARKDOWN_SOURCE", true);
3535
3536
  var FRONT_PAGE_TYPES = /* @__PURE__ */ new Set(["theme_index", "markdown", "html"]);
3536
3537
  var BUILD_PAGES_CONFIG_SCHEMA_URL = "https://zeropress.dev/schemas/zeropress-build-pages.config.v0.1.schema.json";
3537
- var PREVIEW_DATA_SCHEMA_URL = "https://zeropress.dev/schemas/preview-data.v0.5.schema.json";
3538
+ var PREVIEW_DATA_SCHEMA_URL = "https://zeropress.dev/schemas/preview-data.v0.6.schema.json";
3539
+ var FRONT_MATTER_DATA_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*$/;
3540
+ var FRONT_MATTER_DATA_MAX_DEPTH = 4;
3541
+ var FRONT_MATTER_DATA_MAX_KEYS = 64;
3542
+ var FRONT_MATTER_DATA_MAX_ARRAY_LENGTH = 256;
3543
+ var FRONT_MATTER_DISCOVERABILITY_VALUES = /* @__PURE__ */ new Set(["default", "noindex", "delist"]);
3544
+ var markdownDiscoverExcludeRoots = buildMarkdownDiscoverExcludeRoots();
3538
3545
  var PrebuildMarkdownError = class extends Error {
3539
3546
  constructor(sourcePath, reason, expected = "", code = "invalid_markdown") {
3540
3547
  super(reason);
@@ -3608,6 +3615,8 @@ async function main() {
3608
3615
  ...frontMatter.meta,
3609
3616
  ...copyMarkdownSource ? { source_markdown_url: buildSourceMarkdownUrl(sourcePath) } : {}
3610
3617
  },
3618
+ ...frontMatter.data !== void 0 ? { data: frontMatter.data } : {},
3619
+ ...frontMatter.discoverability !== "default" ? { discoverability: frontMatter.discoverability } : {},
3611
3620
  content: rewriteMarkdownLinks(bodyMarkdown, sourcePath, routeBySourcePath),
3612
3621
  document_type: "markdown",
3613
3622
  excerpt: frontMatter.description || extractExcerpt(bodyMarkdown, title),
@@ -3621,7 +3630,7 @@ async function main() {
3621
3630
  const customHtml = await buildCustomHtmlData(customHtmlConfig);
3622
3631
  const previewData = {
3623
3632
  $schema: PREVIEW_DATA_SCHEMA_URL,
3624
- version: "0.5",
3633
+ version: "0.6",
3625
3634
  generator: "zeropress-build-pages",
3626
3635
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3627
3636
  site,
@@ -3719,18 +3728,21 @@ function buildSiteData(config, frontPage) {
3719
3728
  title: configuredSite.title,
3720
3729
  description: configuredSite.description,
3721
3730
  url: configuredSite.url,
3722
- mediaBaseUrl: "",
3731
+ media_base_url: "",
3723
3732
  locale: "en-US",
3724
- postsPerPage: 10,
3725
- dateFormat: "YYYY-MM-DD",
3726
- timeFormat: "HH:mm",
3733
+ posts_per_page: 10,
3734
+ datetime_display: "static",
3735
+ date_style: "medium",
3736
+ time_style: "none",
3727
3737
  timezone: "UTC",
3728
3738
  permalinks: defaultPermalinks(),
3729
3739
  front_page: frontPage,
3730
3740
  post_index: {
3731
3741
  enabled: false
3732
3742
  },
3733
- disallowComments: true,
3743
+ disallow_comments: true,
3744
+ expose_generator: configuredSite.expose_generator !== false,
3745
+ search: configuredSite.search !== false,
3734
3746
  indexing: configuredSite.indexing !== false
3735
3747
  };
3736
3748
  if (configuredSite.footer) {
@@ -3759,11 +3771,13 @@ function normalizeSiteConfig(value) {
3759
3771
  );
3760
3772
  }
3761
3773
  const configuredSite = isPlainObject(value) ? value : {};
3762
- assertKnownConfigKeys(configuredSite, ["title", "description", "url", "indexing", "footer"], "site");
3774
+ assertKnownConfigKeys(configuredSite, ["title", "description", "url", "expose_generator", "search", "indexing", "footer"], "site");
3763
3775
  const site = {
3764
3776
  title: readConfigString(configuredSite.title, "Documentation"),
3765
3777
  description: readConfigString(configuredSite.description, "A documentation site."),
3766
3778
  url: readEnv("ZEROPRESS_SITE_URL", readConfigString(configuredSite.url, "")),
3779
+ expose_generator: readConfigBoolean(configuredSite.expose_generator, true, "site.expose_generator"),
3780
+ search: readConfigBoolean(configuredSite.search, true, "site.search"),
3767
3781
  indexing: readConfigBoolean(configuredSite.indexing, true, "site.indexing")
3768
3782
  };
3769
3783
  const footer = normalizeFooter(configuredSite.footer);
@@ -3785,19 +3799,11 @@ function normalizeFooter(value) {
3785
3799
  if (copyrightText) {
3786
3800
  footer.copyright_text = copyrightText;
3787
3801
  }
3788
- if (value.attribution !== void 0 && !isPlainObject(value.attribution)) {
3789
- throw new PrebuildConfigError("site.footer.attribution must be an object.");
3790
- }
3791
- if (isPlainObject(value.attribution)) {
3792
- assertKnownConfigKeys(value.attribution, ["enabled"], "site.footer.attribution");
3793
- if (value.attribution.enabled !== void 0 && typeof value.attribution.enabled !== "boolean") {
3794
- throw new PrebuildConfigError("site.footer.attribution.enabled must be a boolean when provided.");
3802
+ if (value.attribution !== void 0) {
3803
+ if (typeof value.attribution !== "boolean") {
3804
+ throw new PrebuildConfigError("site.footer.attribution must be a boolean when provided.");
3795
3805
  }
3796
- }
3797
- if (isPlainObject(value.attribution) && typeof value.attribution.enabled === "boolean") {
3798
- footer.attribution = {
3799
- enabled: value.attribution.enabled
3800
- };
3806
+ footer.attribution = value.attribution;
3801
3807
  }
3802
3808
  return Object.keys(footer).length ? footer : void 0;
3803
3809
  }
@@ -4143,9 +4149,23 @@ function normalizeMenuItem(item, pathLabel) {
4143
4149
  url,
4144
4150
  type: readConfigString(item.type, "custom"),
4145
4151
  target: readConfigString(item.target, "_self"),
4152
+ ...item.meta !== void 0 ? { meta: normalizeMenuItemMeta(item.meta, `${pathLabel}.meta`) } : {},
4146
4153
  children: Array.isArray(item.children) ? item.children.map((child, index) => normalizeMenuItem(child, `${pathLabel}.children[${index}]`)) : []
4147
4154
  };
4148
4155
  }
4156
+ function normalizeMenuItemMeta(value, pathLabel) {
4157
+ if (!isPlainObject(value)) {
4158
+ throw new PrebuildConfigError(`${pathLabel} must be an object when provided.`);
4159
+ }
4160
+ const meta = {};
4161
+ for (const [key, metaValue] of Object.entries(value)) {
4162
+ if (!isPreviewMetaValue(metaValue)) {
4163
+ throw new PrebuildConfigError(`${pathLabel}.${key} must be a string, number, boolean, or null.`);
4164
+ }
4165
+ meta[key] = metaValue;
4166
+ }
4167
+ return meta;
4168
+ }
4149
4169
  function defaultMenus() {
4150
4170
  return {
4151
4171
  primary: {
@@ -4168,6 +4188,7 @@ function buildPrebuildReport({
4168
4188
  return {
4169
4189
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
4170
4190
  source_dir: formatSourcePath(sourceDir),
4191
+ public_dir: formatSourcePath(publicDir),
4171
4192
  config_path: formatSourcePath(configPath),
4172
4193
  build_pages_config_path: formatSourcePath(buildPagesConfigPath),
4173
4194
  preview_data_path: formatSourcePath(previewDataPath),
@@ -4193,7 +4214,8 @@ function buildPrebuildReport({
4193
4214
  function printPrebuildSummary(report) {
4194
4215
  const lines = [
4195
4216
  "ZeroPress build report",
4196
- `- Public root: ${report.source_dir}`,
4217
+ `- Source root: ${report.source_dir}`,
4218
+ `- Public root: ${report.public_dir}`,
4197
4219
  `- Markdown discovered: ${report.markdown.discovered}`,
4198
4220
  `- Markdown pages generated: ${report.markdown.generated_pages}`,
4199
4221
  `- Markdown skipped: ${report.markdown.skipped}`,
@@ -4262,7 +4284,9 @@ function normalizePublishedFrontMatter(frontMatter, sourcePath) {
4262
4284
  title: normalizeFrontMatterTitle(frontMatter.title, sourcePath),
4263
4285
  description: normalizeFrontMatterDescription(frontMatter.description, sourcePath),
4264
4286
  path: normalizeFrontMatterRoutePath(frontMatter.path, sourcePath),
4265
- meta: normalizeFrontMatterMeta(frontMatter.meta, sourcePath)
4287
+ discoverability: normalizeFrontMatterDiscoverability(frontMatter.discoverability, sourcePath),
4288
+ meta: normalizeFrontMatterMeta(frontMatter.meta, sourcePath),
4289
+ data: normalizeFrontMatterData(frontMatter.data, sourcePath)
4266
4290
  };
4267
4291
  }
4268
4292
  function normalizeFrontMatterTitle(value, sourcePath) {
@@ -4305,11 +4329,24 @@ function normalizeFrontMatterRoutePath(value, sourcePath) {
4305
4329
  throw new PrebuildMarkdownError(
4306
4330
  sourcePath,
4307
4331
  "front matter path must be a safe generated route path.",
4308
- " path: guides/install\n path: spec/preview-data-v0.5"
4332
+ " path: guides/install\n path: spec/preview-data-v0.6"
4309
4333
  );
4310
4334
  }
4311
4335
  return routePath;
4312
4336
  }
4337
+ function normalizeFrontMatterDiscoverability(value, sourcePath) {
4338
+ if (value === void 0) {
4339
+ return "default";
4340
+ }
4341
+ if (typeof value === "string" && FRONT_MATTER_DISCOVERABILITY_VALUES.has(value)) {
4342
+ return value;
4343
+ }
4344
+ throw new PrebuildMarkdownError(
4345
+ sourcePath,
4346
+ `front matter discoverability must be one of: ${Array.from(FRONT_MATTER_DISCOVERABILITY_VALUES).join(", ")}.`,
4347
+ " discoverability: default\n discoverability: noindex\n discoverability: delist"
4348
+ );
4349
+ }
4313
4350
  function isSafeRoutePathSegment(segment) {
4314
4351
  return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(segment) && !segment.includes("..");
4315
4352
  }
@@ -4336,7 +4373,88 @@ function normalizeFrontMatterMeta(value, sourcePath) {
4336
4373
  return meta;
4337
4374
  }
4338
4375
  function isPreviewMetaValue(value) {
4339
- return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
4376
+ return value === null || typeof value === "string" || typeof value === "number" && Number.isFinite(value) || typeof value === "boolean";
4377
+ }
4378
+ function normalizeFrontMatterData(value, sourcePath) {
4379
+ if (value === void 0) {
4380
+ return void 0;
4381
+ }
4382
+ if (!isPlainObject(value)) {
4383
+ throw new PrebuildMarkdownError(
4384
+ sourcePath,
4385
+ "front matter data must be an object when provided."
4386
+ );
4387
+ }
4388
+ validateFrontMatterDataObject(value, sourcePath, "data", 0);
4389
+ return value;
4390
+ }
4391
+ function validateFrontMatterDataValue(value, sourcePath, pathLabel, depth) {
4392
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
4393
+ return;
4394
+ }
4395
+ if (typeof value === "number") {
4396
+ if (!Number.isFinite(value)) {
4397
+ throw new PrebuildMarkdownError(
4398
+ sourcePath,
4399
+ `front matter ${pathLabel} must be a finite number.`
4400
+ );
4401
+ }
4402
+ return;
4403
+ }
4404
+ if (Array.isArray(value)) {
4405
+ validateFrontMatterDataArray(value, sourcePath, pathLabel, depth);
4406
+ return;
4407
+ }
4408
+ if (isPlainObject(value)) {
4409
+ validateFrontMatterDataObject(value, sourcePath, pathLabel, depth);
4410
+ return;
4411
+ }
4412
+ throw new PrebuildMarkdownError(
4413
+ sourcePath,
4414
+ `front matter ${pathLabel} must be JSON-safe structured data.`
4415
+ );
4416
+ }
4417
+ function validateFrontMatterDataObject(object, sourcePath, pathLabel, depth) {
4418
+ if (depth > FRONT_MATTER_DATA_MAX_DEPTH) {
4419
+ throw new PrebuildMarkdownError(
4420
+ sourcePath,
4421
+ `front matter ${pathLabel} nesting must not exceed ${FRONT_MATTER_DATA_MAX_DEPTH} container levels.`
4422
+ );
4423
+ }
4424
+ const entries = Object.entries(object);
4425
+ if (entries.length > FRONT_MATTER_DATA_MAX_KEYS) {
4426
+ throw new PrebuildMarkdownError(
4427
+ sourcePath,
4428
+ `front matter ${pathLabel} must not contain more than ${FRONT_MATTER_DATA_MAX_KEYS} keys.`
4429
+ );
4430
+ }
4431
+ for (const [key, dataValue] of entries) {
4432
+ const childLabel = `${pathLabel}.${key}`;
4433
+ if (!FRONT_MATTER_DATA_KEY_PATTERN.test(key)) {
4434
+ throw new PrebuildMarkdownError(
4435
+ sourcePath,
4436
+ `front matter ${childLabel} uses an invalid key.`
4437
+ );
4438
+ }
4439
+ validateFrontMatterDataValue(dataValue, sourcePath, childLabel, depth + 1);
4440
+ }
4441
+ }
4442
+ function validateFrontMatterDataArray(array, sourcePath, pathLabel, depth) {
4443
+ if (depth > FRONT_MATTER_DATA_MAX_DEPTH) {
4444
+ throw new PrebuildMarkdownError(
4445
+ sourcePath,
4446
+ `front matter ${pathLabel} nesting must not exceed ${FRONT_MATTER_DATA_MAX_DEPTH} container levels.`
4447
+ );
4448
+ }
4449
+ if (array.length > FRONT_MATTER_DATA_MAX_ARRAY_LENGTH) {
4450
+ throw new PrebuildMarkdownError(
4451
+ sourcePath,
4452
+ `front matter ${pathLabel} must not contain more than ${FRONT_MATTER_DATA_MAX_ARRAY_LENGTH} items.`
4453
+ );
4454
+ }
4455
+ array.forEach((dataValue, index) => {
4456
+ validateFrontMatterDataValue(dataValue, sourcePath, `${pathLabel}[${index}]`, depth + 1);
4457
+ });
4340
4458
  }
4341
4459
  function formatFrontMatterValue(value) {
4342
4460
  if (typeof value === "string") {
@@ -4385,6 +4503,9 @@ async function listMarkdownFiles(dir) {
4385
4503
  continue;
4386
4504
  }
4387
4505
  const entryPath = path.join(dir, entry.name);
4506
+ if (isMarkdownDiscoverExcluded(entryPath)) {
4507
+ continue;
4508
+ }
4388
4509
  if (entry.isDirectory()) {
4389
4510
  files.push(...await listMarkdownFiles(entryPath));
4390
4511
  continue;
@@ -4395,6 +4516,18 @@ async function listMarkdownFiles(dir) {
4395
4516
  }
4396
4517
  return files.sort((left, right) => left.localeCompare(right));
4397
4518
  }
4519
+ function buildMarkdownDiscoverExcludeRoots() {
4520
+ if (samePath(sourceDir, publicDir) || !isPathInside(sourceDir, publicDir)) {
4521
+ return [];
4522
+ }
4523
+ return [publicDir];
4524
+ }
4525
+ function isMarkdownDiscoverExcluded(entryPath) {
4526
+ return markdownDiscoverExcludeRoots.some((excludeRoot) => samePath(entryPath, excludeRoot) || isPathInside(excludeRoot, entryPath));
4527
+ }
4528
+ function samePath(firstPath, secondPath) {
4529
+ return path.resolve(firstPath) === path.resolve(secondPath);
4530
+ }
4398
4531
  function shouldIgnoreMarkdownDiscoverEntry(name) {
4399
4532
  const basename = String(name || "");
4400
4533
  const lowerName = basename.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-pages",
3
- "version": "0.5.6",
3
+ "version": "0.6.2",
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.5.2",
43
+ "@zeropress/build": "0.6.2",
44
44
  "gray-matter": "4.0.3"
45
45
  },
46
46
  "devDependencies": {
@@ -50,6 +50,18 @@
50
50
  "description": "Canonical site URL. Use an empty string or omit this field for local builds without canonical output.",
51
51
  "markdownDescription": "Canonical site URL. Use an empty string or omit this field for local builds without canonical output."
52
52
  },
53
+ "expose_generator": {
54
+ "type": "boolean",
55
+ "default": true,
56
+ "description": "Whether generated HTML should expose the ZeroPress generator meta tag. Set false for white-label sites.",
57
+ "markdownDescription": "Whether generated HTML should expose `<meta name=\"generator\" content=\"ZeroPress\">`. Set `false` for white-label sites."
58
+ },
59
+ "search": {
60
+ "type": "boolean",
61
+ "default": true,
62
+ "description": "Whether native ZeroPress search should be enabled when the selected theme supports search UI. Set false to omit native search artifacts and hide theme search UI.",
63
+ "markdownDescription": "Whether native ZeroPress search should be enabled when the selected theme supports search UI. Set `false` to omit native search artifacts and hide theme search UI."
64
+ },
53
65
  "indexing": {
54
66
  "type": "boolean",
55
67
  "default": true,
@@ -74,17 +86,6 @@
74
86
  "markdownDescription": "Footer copyright or legal text. ZeroPress does not add a copyright symbol automatically."
75
87
  },
76
88
  "attribution": {
77
- "$ref": "#/$defs/siteFooterAttribution"
78
- }
79
- }
80
- },
81
- "siteFooterAttribution": {
82
- "type": "object",
83
- "additionalProperties": false,
84
- "description": "Optional ZeroPress attribution display policy for themes.",
85
- "markdownDescription": "Optional ZeroPress attribution display policy for themes.",
86
- "properties": {
87
- "enabled": {
88
89
  "type": "boolean",
89
90
  "description": "When false, bundled themes hide the Published with ZeroPress attribution. Missing or true means attribution may be shown.",
90
91
  "markdownDescription": "When `false`, bundled themes hide the Published with ZeroPress attribution. Missing or `true` means attribution may be shown."
@@ -227,6 +228,12 @@
227
228
  "$ref": "#/$defs/menu"
228
229
  }
229
230
  },
231
+ "previewMeta": {
232
+ "type": "object",
233
+ "additionalProperties": {
234
+ "type": ["string", "number", "boolean", "null"]
235
+ }
236
+ },
230
237
  "menu": {
231
238
  "type": "object",
232
239
  "additionalProperties": false,
@@ -248,6 +255,8 @@
248
255
  "type": "object",
249
256
  "additionalProperties": false,
250
257
  "required": ["title", "url"],
258
+ "description": "Menu item copied into generated preview-data.",
259
+ "markdownDescription": "Menu item copied into generated preview-data.",
251
260
  "properties": {
252
261
  "title": {
253
262
  "type": "string",
@@ -267,6 +276,9 @@
267
276
  "enum": ["_self", "_blank"],
268
277
  "default": "_self"
269
278
  },
279
+ "meta": {
280
+ "$ref": "#/$defs/previewMeta"
281
+ },
270
282
  "children": {
271
283
  "type": "array",
272
284
  "items": {
@@ -285,6 +297,8 @@
285
297
  "title": "ZeroPress Public Docs",
286
298
  "description": "Public documentation.",
287
299
  "url": "https://zeropress.dev",
300
+ "expose_generator": true,
301
+ "search": true,
288
302
  "indexing": true
289
303
  },
290
304
  "front_page": {
@@ -303,7 +317,14 @@
303
317
  "name": "Primary Menu",
304
318
  "items": [
305
319
  { "title": "Home", "url": "/" },
306
- { "title": "Docs", "url": "/docs/" }
320
+ {
321
+ "title": "Docs",
322
+ "url": "/docs/",
323
+ "meta": {
324
+ "icon": "book-open",
325
+ "badge": "New"
326
+ }
327
+ }
307
328
  ]
308
329
  }
309
330
  }
package/src/action.js CHANGED
@@ -2,6 +2,7 @@ import { runBuildPages } from './index.js';
2
2
 
3
3
  const options = {
4
4
  source: input('source') || './docs',
5
+ publicDir: input('public-dir'),
5
6
  destination: input('destination') || './_site',
6
7
  theme: input('theme') || 'docs',
7
8
  themePath: input('theme-path'),
@@ -21,7 +22,7 @@ try {
21
22
  }
22
23
 
23
24
  function input(name) {
24
- return process.env[`INPUT_${name.toUpperCase().replace(/-/g, '_')}`]?.trim() || '';
25
+ return process.env[`INPUT_${name.toUpperCase()}`]?.trim() || '';
25
26
  }
26
27
 
27
28
  function booleanInput(name, fallback) {
package/src/index.js CHANGED
@@ -39,6 +39,8 @@ export async function runBuildPages(options) {
39
39
  const cwd = path.resolve(options.cwd || process.cwd());
40
40
  const copyMarkdownSource = options.copyMarkdownSource !== false;
41
41
  const sourceDir = path.resolve(cwd, options.source);
42
+ const publicDirExplicit = hasExplicitPublicDir(options);
43
+ const publicDir = publicDirExplicit ? path.resolve(cwd, options.publicDir) : sourceDir;
42
44
  const destinationDir = path.resolve(cwd, options.destination);
43
45
  const generatedDir = path.join(cwd, '.zeropress');
44
46
  const stagingDir = path.join(cwd, STAGING_DIR);
@@ -48,18 +50,23 @@ export async function runBuildPages(options) {
48
50
  assertBuildPagesPathLayout({
49
51
  cwd,
50
52
  sourceDir,
53
+ publicDir,
54
+ publicDirExplicit,
51
55
  destinationDir,
52
56
  themeDir,
53
57
  generatedDir,
54
58
  });
55
59
  await assertDirectory(sourceDir, 'Source directory');
60
+ await assertPublicDirectory(publicDir, publicDirExplicit);
61
+ await assertDestinationPath(destinationDir);
56
62
  await fs.rm(generatedDir, { recursive: true, force: true });
57
63
  await fs.mkdir(generatedDir, { recursive: true });
58
64
 
59
65
  const env = {
60
66
  ...process.env,
61
67
  ZEROPRESS_BUILD_PAGES_SOURCE: sourceDir,
62
- ZEROPRESS_PUBLIC_DIR: sourceDir,
68
+ ZEROPRESS_BUILD_PAGES_PUBLIC_DIR: publicDir,
69
+ ZEROPRESS_PUBLIC_DIR: publicDir,
63
70
  ZEROPRESS_SKIP_UNTITLED_MARKDOWN: String(Boolean(options.skipUntitledMarkdown)),
64
71
  ZEROPRESS_COPY_MARKDOWN_SOURCE: String(copyMarkdownSource),
65
72
  };
@@ -85,10 +92,13 @@ export async function runBuildPages(options) {
85
92
  await fs.rm(destinationDir, { recursive: true, force: true });
86
93
  await fs.rm(stagingDir, { recursive: true, force: true });
87
94
  await fs.mkdir(stagingDir, { recursive: true });
88
- await copyPublicStaging(sourceDir, stagingDir, {
95
+ await copyPublicStaging(publicDir, stagingDir, {
89
96
  excludePaths: [destinationDir, themeDir, generatedDir],
90
97
  copyMarkdownSource,
91
98
  });
99
+ if (copyMarkdownSource) {
100
+ await copySourceMarkdownFiles(sourceDir, stagingDir, previewData);
101
+ }
92
102
 
93
103
  const previousPublicDir = process.env.ZEROPRESS_PUBLIC_DIR;
94
104
  process.env.ZEROPRESS_PUBLIC_DIR = stagingDir;
@@ -137,6 +147,7 @@ export function parseArgs(argv) {
137
147
 
138
148
  const valueOptions = new Set([
139
149
  '--source',
150
+ '--public-dir',
140
151
  '--destination',
141
152
  '--theme',
142
153
  '--theme-path',
@@ -167,6 +178,7 @@ export function parseArgs(argv) {
167
178
 
168
179
  return {
169
180
  source,
181
+ publicDir: flags['public-dir'] || '',
170
182
  destination,
171
183
  theme: flags.theme || DEFAULT_THEME,
172
184
  themePath: flags['theme-path'] || '',
@@ -186,6 +198,7 @@ Usage:
186
198
 
187
199
  Options:
188
200
  --source <dir> Dedicated source directory (required)
201
+ --public-dir <dir> Public passthrough directory (default: source)
189
202
  --destination <dir> Output directory (required)
190
203
  --theme docs Bundled theme name (default: docs)
191
204
  --theme-path <dir> Custom ZeroPress theme directory
@@ -208,6 +221,10 @@ function resolveThemeDir(cwd, options) {
208
221
  throw new Error(`Unknown bundled theme: ${options.theme}`);
209
222
  }
210
223
 
224
+ function hasExplicitPublicDir(options) {
225
+ return typeof options.publicDir === 'string' && Boolean(options.publicDir.trim());
226
+ }
227
+
211
228
  async function assertDirectory(dir, label) {
212
229
  let stat;
213
230
  try {
@@ -223,7 +240,55 @@ async function assertDirectory(dir, label) {
223
240
  }
224
241
  }
225
242
 
226
- function assertBuildPagesPathLayout({ cwd, sourceDir, destinationDir, themeDir, generatedDir }) {
243
+ async function assertPublicDirectory(publicDir, explicit) {
244
+ if (!explicit) {
245
+ return;
246
+ }
247
+
248
+ let stat;
249
+ try {
250
+ stat = await fs.lstat(publicDir);
251
+ } catch (error) {
252
+ if (error?.code === 'ENOENT') {
253
+ throw new Error(`Public directory not found: ${publicDir}`);
254
+ }
255
+ throw error;
256
+ }
257
+
258
+ if (stat.isSymbolicLink()) {
259
+ throw new Error(`Public directory must not be a symbolic link: ${publicDir}`);
260
+ }
261
+
262
+ if (!stat.isDirectory()) {
263
+ throw new Error(`Public path is not a directory: ${publicDir}`);
264
+ }
265
+ }
266
+
267
+ async function assertDestinationPath(destinationDir) {
268
+ let stat;
269
+ try {
270
+ stat = await fs.lstat(destinationDir);
271
+ } catch (error) {
272
+ if (error?.code === 'ENOENT') {
273
+ return;
274
+ }
275
+ throw error;
276
+ }
277
+
278
+ if (!stat.isDirectory()) {
279
+ throw new Error(`Destination path is not a directory: ${destinationDir}`);
280
+ }
281
+ }
282
+
283
+ function assertBuildPagesPathLayout({
284
+ cwd,
285
+ sourceDir,
286
+ publicDir,
287
+ publicDirExplicit,
288
+ destinationDir,
289
+ themeDir,
290
+ generatedDir,
291
+ }) {
227
292
  if (samePath(sourceDir, cwd)) {
228
293
  throw new Error(
229
294
  'Source directory must be a dedicated content directory, not the current working directory. '
@@ -231,11 +296,36 @@ function assertBuildPagesPathLayout({ cwd, sourceDir, destinationDir, themeDir,
231
296
  );
232
297
  }
233
298
 
299
+ if (publicDirExplicit && samePath(publicDir, cwd)) {
300
+ throw new Error(
301
+ 'Public directory must be a dedicated asset directory, not the current working directory. '
302
+ + `Received: ${formatPath(cwd, publicDir)}`,
303
+ );
304
+ }
305
+
234
306
  assertNoPathOverlap(cwd, 'Source directory', sourceDir, 'internal .zeropress working directory', generatedDir);
235
307
  assertNoPathOverlap(cwd, 'Destination directory', destinationDir, 'internal .zeropress working directory', generatedDir);
236
308
  assertNoPathOverlap(cwd, 'Theme directory', themeDir, 'internal .zeropress working directory', generatedDir);
309
+ if (!samePath(publicDir, sourceDir)) {
310
+ assertNoPathOverlap(cwd, 'Public directory', publicDir, 'internal .zeropress working directory', generatedDir);
311
+ assertNoPathOverlap(cwd, 'Public directory', publicDir, 'destination directory', destinationDir);
312
+ assertNoPathOverlap(cwd, 'Public directory', publicDir, 'theme directory', themeDir);
313
+ }
237
314
  assertNoPathOverlap(cwd, 'Source directory', sourceDir, 'destination directory', destinationDir);
238
315
  assertNoPathOverlap(cwd, 'Source directory', sourceDir, 'theme directory', themeDir);
316
+ assertSourceIsNotInsidePublicDirectory(cwd, sourceDir, publicDir);
317
+ }
318
+
319
+ function assertSourceIsNotInsidePublicDirectory(cwd, sourceDir, publicDir) {
320
+ if (samePath(sourceDir, publicDir) || !isPathInside(publicDir, sourceDir)) {
321
+ return;
322
+ }
323
+
324
+ throw new Error(
325
+ 'Source directory must not be inside the public directory. '
326
+ + `Source directory: ${formatPath(cwd, sourceDir)}; `
327
+ + `Public directory: ${formatPath(cwd, publicDir)}`,
328
+ );
239
329
  }
240
330
 
241
331
  function assertNoPathOverlap(cwd, firstLabel, firstPath, secondLabel, secondPath) {
@@ -282,6 +372,67 @@ async function copyPublicStaging(sourceDir, targetDir, options) {
282
372
  }
283
373
  }
284
374
 
375
+ async function copySourceMarkdownFiles(sourceDir, targetDir, previewData) {
376
+ const markdownUrls = new Set();
377
+
378
+ for (const page of previewData?.content?.pages || []) {
379
+ const sourceMarkdownUrl = page?.meta?.source_markdown_url;
380
+ if (typeof sourceMarkdownUrl === 'string' && sourceMarkdownUrl) {
381
+ markdownUrls.add(sourceMarkdownUrl);
382
+ }
383
+ }
384
+
385
+ for (const sourceMarkdownUrl of markdownUrls) {
386
+ const relativePath = sourceMarkdownUrlToRelativePath(sourceMarkdownUrl);
387
+ if (!relativePath) {
388
+ continue;
389
+ }
390
+
391
+ const sourcePath = path.join(sourceDir, relativePath);
392
+ if (!isPathInside(sourceDir, sourcePath)) {
393
+ continue;
394
+ }
395
+
396
+ const targetPath = path.join(targetDir, relativePath);
397
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
398
+ await fs.copyFile(sourcePath, targetPath);
399
+ }
400
+ }
401
+
402
+ function sourceMarkdownUrlToRelativePath(sourceMarkdownUrl) {
403
+ if (
404
+ !sourceMarkdownUrl.startsWith('/')
405
+ || sourceMarkdownUrl.includes('?')
406
+ || sourceMarkdownUrl.includes('#')
407
+ ) {
408
+ return '';
409
+ }
410
+
411
+ const rawSegments = sourceMarkdownUrl.slice(1).split('/');
412
+ const segments = [];
413
+ for (const rawSegment of rawSegments) {
414
+ if (!rawSegment) {
415
+ return '';
416
+ }
417
+
418
+ let segment;
419
+ try {
420
+ segment = decodeURIComponent(rawSegment);
421
+ } catch {
422
+ return '';
423
+ }
424
+
425
+ if (!segment || segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\')) {
426
+ return '';
427
+ }
428
+
429
+ segments.push(segment);
430
+ }
431
+
432
+ const relativePath = segments.join('/');
433
+ return relativePath.toLowerCase().endsWith('.md') ? relativePath : '';
434
+ }
435
+
285
436
  function shouldIgnorePublicEntry(name) {
286
437
  const basename = String(name || '');
287
438
  const lowerName = basename.toLowerCase();