@zeropress/build-pages 0.6.1 → 0.6.3

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");
@@ -3533,13 +3534,14 @@ var buildReportPath = path.join(outDir, "build-report.json");
3533
3534
  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
- 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.6.schema.json";
3537
+ var BUILD_PAGES_CONFIG_SCHEMA_URL = "https://schemas.zeropress.dev/build-pages-config/v0.1/schema.json";
3538
+ var PREVIEW_DATA_SCHEMA_URL = "https://schemas.zeropress.dev/preview-data/v0.6/schema.json";
3538
3539
  var FRONT_MATTER_DATA_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*$/;
3539
3540
  var FRONT_MATTER_DATA_MAX_DEPTH = 4;
3540
3541
  var FRONT_MATTER_DATA_MAX_KEYS = 64;
3541
3542
  var FRONT_MATTER_DATA_MAX_ARRAY_LENGTH = 256;
3542
3543
  var FRONT_MATTER_DISCOVERABILITY_VALUES = /* @__PURE__ */ new Set(["default", "noindex", "delist"]);
3544
+ var markdownDiscoverExcludeRoots = buildMarkdownDiscoverExcludeRoots();
3543
3545
  var PrebuildMarkdownError = class extends Error {
3544
3546
  constructor(sourcePath, reason, expected = "", code = "invalid_markdown") {
3545
3547
  super(reason);
@@ -3727,7 +3729,7 @@ function buildSiteData(config, frontPage) {
3727
3729
  description: configuredSite.description,
3728
3730
  url: configuredSite.url,
3729
3731
  media_base_url: "",
3730
- locale: "en-US",
3732
+ locale: configuredSite.locale,
3731
3733
  posts_per_page: 10,
3732
3734
  datetime_display: "static",
3733
3735
  date_style: "medium",
@@ -3740,8 +3742,15 @@ function buildSiteData(config, frontPage) {
3740
3742
  },
3741
3743
  disallow_comments: true,
3742
3744
  expose_generator: configuredSite.expose_generator !== false,
3745
+ search: configuredSite.search !== false,
3743
3746
  indexing: configuredSite.indexing !== false
3744
3747
  };
3748
+ if (configuredSite.logo) {
3749
+ site.logo = configuredSite.logo;
3750
+ }
3751
+ if (configuredSite.meta !== void 0) {
3752
+ site.meta = configuredSite.meta;
3753
+ }
3745
3754
  if (configuredSite.footer) {
3746
3755
  site.footer = configuredSite.footer;
3747
3756
  }
@@ -3768,20 +3777,83 @@ function normalizeSiteConfig(value) {
3768
3777
  );
3769
3778
  }
3770
3779
  const configuredSite = isPlainObject(value) ? value : {};
3771
- assertKnownConfigKeys(configuredSite, ["title", "description", "url", "expose_generator", "indexing", "footer"], "site");
3780
+ assertKnownConfigKeys(configuredSite, ["title", "description", "url", "logo", "locale", "expose_generator", "search", "indexing", "footer", "meta"], "site");
3772
3781
  const site = {
3773
3782
  title: readConfigString(configuredSite.title, "Documentation"),
3774
3783
  description: readConfigString(configuredSite.description, "A documentation site."),
3775
3784
  url: readEnv("ZEROPRESS_SITE_URL", readConfigString(configuredSite.url, "")),
3785
+ locale: normalizeSiteLocale(configuredSite.locale),
3776
3786
  expose_generator: readConfigBoolean(configuredSite.expose_generator, true, "site.expose_generator"),
3787
+ search: readConfigBoolean(configuredSite.search, true, "site.search"),
3777
3788
  indexing: readConfigBoolean(configuredSite.indexing, true, "site.indexing")
3778
3789
  };
3790
+ const logo = normalizeSiteLogo(configuredSite.logo);
3791
+ if (logo) {
3792
+ site.logo = logo;
3793
+ }
3779
3794
  const footer = normalizeFooter(configuredSite.footer);
3780
3795
  if (footer) {
3781
3796
  site.footer = footer;
3782
3797
  }
3798
+ if (configuredSite.meta !== void 0) {
3799
+ site.meta = normalizeSiteMeta(configuredSite.meta, "site.meta");
3800
+ }
3783
3801
  return site;
3784
3802
  }
3803
+ function normalizeSiteLocale(value) {
3804
+ if (value === void 0) {
3805
+ return "en-US";
3806
+ }
3807
+ if (typeof value !== "string") {
3808
+ throw new PrebuildConfigError("site.locale must be a string when provided.");
3809
+ }
3810
+ const locale = value.trim();
3811
+ if (locale.length < 2) {
3812
+ throw new PrebuildConfigError('site.locale must be a non-empty locale string such as "en-US" or "ko-KR".');
3813
+ }
3814
+ return locale;
3815
+ }
3816
+ function normalizeSiteLogo(value) {
3817
+ if (value === void 0) {
3818
+ return void 0;
3819
+ }
3820
+ if (!isPlainObject(value)) {
3821
+ throw new PrebuildConfigError("site.logo must be an object when provided.");
3822
+ }
3823
+ assertKnownConfigKeys(value, ["src", "alt"], "site.logo");
3824
+ const src = readConfigString(value.src, "");
3825
+ if (!src) {
3826
+ throw new PrebuildConfigError(
3827
+ "site.logo.src must be a non-empty URL-like string.",
3828
+ ' "logo": { "src": "/logo.svg", "alt": "My Site" }'
3829
+ );
3830
+ }
3831
+ validateUrlLikeString(src, "site.logo.src");
3832
+ const logo = { src };
3833
+ if (value.alt !== void 0) {
3834
+ if (typeof value.alt !== "string") {
3835
+ throw new PrebuildConfigError("site.logo.alt must be a string when provided.");
3836
+ }
3837
+ const alt = value.alt.trim();
3838
+ if (alt) {
3839
+ logo.alt = alt;
3840
+ }
3841
+ }
3842
+ return logo;
3843
+ }
3844
+ function normalizeSiteMeta(value, pathLabel) {
3845
+ if (!isPlainObject(value)) {
3846
+ throw new PrebuildConfigError(`${pathLabel} must be an object when provided.`);
3847
+ }
3848
+ const meta = {};
3849
+ for (const [key, metaValue] of Object.entries(value)) {
3850
+ if (!isPreviewMetaValue(metaValue)) {
3851
+ throw new PrebuildConfigError(`${pathLabel}.${key} must be a string, number, boolean, or null.`);
3852
+ }
3853
+ meta[key] = metaValue;
3854
+ }
3855
+ return meta;
3856
+ }
3785
3857
  function normalizeFooter(value) {
3786
3858
  if (value === void 0) {
3787
3859
  return void 0;
@@ -3803,6 +3875,22 @@ function normalizeFooter(value) {
3803
3875
  }
3804
3876
  return Object.keys(footer).length ? footer : void 0;
3805
3877
  }
3878
+ function validateUrlLikeString(value, pathLabel) {
3879
+ if (value.startsWith("//")) {
3880
+ throw new PrebuildConfigError(`${pathLabel} must be an absolute URL or a safe relative path.`);
3881
+ }
3882
+ if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../")) {
3883
+ return;
3884
+ }
3885
+ try {
3886
+ const url = new URL(value);
3887
+ if (!url.protocol || !url.hostname) {
3888
+ throw new Error("missing host");
3889
+ }
3890
+ } catch {
3891
+ throw new PrebuildConfigError(`${pathLabel} must be an absolute URL or a safe relative path.`);
3892
+ }
3893
+ }
3806
3894
  function readConfigBoolean(value, fallback, pathName) {
3807
3895
  if (value === void 0) {
3808
3896
  return fallback;
@@ -4184,6 +4272,7 @@ function buildPrebuildReport({
4184
4272
  return {
4185
4273
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
4186
4274
  source_dir: formatSourcePath(sourceDir),
4275
+ public_dir: formatSourcePath(publicDir),
4187
4276
  config_path: formatSourcePath(configPath),
4188
4277
  build_pages_config_path: formatSourcePath(buildPagesConfigPath),
4189
4278
  preview_data_path: formatSourcePath(previewDataPath),
@@ -4209,7 +4298,8 @@ function buildPrebuildReport({
4209
4298
  function printPrebuildSummary(report) {
4210
4299
  const lines = [
4211
4300
  "ZeroPress build report",
4212
- `- Public root: ${report.source_dir}`,
4301
+ `- Source root: ${report.source_dir}`,
4302
+ `- Public root: ${report.public_dir}`,
4213
4303
  `- Markdown discovered: ${report.markdown.discovered}`,
4214
4304
  `- Markdown pages generated: ${report.markdown.generated_pages}`,
4215
4305
  `- Markdown skipped: ${report.markdown.skipped}`,
@@ -4497,6 +4587,9 @@ async function listMarkdownFiles(dir) {
4497
4587
  continue;
4498
4588
  }
4499
4589
  const entryPath = path.join(dir, entry.name);
4590
+ if (isMarkdownDiscoverExcluded(entryPath)) {
4591
+ continue;
4592
+ }
4500
4593
  if (entry.isDirectory()) {
4501
4594
  files.push(...await listMarkdownFiles(entryPath));
4502
4595
  continue;
@@ -4507,6 +4600,18 @@ async function listMarkdownFiles(dir) {
4507
4600
  }
4508
4601
  return files.sort((left, right) => left.localeCompare(right));
4509
4602
  }
4603
+ function buildMarkdownDiscoverExcludeRoots() {
4604
+ if (samePath(sourceDir, publicDir) || !isPathInside(sourceDir, publicDir)) {
4605
+ return [];
4606
+ }
4607
+ return [publicDir];
4608
+ }
4609
+ function isMarkdownDiscoverExcluded(entryPath) {
4610
+ return markdownDiscoverExcludeRoots.some((excludeRoot) => samePath(entryPath, excludeRoot) || isPathInside(excludeRoot, entryPath));
4611
+ }
4612
+ function samePath(firstPath, secondPath) {
4613
+ return path.resolve(firstPath) === path.resolve(secondPath);
4614
+ }
4510
4615
  function shouldIgnoreMarkdownDiscoverEntry(name) {
4511
4616
  const basename = String(name || "");
4512
4617
  const lowerName = basename.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-pages",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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.1",
43
+ "@zeropress/build": "0.6.3",
44
44
  "gray-matter": "4.0.3"
45
45
  },
46
46
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://zeropress.dev/schemas/zeropress-build-pages.config.v0.1.schema.json",
3
+ "$id": "https://schemas.zeropress.dev/build-pages-config/v0.1/schema.json",
4
4
  "title": "ZeroPress Build Pages Config v0.1",
5
5
  "description": "Optional source-directory configuration for @zeropress/build-pages workflows.",
6
6
  "markdownDescription": "Optional source-directory configuration for `@zeropress/build-pages` workflows.",
@@ -50,12 +50,27 @@
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
+ "logo": {
54
+ "$ref": "#/$defs/siteLogo"
55
+ },
56
+ "locale": {
57
+ "type": "string",
58
+ "minLength": 2,
59
+ "description": "Site locale used for generated preview-data language metadata, such as html lang and feed language.",
60
+ "markdownDescription": "Site locale used for generated preview-data language metadata, such as HTML `lang` and feed language."
61
+ },
53
62
  "expose_generator": {
54
63
  "type": "boolean",
55
64
  "default": true,
56
65
  "description": "Whether generated HTML should expose the ZeroPress generator meta tag. Set false for white-label sites.",
57
66
  "markdownDescription": "Whether generated HTML should expose `<meta name=\"generator\" content=\"ZeroPress\">`. Set `false` for white-label sites."
58
67
  },
68
+ "search": {
69
+ "type": "boolean",
70
+ "default": true,
71
+ "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.",
72
+ "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."
73
+ },
59
74
  "indexing": {
60
75
  "type": "boolean",
61
76
  "default": true,
@@ -64,6 +79,29 @@
64
79
  },
65
80
  "footer": {
66
81
  "$ref": "#/$defs/siteFooter"
82
+ },
83
+ "meta": {
84
+ "$ref": "#/$defs/previewMeta"
85
+ }
86
+ }
87
+ },
88
+ "siteLogo": {
89
+ "type": "object",
90
+ "additionalProperties": false,
91
+ "required": ["src"],
92
+ "description": "Optional site logo data copied into generated preview-data.",
93
+ "markdownDescription": "Optional site logo data copied into generated preview-data.",
94
+ "properties": {
95
+ "src": {
96
+ "type": "string",
97
+ "minLength": 1,
98
+ "description": "Logo URL or safe relative path.",
99
+ "markdownDescription": "Logo URL or safe relative path."
100
+ },
101
+ "alt": {
102
+ "type": "string",
103
+ "description": "Optional logo alternative text. Themes may fall back to site.title when omitted.",
104
+ "markdownDescription": "Optional logo alternative text. Themes may fall back to `site.title` when omitted."
67
105
  }
68
106
  }
69
107
  },
@@ -285,14 +323,23 @@
285
323
  },
286
324
  "examples": [
287
325
  {
288
- "$schema": "https://zeropress.dev/schemas/zeropress-build-pages.config.v0.1.schema.json",
326
+ "$schema": "https://schemas.zeropress.dev/build-pages-config/v0.1/schema.json",
289
327
  "version": "0.1",
290
328
  "site": {
291
329
  "title": "ZeroPress Public Docs",
292
330
  "description": "Public documentation.",
293
331
  "url": "https://zeropress.dev",
332
+ "logo": {
333
+ "src": "/logo.svg",
334
+ "alt": "ZeroPress Public Docs"
335
+ },
336
+ "locale": "en-US",
294
337
  "expose_generator": true,
295
- "indexing": true
338
+ "search": true,
339
+ "indexing": true,
340
+ "meta": {
341
+ "issue": "Spring 2026"
342
+ }
296
343
  },
297
344
  "front_page": {
298
345
  "type": "markdown"
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'),
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();