@zeropress/build-core 0.6.12 → 0.7.0

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.
@@ -8,20 +8,24 @@ import { ZeroPressEngine } from '../render/zeropress-engine.js';
8
8
 
9
9
  const DEFAULT_OPTIONS = {
10
10
  assetHashing: true,
11
- generateSpecialFiles: true,
12
11
  generateFeed: true,
13
12
  generateRobotsTxt: true,
14
- writeManifest: false,
15
13
  };
16
14
 
17
15
  const DEFAULT_POSTS_PER_PAGE = 10;
18
- const DEFAULT_DATETIME_DISPLAY = 'static';
19
16
  const DEFAULT_DATE_STYLE = 'medium';
20
17
  const DEFAULT_TIME_STYLE = 'none';
21
18
  const DEFAULT_TIMEZONE = 'UTC';
22
19
  const DEFAULT_LOCALE = 'en-US';
23
- const DATETIME_DISPLAY_MODES = new Set(['static', 'client']);
20
+ const DEFAULT_COMMENTS_PROVIDER = 'zeropress';
21
+ const DEFAULT_COMMENTS_PER_PAGE = 50;
22
+ const DEFAULT_COMMENTS_ORDER = 'desc';
23
+ const DEFAULT_COMMENTS_THREADING_ENABLED = true;
24
+ const DEFAULT_COMMENTS_THREADING_MAX_DEPTH = 2;
24
25
  const DATETIME_STYLES = new Set(['none', 'short', 'medium', 'long', 'full']);
26
+ const COMMENTS_PROVIDERS = new Set(['zeropress', 'wordpress']);
27
+ const COMMENTS_ORDERS = new Set(['asc', 'desc']);
28
+ const DISABLED_COMMENTS_CONTEXT = Object.freeze({ enabled: false });
25
29
  const DEFAULT_PERMALINKS = Object.freeze({
26
30
  output_style: 'directory',
27
31
  posts: '/posts/:slug/',
@@ -43,9 +47,12 @@ const SEARCH_ADAPTER_OUTPUT_PATH = '_zeropress/search.js';
43
47
  const SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH = '_zeropress/search_pagefind.js';
44
48
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
45
49
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
46
- const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
50
+ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:']);
47
51
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
48
52
  const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
53
+ const CUSTOM_HTML_SLOT_MAX_CODE_POINTS = 65_536;
54
+ const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
55
+ const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
49
56
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
50
57
  const RESPONSIVE_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif']);
51
58
  const SEARCH_FIELD_WEIGHTS = Object.freeze({
@@ -100,32 +107,32 @@ export async function buildSite(input) {
100
107
  await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
101
108
  }
102
109
 
103
- if (shouldGenerateSearchArtifacts(state, options)) {
110
+ if (shouldGenerateSearchArtifacts(state)) {
104
111
  await writeOutput(state.writer, state.summaries, SEARCH_INDEX_OUTPUT_PATH, buildSearchIndexJson(state), 'application/json');
105
112
  await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(), 'application/javascript');
106
113
  await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
107
114
  }
108
115
 
109
- if (options.generateSpecialFiles) {
110
- await maybeRenderNotFoundPage(state);
111
- if (hasCanonicalSiteUrl(state.previewData.site.url)) {
112
- await writeOutput(
113
- state.writer,
114
- state.summaries,
115
- 'sitemap.xml',
116
- buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt, options.sitemapStylesheetHref),
117
- 'application/xml',
118
- );
119
- if (shouldGenerateFeed(options)) {
120
- await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
121
- }
122
- }
123
- if (shouldGenerateRobotsTxt(options)) {
124
- await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
116
+ await maybeRenderNotFoundPage(state);
117
+ if (hasCanonicalSiteUrl(state.previewData.site.url)) {
118
+ await writeOutput(
119
+ state.writer,
120
+ state.summaries,
121
+ 'sitemap.xml',
122
+ buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt, options.sitemapStylesheetHref),
123
+ 'application/xml',
124
+ );
125
+ if (shouldGenerateFeed(state)) {
126
+ await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
125
127
  }
126
128
  }
129
+ if (shouldGenerateRobotsTxt(options)) {
130
+ await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
131
+ }
127
132
 
128
- return finalizeBuildResult(state.writer, state.summaries, options);
133
+ return {
134
+ files: state.summaries,
135
+ };
129
136
  }
130
137
 
131
138
  async function createBuildState(input, options) {
@@ -140,7 +147,7 @@ async function createBuildState(input, options) {
140
147
  const assetProcessor = new AssetProcessor();
141
148
  const summaries = [];
142
149
  const previewData = normalizePreviewData(input.previewData, options);
143
- const renderData = createRenderData(previewData, themePackage.metadata);
150
+ const renderData = createRenderData(previewData, themePackage, options);
144
151
 
145
152
  engine.initialize(themePackage);
146
153
 
@@ -193,30 +200,9 @@ function assertBuildPreviewData(previewData) {
193
200
  }
194
201
  }
195
202
 
196
- async function finalizeBuildResult(writer, summaries, options) {
197
- let manifest;
198
- if (options.writeManifest) {
199
- manifest = {
200
- generatedAt: formatUtcIsoSeconds(new Date()),
201
- files: summaries.map((file) => ({ ...file })),
202
- };
203
- await writeOutput(
204
- writer,
205
- summaries,
206
- 'build-manifest.json',
207
- JSON.stringify(manifest, null, 2),
208
- 'application/json',
209
- );
210
- }
211
-
212
- return {
213
- files: summaries,
214
- manifest,
215
- };
216
- }
217
-
218
203
  async function renderRoute(state, templateName, route) {
219
204
  const currentUrl = routePathToPublicUrl(route.path, state.previewData.site.permalinks.output_style);
205
+ const outputPath = routePathToOutputPath(route.path, state.previewData.site.permalinks.output_style);
220
206
  const routeContext = buildRouteContext(route.route_type || templateName, currentUrl, {
221
207
  isFrontPage: route.is_front_page === true,
222
208
  isPostIndex: route.is_post_index === true,
@@ -242,8 +228,11 @@ async function renderRoute(state, templateName, route) {
242
228
  createRenderContext(state.previewData.site, currentUrl),
243
229
  );
244
230
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
245
- html = injectSiteCustomizations(html, state);
246
- await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path, state.previewData.site.permalinks.output_style), html, 'text/html');
231
+ html = injectSiteCustomizations(html, state, {
232
+ route: currentUrl,
233
+ outputPath,
234
+ });
235
+ await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
247
236
  recordRouteEmission(state, templateName, route, currentUrl);
248
237
  }
249
238
 
@@ -288,10 +277,17 @@ async function renderFrontPage(state, route) {
288
277
  robotsNoindex: shouldNoindexDocument(page),
289
278
  }),
290
279
  },
291
- createRenderContext(state.previewData.site, currentUrl),
280
+ createRenderContext(
281
+ state.previewData.site,
282
+ currentUrl,
283
+ getTargetCommentsContext(state, 'page', state.renderData.pageReferencePathByPage.get(route.page)),
284
+ ),
292
285
  );
293
286
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
294
- html = injectSiteCustomizations(html, state);
287
+ html = injectSiteCustomizations(html, state, {
288
+ route: currentUrl,
289
+ outputPath: 'index.html',
290
+ });
295
291
  await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
296
292
  state.emitted.frontPage = {
297
293
  url: currentUrl,
@@ -323,7 +319,10 @@ async function renderFrontPage(state, route) {
323
319
  createRenderContext(state.previewData.site, currentUrl),
324
320
  );
325
321
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
326
- html = injectSiteCustomizations(html, state);
322
+ html = injectSiteCustomizations(html, state, {
323
+ route: currentUrl,
324
+ outputPath: 'index.html',
325
+ });
327
326
  await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
328
327
  state.emitted.frontPage = {
329
328
  url: currentUrl,
@@ -335,6 +334,7 @@ async function renderFrontPage(state, route) {
335
334
 
336
335
  async function renderPost(state, post) {
337
336
  const currentUrl = post.url;
337
+ const outputPath = routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style);
338
338
  let html = await state.engine.render(
339
339
  'post',
340
340
  {
@@ -355,11 +355,18 @@ async function renderPost(state, post) {
355
355
  robotsNoindex: shouldNoindexDocument(post),
356
356
  }),
357
357
  },
358
- createRenderContext(state.previewData.site, currentUrl),
358
+ createRenderContext(
359
+ state.previewData.site,
360
+ currentUrl,
361
+ getTargetCommentsContext(state, 'post', post.slug),
362
+ ),
359
363
  );
360
364
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
361
- html = injectSiteCustomizations(html, state);
362
- await writeOutput(state.writer, state.summaries, routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style), html, 'text/html');
365
+ html = injectSiteCustomizations(html, state, {
366
+ route: currentUrl,
367
+ outputPath,
368
+ });
369
+ await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
363
370
  if (!isDelistedDocument(post)) {
364
371
  state.emitted.posts.push({
365
372
  url: currentUrl,
@@ -395,10 +402,17 @@ async function renderPage(state, page) {
395
402
  robotsNoindex: shouldNoindexDocument(page),
396
403
  }),
397
404
  },
398
- createRenderContext(state.previewData.site, currentUrl),
405
+ createRenderContext(
406
+ state.previewData.site,
407
+ currentUrl,
408
+ getTargetCommentsContext(state, 'page', state.renderData.pageReferencePathByPage.get(page)),
409
+ ),
399
410
  );
400
411
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
401
- html = injectSiteCustomizations(html, state);
412
+ html = injectSiteCustomizations(html, state, {
413
+ route: currentUrl,
414
+ outputPath,
415
+ });
402
416
  await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
403
417
  state.emitted.pages.push({
404
418
  url: currentUrl,
@@ -425,53 +439,72 @@ async function maybeRenderNotFoundPage(state) {
425
439
  route: buildRouteContext('not_found', '/404.html'),
426
440
  meta: buildPageMeta(state.previewData.site, {
427
441
  currentUrl: '/404.html',
428
- title: state.previewData.site.title,
429
- description: state.previewData.site.description,
430
- ogType: 'website',
442
+ title: buildDocumentTitle('Page Not Found', state.previewData.site.title),
443
+ robotsNoindex: true,
444
+ includeRichMetadata: false,
445
+ includeFeedLink: false,
431
446
  }),
432
447
  },
433
448
  createRenderContext(state.previewData.site, '/404.html'),
434
449
  );
435
450
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
436
- html = injectSiteCustomizations(html, state);
451
+ html = injectSiteCustomizations(html, state, {
452
+ route: '/404.html',
453
+ outputPath: '404.html',
454
+ });
437
455
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
438
456
  }
439
457
 
440
458
  function normalizePreviewData(previewData, options = {}) {
441
- const media_base_url = normalizeOptionalString(previewData.site.media_base_url);
459
+ const media_origin = normalizeMediaOrigin(previewData.site.media_origin);
460
+ const mediaDeliveryMode = MEDIA_DELIVERY_MODES.has(previewData.site.media_delivery_mode)
461
+ ? previewData.site.media_delivery_mode
462
+ : 'none';
463
+ if (mediaDeliveryMode === 'media_domain' && !media_origin) {
464
+ throw new Error('Invalid preview-data: site.media_origin must be a non-empty HTTP(S) origin when site.media_delivery_mode is "media_domain".');
465
+ }
466
+ const {
467
+ search: siteSearch,
468
+ feed: siteFeed,
469
+ archive: siteArchive,
470
+ comments: siteComments,
471
+ robots: siteRobots,
472
+ ...siteFields
473
+ } = previewData.site;
474
+ const normalizedComments = normalizeSiteComments(siteComments);
442
475
  const normalizedSite = {
443
- ...previewData.site,
444
- media_base_url,
445
- media_delivery_mode: MEDIA_DELIVERY_MODES.has(previewData.site.media_delivery_mode)
446
- ? previewData.site.media_delivery_mode
447
- : 'none',
476
+ ...siteFields,
477
+ url: normalizeSiteOrigin(previewData.site.url),
478
+ media_origin,
479
+ media_delivery_mode: mediaDeliveryMode,
448
480
  favicon: previewData.site.favicon
449
- ? normalizeSiteFavicon(previewData.site.favicon, media_base_url)
481
+ ? normalizeSiteFavicon(previewData.site.favicon, media_origin)
450
482
  : normalizeSiteFavicon(options.favicon, ''),
451
- logo: normalizeSiteLogo(previewData.site.logo, media_base_url),
483
+ logo: normalizeSiteLogo(previewData.site.logo, media_origin),
452
484
  newsletter: normalizeSiteNewsletter(previewData.site.newsletter),
453
485
  posts_per_page: Number.isInteger(previewData.site.posts_per_page) && previewData.site.posts_per_page > 0
454
486
  ? previewData.site.posts_per_page
455
487
  : DEFAULT_POSTS_PER_PAGE,
456
- datetime_display: DATETIME_DISPLAY_MODES.has(previewData.site.datetime_display)
457
- ? previewData.site.datetime_display
458
- : DEFAULT_DATETIME_DISPLAY,
459
488
  date_style: DATETIME_STYLES.has(previewData.site.date_style)
460
489
  ? previewData.site.date_style
461
490
  : DEFAULT_DATE_STYLE,
462
491
  time_style: DATETIME_STYLES.has(previewData.site.time_style)
463
492
  ? previewData.site.time_style
464
493
  : DEFAULT_TIME_STYLE,
465
- timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
466
- locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
467
- disallow_comments: previewData.site.disallow_comments === true,
494
+ timezone: normalizeTimezone(previewData.site.timezone),
495
+ locale: normalizeLocale(previewData.site.locale),
468
496
  expose_generator: previewData.site.expose_generator !== false,
469
- search: previewData.site.search !== false,
470
- indexing: previewData.site.indexing !== false,
497
+ search: normalizeRequestedFeatureState(siteSearch),
498
+ feed: normalizeRequestedFeatureState(siteFeed),
499
+ archive: normalizeRequestedFeatureState(siteArchive),
500
+ robots: {
501
+ allow_indexing: siteRobots?.allow_indexing !== false,
502
+ },
471
503
  permalinks: normalizePermalinks(previewData.site.permalinks),
472
504
  front_page: normalizeFrontPage(previewData.site.front_page),
473
505
  post_index: normalizePostIndex(previewData.site.post_index),
474
506
  footer: normalizeSiteFooter(previewData.site.footer),
507
+ ...(normalizedComments ? { comments: normalizedComments } : {}),
475
508
  };
476
509
  const media = normalizeContentMedia(previewData.content.media, normalizedSite);
477
510
  const mediaRegistry = buildMediaRegistry(media);
@@ -479,15 +512,15 @@ function normalizePreviewData(previewData, options = {}) {
479
512
  return {
480
513
  ...previewData,
481
514
  site: normalizedSite,
482
- menus: normalizeRecordMap(previewData.menus),
515
+ menus: normalizeMenus(previewData.menus),
483
516
  collections: normalizeCollections(previewData.collections),
484
- widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_base_url),
517
+ widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_origin),
485
518
  custom_css: normalizeCustomCss(previewData.custom_css),
486
519
  custom_html: normalizeCustomHtml(previewData.custom_html),
487
520
  content: {
488
521
  ...previewData.content,
489
522
  authors: previewData.content.authors.map((author) => {
490
- const avatar = normalizeMediaField(author.avatar, normalizedSite.media_base_url);
523
+ const avatar = normalizeMediaField(author.avatar, normalizedSite.media_origin);
491
524
  const avatarMedia = deriveManagedMedia(avatar, mediaRegistry, normalizedSite);
492
525
  return {
493
526
  ...author,
@@ -497,27 +530,35 @@ function normalizePreviewData(previewData, options = {}) {
497
530
  }),
498
531
  posts: previewData.content.posts
499
532
  .map((post) => {
500
- const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_base_url);
533
+ const { comments, ...postFields } = post;
534
+ const normalizedPostComments = normalizeTargetComments(comments);
535
+ const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_origin);
501
536
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
502
537
  return {
503
- ...post,
538
+ ...postFields,
504
539
  published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
505
540
  updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
541
+ allow_comments: post.allow_comments === true,
506
542
  discoverability: normalizeDiscoverability(post.discoverability),
507
543
  featured_image: featuredImage,
508
544
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
545
+ ...(normalizedPostComments ? { comments: normalizedPostComments } : {}),
509
546
  };
510
547
  })
511
548
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
512
549
  pages: previewData.content.pages.map((page) => {
513
- const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_base_url);
550
+ const { comments, ...pageFields } = page;
551
+ const normalizedPageComments = normalizeTargetComments(comments);
552
+ const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_origin);
514
553
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
515
554
  return {
516
- ...page,
555
+ ...pageFields,
517
556
  ...(page.updated_at_iso ? { updated_at_iso: normalizeIsoTimestamp(page.updated_at_iso) } : {}),
557
+ allow_comments: page.allow_comments === true,
518
558
  discoverability: normalizeDiscoverability(page.discoverability),
519
559
  featured_image: featuredImage,
520
560
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
561
+ ...(normalizedPageComments ? { comments: normalizedPageComments } : {}),
521
562
  };
522
563
  }),
523
564
  categories: [...previewData.content.categories],
@@ -535,6 +576,22 @@ function normalizeRecordMap(value) {
535
576
  return { ...value };
536
577
  }
537
578
 
579
+ function normalizeMenus(menus) {
580
+ const normalized = normalizeRecordMap(menus);
581
+ return Object.fromEntries(Object.entries(normalized).map(([menuId, menu]) => [
582
+ menuId,
583
+ {
584
+ ...menu,
585
+ items: Array.isArray(menu?.items)
586
+ ? menu.items.map((item) => ({
587
+ ...item,
588
+ url: normalizeNavigationUrl(item?.url),
589
+ }))
590
+ : [],
591
+ },
592
+ ]));
593
+ }
594
+
538
595
  function normalizeDiscoverability(value) {
539
596
  return DISCOVERABILITY_VALUES.has(value) ? value : 'default';
540
597
  }
@@ -557,7 +614,7 @@ function normalizeContentMedia(mediaItems, site) {
557
614
  if (!item || typeof item !== 'object') {
558
615
  return null;
559
616
  }
560
- const src = normalizeMediaField(item.src, site.media_base_url);
617
+ const src = normalizeMediaField(item.src, site.media_origin);
561
618
  const width = Number.isInteger(item.width) && item.width > 0 ? item.width : 0;
562
619
  const height = Number.isInteger(item.height) && item.height > 0 ? item.height : 0;
563
620
  if (!src || !width || !height) {
@@ -603,8 +660,8 @@ function buildResponsiveImageSrcset(media, site) {
603
660
  return '';
604
661
  }
605
662
 
606
- const media_base_url = normalizeOptionalString(site.media_base_url);
607
- if (!media_base_url || !isUrlUnderMediaBase(media.src, media_base_url) || !isResponsiveRasterImage(media.src)) {
663
+ const media_origin = normalizeMediaOrigin(site.media_origin);
664
+ if (!media_origin || !isUrlAtMediaOrigin(media.src, media_origin) || !isResponsiveRasterImage(media.src)) {
608
665
  return '';
609
666
  }
610
667
 
@@ -631,12 +688,10 @@ function buildResponsiveImageVariantUrl(src, width) {
631
688
  }
632
689
  }
633
690
 
634
- function isUrlUnderMediaBase(src, media_base_url) {
691
+ function isUrlAtMediaOrigin(src, media_origin) {
635
692
  try {
636
693
  const sourceUrl = new URL(src);
637
- const baseUrl = new URL(media_base_url);
638
- const basePath = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/`;
639
- return sourceUrl.origin === baseUrl.origin && sourceUrl.pathname.startsWith(basePath);
694
+ return sourceUrl.origin === media_origin;
640
695
  } catch {
641
696
  return false;
642
697
  }
@@ -671,7 +726,7 @@ function normalizeCollections(collections) {
671
726
  );
672
727
  }
673
728
 
674
- function normalizeWidgetAreas(widget_areas, media_base_url) {
729
+ function normalizeWidgetAreas(widget_areas, media_origin) {
675
730
  if (!widget_areas || typeof widget_areas !== 'object') {
676
731
  return {};
677
732
  }
@@ -683,7 +738,7 @@ function normalizeWidgetAreas(widget_areas, media_base_url) {
683
738
  ...widgetArea,
684
739
  name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
685
740
  items: Array.isArray(widgetArea?.items)
686
- ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_base_url))
741
+ ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_origin))
687
742
  : [],
688
743
  },
689
744
  ]),
@@ -700,7 +755,7 @@ function normalizeSiteFooter(footer) {
700
755
  };
701
756
  }
702
757
 
703
- function normalizeWidgetItem(item, media_base_url) {
758
+ function normalizeWidgetItem(item, media_origin) {
704
759
  const normalizedItem = {
705
760
  ...item,
706
761
  title: typeof item?.title === 'string' ? item.title.trim() : '',
@@ -711,7 +766,7 @@ function normalizeWidgetItem(item, media_base_url) {
711
766
  ...normalizedItem,
712
767
  settings: {
713
768
  ...item.settings,
714
- avatar: normalizeMediaField(item.settings.avatar, media_base_url),
769
+ avatar: normalizeMediaField(item.settings.avatar, media_origin),
715
770
  },
716
771
  };
717
772
  }
@@ -725,44 +780,52 @@ function normalizeCustomCss(customCss) {
725
780
  }
726
781
 
727
782
  function normalizeCustomHtml(customHtml) {
728
- if (!customHtml || typeof customHtml !== 'object') {
783
+ if (!customHtml || typeof customHtml !== 'object' || Array.isArray(customHtml)) {
729
784
  return undefined;
730
785
  }
731
786
 
732
- const headEnd = normalizeOptionalRawString(customHtml.head_end?.content);
733
- const bodyEnd = normalizeOptionalRawString(customHtml.body_end?.content);
787
+ const headEnd = normalizeCustomHtmlSlot(customHtml.head_end);
788
+ const bodyEnd = normalizeCustomHtmlSlot(customHtml.body_end);
734
789
  if (!headEnd && !bodyEnd) {
735
790
  return undefined;
736
791
  }
737
792
 
738
793
  return {
739
- ...(headEnd ? { head_end: { content: headEnd } } : {}),
740
- ...(bodyEnd ? { body_end: { content: bodyEnd } } : {}),
794
+ ...(headEnd ? { head_end: headEnd } : {}),
795
+ ...(bodyEnd ? { body_end: bodyEnd } : {}),
741
796
  };
742
797
  }
743
798
 
744
- function normalizeSiteFavicon(favicon, media_base_url) {
799
+ function normalizeCustomHtmlSlot(value) {
800
+ if (typeof value !== 'string' || !value.trim()) {
801
+ return '';
802
+ }
803
+
804
+ return [...value].length <= CUSTOM_HTML_SLOT_MAX_CODE_POINTS ? value : '';
805
+ }
806
+
807
+ function normalizeSiteFavicon(favicon, media_origin) {
745
808
  if (!favicon || typeof favicon !== 'object') {
746
809
  return undefined;
747
810
  }
748
811
 
749
812
  const normalized = {};
750
- for (const key of ['icon', 'svg', 'png', 'apple_touch_icon']) {
813
+ for (const key of ['icon', 'icon_dark', 'svg', 'png', 'apple_touch_icon']) {
751
814
  const value = normalizeOptionalString(favicon[key]);
752
815
  if (value) {
753
- normalized[key] = normalizeMediaField(value, media_base_url);
816
+ normalized[key] = normalizeMediaField(value, media_origin);
754
817
  }
755
818
  }
756
819
 
757
820
  return Object.keys(normalized).length ? normalized : undefined;
758
821
  }
759
822
 
760
- function normalizeSiteLogo(logo, media_base_url) {
823
+ function normalizeSiteLogo(logo, media_origin) {
761
824
  if (!logo || typeof logo !== 'object') {
762
825
  return undefined;
763
826
  }
764
827
 
765
- const src = normalizeMediaField(logo.src, media_base_url);
828
+ const src = normalizeMediaField(logo.src, media_origin);
766
829
  if (!src) {
767
830
  return undefined;
768
831
  }
@@ -782,8 +845,8 @@ function normalizeSiteNewsletter(newsletter) {
782
845
  const title = normalizeOptionalString(newsletter.title);
783
846
  const description = normalizeOptionalString(newsletter.description);
784
847
  const buttonLabel = normalizeOptionalString(newsletter.button_label);
785
- const signupUrl = normalizeOptionalString(newsletter.signup_url);
786
- const embedUrl = normalizeOptionalString(newsletter.embed_url);
848
+ const signupUrl = normalizeNavigationUrl(newsletter.signup_url);
849
+ const embedUrl = normalizeNavigationUrl(newsletter.embed_url);
787
850
 
788
851
  return {
789
852
  enabled: newsletter.enabled === true,
@@ -795,6 +858,66 @@ function normalizeSiteNewsletter(newsletter) {
795
858
  };
796
859
  }
797
860
 
861
+ function normalizeSiteComments(comments) {
862
+ if (!comments || typeof comments !== 'object' || Array.isArray(comments)) {
863
+ return undefined;
864
+ }
865
+
866
+ const apiBaseUrl = normalizeCommentsApiBaseUrl(comments.api_base_url);
867
+ if (!apiBaseUrl) {
868
+ return undefined;
869
+ }
870
+
871
+ const threading = comments.threading && typeof comments.threading === 'object' && !Array.isArray(comments.threading)
872
+ ? comments.threading
873
+ : {};
874
+
875
+ return {
876
+ enabled: comments.enabled === true,
877
+ provider: COMMENTS_PROVIDERS.has(comments.provider)
878
+ ? comments.provider
879
+ : DEFAULT_COMMENTS_PROVIDER,
880
+ api_base_url: apiBaseUrl,
881
+ per_page: Number.isInteger(comments.per_page) && comments.per_page >= 1 && comments.per_page <= 100
882
+ ? comments.per_page
883
+ : DEFAULT_COMMENTS_PER_PAGE,
884
+ order: COMMENTS_ORDERS.has(comments.order)
885
+ ? comments.order
886
+ : DEFAULT_COMMENTS_ORDER,
887
+ threading: {
888
+ enabled: typeof threading.enabled === 'boolean'
889
+ ? threading.enabled
890
+ : DEFAULT_COMMENTS_THREADING_ENABLED,
891
+ max_depth: Number.isInteger(threading.max_depth) && threading.max_depth >= 2 && threading.max_depth <= 10
892
+ ? threading.max_depth
893
+ : DEFAULT_COMMENTS_THREADING_MAX_DEPTH,
894
+ },
895
+ };
896
+ }
897
+
898
+ function normalizeRequestedFeatureState(value) {
899
+ return {
900
+ enabled: value?.enabled !== false,
901
+ };
902
+ }
903
+
904
+ function normalizeCommentsApiBaseUrl(value) {
905
+ const normalized = normalizeOptionalString(value);
906
+ if (!normalized || normalized === '/') {
907
+ return normalized;
908
+ }
909
+ return normalized.replace(/\/+$/u, '');
910
+ }
911
+
912
+ function normalizeTargetComments(comments) {
913
+ if (!comments || typeof comments !== 'object' || Array.isArray(comments)) {
914
+ return undefined;
915
+ }
916
+
917
+ const requestToken = preserveOpaqueNonBlankString(comments.request_token);
918
+ return requestToken ? { request_token: requestToken } : undefined;
919
+ }
920
+
798
921
  function normalizePermalinks(permalinks) {
799
922
  const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
800
923
  const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
@@ -821,7 +944,7 @@ function normalizeFrontPage(frontPage) {
821
944
 
822
945
  return {
823
946
  type,
824
- ...(type === 'page' ? { page_slug: normalizeOptionalString(frontPage.page_slug) } : {}),
947
+ ...(type === 'page' ? { page_path: normalizePageReferencePath(frontPage.page_path) } : {}),
825
948
  ...(type === 'standalone_html' ? { html: normalizeOptionalRawString(frontPage.html) } : {}),
826
949
  };
827
950
  }
@@ -838,11 +961,31 @@ function normalizePostIndex(post_index) {
838
961
  };
839
962
  }
840
963
 
841
- function createRenderData(previewData, themeMetadata = {}) {
964
+ function createRenderData(previewData, themePackage = {}, options = {}) {
965
+ const themeMetadata = themePackage.metadata || {};
842
966
  const themeSupportsComments = themeMetadata?.features?.comments === true;
843
967
  const themeSupportsPostIndex = themeMetadata?.features?.post_index !== false;
844
968
  const themeSupportsSearch = themeMetadata?.features?.search === true;
845
- previewData.site.search = previewData.site.search !== false && themeSupportsSearch;
969
+ const outputStyle = previewData.site.permalinks.output_style;
970
+ const requestedComments = previewData.site.comments;
971
+ previewData.site.search = {
972
+ enabled: previewData.site.search.enabled === true && themeSupportsSearch,
973
+ };
974
+ previewData.site.feed = previewData.site.feed.enabled === true
975
+ && hasCanonicalSiteUrl(previewData.site.url)
976
+ && options.generateFeed !== false
977
+ ? { enabled: true, url: '/feed.xml' }
978
+ : { enabled: false };
979
+ previewData.site.archive = previewData.site.archive.enabled === true
980
+ && themePackage.templates?.has('archive') === true
981
+ ? {
982
+ enabled: true,
983
+ url: outputStyle === 'html-extension' ? '/archive' : '/archive/',
984
+ }
985
+ : { enabled: false };
986
+ previewData.site.comments = requestedComments?.enabled === true && themeSupportsComments
987
+ ? { ...requestedComments, enabled: true }
988
+ : { enabled: false };
846
989
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
847
990
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
848
991
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -864,7 +1007,38 @@ function createRenderData(previewData, themeMetadata = {}) {
864
1007
  }
865
1008
  }
866
1009
 
867
- const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
1010
+ const rawPageReferencePaths = new Map(previewData.content.pages.map((page) => [
1011
+ page,
1012
+ resolveEffectivePageReferencePath(previewData.site, page),
1013
+ ]));
1014
+ assertUniquePageReferencePaths(rawPageReferencePaths);
1015
+ const commentsByTarget = {
1016
+ posts: new Map(previewData.content.posts.map((post) => [
1017
+ post.slug,
1018
+ buildTargetCommentsContext({
1019
+ site: previewData.site,
1020
+ target: post,
1021
+ targetType: 'post',
1022
+ themeSupportsComments,
1023
+ }),
1024
+ ])),
1025
+ pages: new Map(previewData.content.pages.map((page) => [
1026
+ rawPageReferencePaths.get(page),
1027
+ buildTargetCommentsContext({
1028
+ site: previewData.site,
1029
+ target: page,
1030
+ targetType: 'page',
1031
+ themeSupportsComments,
1032
+ }),
1033
+ ])),
1034
+ };
1035
+ for (const target of previewData.content.posts) {
1036
+ delete target.comments;
1037
+ }
1038
+ for (const target of previewData.content.pages) {
1039
+ delete target.comments;
1040
+ }
1041
+ const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
868
1042
  const discoverablePreparedPosts = preparedPosts.filter((post) => !isDelistedDocument(post));
869
1043
  const adjacentPostsBySlug = new Map(
870
1044
  discoverablePreparedPosts.map((post, index) => [post.slug, {
@@ -878,30 +1052,50 @@ function createRenderData(previewData, themeMetadata = {}) {
878
1052
  next: adjacentPostsBySlug.get(post.slug)?.next || null,
879
1053
  }));
880
1054
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
1055
+ const pageReferencePathByPage = new Map(pages.map((page, index) => [
1056
+ page,
1057
+ rawPageReferencePaths.get(previewData.content.pages[index]),
1058
+ ]));
881
1059
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
882
- const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
1060
+ const pageByPath = new Map(pages.map((page) => [pageReferencePathByPage.get(page), page]));
883
1061
  const frontPage = previewData.site.front_page;
884
- const collections = resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage);
885
- attachCollectionCursors(posts, pages, collections);
1062
+ const collectionTargetByItem = new WeakMap();
1063
+ const collections = resolveCollections(
1064
+ previewData.collections,
1065
+ postBySlug,
1066
+ pageByPath,
1067
+ frontPage,
1068
+ pageReferencePathByPage,
1069
+ collectionTargetByItem,
1070
+ );
1071
+ attachCollectionCursors(collections, collectionTargetByItem);
886
1072
  const post_index = previewData.site.post_index;
887
1073
  const effectivePostIndexEnabled = post_index.enabled !== false && themeSupportsPostIndex;
888
1074
  const effectivePostIndexPaginate = effectivePostIndexEnabled && post_index.paginate !== false;
889
1075
  const post_indexBasePath = normalizeRoutePath(post_index.path || DEFAULT_POST_INDEX.path);
1076
+ previewData.site.post_index = {
1077
+ ...post_index,
1078
+ enabled: effectivePostIndexEnabled,
1079
+ paginate: effectivePostIndexPaginate,
1080
+ };
890
1081
 
891
1082
  if (frontPage.type !== 'theme_index' && effectivePostIndexEnabled && post_indexBasePath === '/') {
892
1083
  throw new Error('Invalid front page configuration: site.front_page occupies "/" so site.post_index.path must not be "/". Set site.post_index.path to a non-root path or disable site.post_index.');
893
1084
  }
894
1085
 
895
- const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath);
896
- const pageFrontPageSlug = frontPage.type === 'page' ? frontPage.page_slug : '';
897
- const preparedPages = pageFrontPageSlug
898
- ? pages.filter((page) => page.slug !== pageFrontPageSlug)
1086
+ const frontPageRoute = buildFrontPageRoute(frontPage, pageByPath, effectivePostIndexEnabled, post_indexBasePath);
1087
+ const frontPagePage = frontPageRoute?.front_page_type === 'page' ? frontPageRoute.page : null;
1088
+ const preparedPages = frontPagePage
1089
+ ? pages.filter((page) => page !== frontPagePage)
899
1090
  : pages;
900
1091
 
901
1092
  return {
902
1093
  posts,
903
1094
  pages: preparedPages,
904
1095
  postBySlug,
1096
+ pageByPath,
1097
+ pageReferencePathByPage,
1098
+ commentsByTarget,
905
1099
  collections,
906
1100
  taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
907
1101
  frontPageRoute,
@@ -915,20 +1109,22 @@ function createRenderData(previewData, themeMetadata = {}) {
915
1109
  postBySlug,
916
1110
  frontPage,
917
1111
  }),
918
- archiveRoutes: buildPaginatedCollection({
919
- items: discoverableSourcePosts,
920
- posts_per_page: previewData.site.posts_per_page,
921
- basePath: '/archive/',
922
- outputStyle: previewData.site.permalinks.output_style,
923
- }).map((entry) => ({
924
- path: entry.path,
925
- page: entry.page,
926
- totalPages: entry.totalPages,
927
- archive: {
928
- groups: buildArchiveGroups(entry.items, postBySlug),
929
- },
930
- pagination: buildStructuredPagination(entry.paginationData),
931
- })),
1112
+ archiveRoutes: previewData.site.archive.enabled
1113
+ ? buildPaginatedCollection({
1114
+ items: discoverableSourcePosts,
1115
+ posts_per_page: previewData.site.posts_per_page,
1116
+ basePath: '/archive/',
1117
+ outputStyle: previewData.site.permalinks.output_style,
1118
+ }).map((entry) => ({
1119
+ path: entry.path,
1120
+ page: entry.page,
1121
+ totalPages: entry.totalPages,
1122
+ archive: {
1123
+ groups: buildArchiveGroups(entry.items, postBySlug, previewData.site),
1124
+ },
1125
+ pagination: buildStructuredPagination(entry.paginationData),
1126
+ }))
1127
+ : [],
932
1128
  categoryRoutes: buildTaxonomyRoutes({
933
1129
  items: previewData.content.categories,
934
1130
  postsBySlug: categoryPostsBySlug,
@@ -954,6 +1150,55 @@ function createRenderData(previewData, themeMetadata = {}) {
954
1150
  };
955
1151
  }
956
1152
 
1153
+ export function buildTargetCommentsContext({ site, target, targetType, themeSupportsComments }) {
1154
+ const targetPublicId = target?.public_id;
1155
+ const comments = site?.comments;
1156
+
1157
+ if (
1158
+ themeSupportsComments !== true ||
1159
+ comments?.enabled !== true ||
1160
+ target?.allow_comments !== true ||
1161
+ !Number.isInteger(targetPublicId) ||
1162
+ targetPublicId <= 0
1163
+ ) {
1164
+ return DISABLED_COMMENTS_CONTEXT;
1165
+ }
1166
+
1167
+ const common = {
1168
+ enabled: true,
1169
+ target_type: targetType,
1170
+ target_public_id: targetPublicId,
1171
+ provider: comments.provider,
1172
+ api_base_url: comments.api_base_url,
1173
+ per_page: comments.per_page,
1174
+ order: comments.order,
1175
+ threading: {
1176
+ enabled: comments.threading.enabled,
1177
+ max_depth: comments.threading.max_depth,
1178
+ },
1179
+ };
1180
+
1181
+ if (comments.provider === 'wordpress') {
1182
+ return common;
1183
+ }
1184
+
1185
+ if (comments.provider !== 'zeropress') {
1186
+ return DISABLED_COMMENTS_CONTEXT;
1187
+ }
1188
+
1189
+ const requestToken = preserveOpaqueNonBlankString(target?.comments?.request_token);
1190
+ return requestToken
1191
+ ? { ...common, request_token: requestToken }
1192
+ : DISABLED_COMMENTS_CONTEXT;
1193
+ }
1194
+
1195
+ function getTargetCommentsContext(state, targetType, slug) {
1196
+ const targetMap = targetType === 'post'
1197
+ ? state.renderData.commentsByTarget.posts
1198
+ : state.renderData.commentsByTarget.pages;
1199
+ return targetMap.get(slug) || DISABLED_COMMENTS_CONTEXT;
1200
+ }
1201
+
957
1202
  function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug) {
958
1203
  return {
959
1204
  categories: buildGlobalTaxonomyItems(previewData.site, 'categories', previewData.content.categories, categoryCountBySlug),
@@ -961,14 +1206,22 @@ function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug)
961
1206
  };
962
1207
  }
963
1208
 
964
- function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
1209
+ function resolveCollections(collections, postBySlug, pageByPath, frontPage, pageReferencePathByPage, collectionTargetByItem) {
965
1210
  if (!collections || typeof collections !== 'object') {
966
1211
  return {};
967
1212
  }
968
1213
 
969
1214
  return Object.fromEntries(
970
1215
  Object.entries(collections).map(([collectionId, collection]) => {
971
- const items = resolveCollectionItems(collectionId, collection?.items, postBySlug, pageBySlug, frontPage);
1216
+ const items = resolveCollectionItems(
1217
+ collectionId,
1218
+ collection?.items,
1219
+ postBySlug,
1220
+ pageByPath,
1221
+ frontPage,
1222
+ pageReferencePathByPage,
1223
+ collectionTargetByItem,
1224
+ );
972
1225
  return [
973
1226
  collectionId,
974
1227
  {
@@ -983,44 +1236,58 @@ function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
983
1236
  );
984
1237
  }
985
1238
 
986
- function resolveCollectionItems(collectionId, items, postBySlug, pageBySlug, frontPage) {
1239
+ function resolveCollectionItems(collectionId, items, postBySlug, pageByPath, frontPage, pageReferencePathByPage, collectionTargetByItem) {
987
1240
  if (!Array.isArray(items)) {
988
1241
  return [];
989
1242
  }
990
1243
 
991
- return items.map((item, index) => resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage));
1244
+ return items.map((item, index) => resolveCollectionItem(
1245
+ collectionId,
1246
+ item,
1247
+ index,
1248
+ postBySlug,
1249
+ pageByPath,
1250
+ frontPage,
1251
+ pageReferencePathByPage,
1252
+ collectionTargetByItem,
1253
+ ));
992
1254
  }
993
1255
 
994
- function resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage) {
1256
+ function resolveCollectionItem(collectionId, item, index, postBySlug, pageByPath, frontPage, pageReferencePathByPage, collectionTargetByItem) {
995
1257
  if (item?.type === 'post') {
996
1258
  const post = postBySlug.get(item.slug);
997
1259
  if (!post) {
998
1260
  throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing post slug "${item.slug}".`);
999
1261
  }
1000
- return {
1262
+ const resolved = {
1001
1263
  type: 'post',
1002
1264
  meta: post.meta,
1003
1265
  ...buildStructuredPostSummary(post),
1004
1266
  };
1267
+ collectionTargetByItem.set(resolved, post);
1268
+ return resolved;
1005
1269
  }
1006
1270
 
1007
1271
  if (item?.type === 'page') {
1008
- const page = pageBySlug.get(item.slug);
1272
+ const pagePath = normalizePageReferencePath(item.path);
1273
+ const page = pageByPath.get(pagePath);
1009
1274
  if (!page) {
1010
- throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing page slug "${item.slug}".`);
1275
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing page path "${item.path}".`);
1011
1276
  }
1012
- return buildCollectionPageSummary(page, frontPage);
1277
+ const resolved = buildCollectionPageSummary(page, frontPage, pageReferencePathByPage.get(page));
1278
+ collectionTargetByItem.set(resolved, page);
1279
+ return resolved;
1013
1280
  }
1014
1281
 
1015
1282
  throw new Error(`Invalid collection "${collectionId}": item ${index + 1} has unsupported type "${item?.type}".`);
1016
1283
  }
1017
1284
 
1018
- function buildCollectionPageSummary(page, frontPage) {
1285
+ function buildCollectionPageSummary(page, frontPage, pageReferencePath) {
1019
1286
  return {
1020
1287
  type: 'page',
1021
1288
  title: page.title,
1022
1289
  slug: page.slug,
1023
- url: frontPage?.type === 'page' && frontPage.page_slug === page.slug ? '/' : page.url,
1290
+ url: frontPage?.type === 'page' && frontPage.page_path === pageReferencePath ? '/' : page.url,
1024
1291
  excerpt: page.excerpt || '',
1025
1292
  featured_image: page.featured_image || '',
1026
1293
  updated_at: page.updated_at || '',
@@ -1031,19 +1298,12 @@ function buildCollectionPageSummary(page, frontPage) {
1031
1298
  };
1032
1299
  }
1033
1300
 
1034
- function attachCollectionCursors(posts, pages, collections) {
1035
- const postTargets = new Map(posts.map((post) => [post.slug, post]));
1036
- const pageTargets = new Map(pages.map((page) => [page.slug, page]));
1037
-
1301
+ function attachCollectionCursors(collections, collectionTargetByItem) {
1038
1302
  for (const [collectionId, collection] of Object.entries(collections || {})) {
1039
1303
  const items = Array.isArray(collection.items) ? collection.items : [];
1040
1304
 
1041
1305
  items.forEach((item, index) => {
1042
- const target = item.type === 'post'
1043
- ? postTargets.get(item.slug)
1044
- : item.type === 'page'
1045
- ? pageTargets.get(item.slug)
1046
- : null;
1306
+ const target = collectionTargetByItem.get(item);
1047
1307
 
1048
1308
  if (!target) {
1049
1309
  return;
@@ -1094,7 +1354,7 @@ function buildCollectionCursorItemSummary(item) {
1094
1354
  };
1095
1355
  }
1096
1356
 
1097
- function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath) {
1357
+ function buildFrontPageRoute(frontPage, pageByPath, effectivePostIndexEnabled, post_indexBasePath) {
1098
1358
  if (frontPage.type === 'theme_index') {
1099
1359
  if (effectivePostIndexEnabled && post_indexBasePath === '/') {
1100
1360
  return null;
@@ -1109,9 +1369,9 @@ function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_i
1109
1369
  }
1110
1370
 
1111
1371
  if (frontPage.type === 'page') {
1112
- const page = pages.find((entry) => entry.slug === frontPage.page_slug);
1372
+ const page = pageByPath.get(frontPage.page_path);
1113
1373
  if (!page) {
1114
- throw new Error(`Invalid front page configuration: site.front_page.page_slug "${frontPage.page_slug}" does not match a page.`);
1374
+ throw new Error(`Invalid front page configuration: site.front_page.page_path "${frontPage.page_path}" does not match a page.`);
1115
1375
  }
1116
1376
 
1117
1377
  return {
@@ -1231,13 +1491,17 @@ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
1231
1491
  case 'tags':
1232
1492
  return resolveTagsWidget(baseWidget, item.settings, previewData);
1233
1493
  case 'archives':
1234
- return resolveArchivesWidget(baseWidget, item.settings, previewData);
1494
+ return previewData.site.archive.enabled === true
1495
+ ? resolveArchivesWidget(baseWidget, item.settings, previewData)
1496
+ : null;
1235
1497
  case 'text':
1236
1498
  return resolveTextWidget(baseWidget, item.settings);
1237
1499
  case 'link-list':
1238
1500
  return resolveLinkListWidget(baseWidget, item.settings);
1239
1501
  case 'search':
1240
- return resolveSearchWidget(baseWidget, item.settings, widgetAreaId, index);
1502
+ return previewData.site.search.enabled === true
1503
+ ? resolveSearchWidget(baseWidget, item.settings, widgetAreaId, index)
1504
+ : null;
1241
1505
  case 'profile':
1242
1506
  return resolveProfileWidget(baseWidget, item.settings);
1243
1507
  default:
@@ -1248,11 +1512,11 @@ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
1248
1512
  function resolveRecentPostsWidget(baseWidget, settings, renderData) {
1249
1513
  const limit = clampInteger(settings?.limit, 5, 1, 20);
1250
1514
  const items = renderData.posts
1251
- .filter((post) => !isDelistedDocument(post))
1515
+ .filter((post) => post.status === 'published' && !isDelistedDocument(post))
1252
1516
  .slice(0, limit)
1253
1517
  .map((post) => ({
1254
1518
  title: post.title,
1255
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
1519
+ url: post.url,
1256
1520
  published_at: post.published_at,
1257
1521
  published_at_iso: post.published_at_iso,
1258
1522
  }));
@@ -1322,7 +1586,7 @@ function resolveArchivesWidget(baseWidget, settings, previewData) {
1322
1586
  .slice(0, limit)
1323
1587
  .map((entry) => ({
1324
1588
  label: entry.label,
1325
- url: routePathToPublicUrl('/archive/', previewData.site.permalinks.output_style),
1589
+ url: previewData.site.archive.url,
1326
1590
  count: entry.count,
1327
1591
  year: entry.year,
1328
1592
  month: entry.month,
@@ -1414,9 +1678,11 @@ function preparePage(page, site) {
1414
1678
  const documentType = normalizeDocumentType(page.document_type);
1415
1679
  const renderedDocument = renderDocument(page.content, documentType);
1416
1680
  const permalink = resolvePagePermalink(site, page);
1681
+ const pageFields = { ...page };
1682
+ delete pageFields.comments;
1417
1683
 
1418
1684
  return {
1419
- ...page,
1685
+ ...pageFields,
1420
1686
  url: permalink.url,
1421
1687
  document_type: documentType,
1422
1688
  html: renderedDocument.html,
@@ -1425,7 +1691,7 @@ function preparePage(page, site) {
1425
1691
  };
1426
1692
  }
1427
1693
 
1428
- function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
1694
+ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1429
1695
  const documentType = normalizeDocumentType(post.document_type);
1430
1696
  const renderedDocument = renderDocument(post.content, documentType);
1431
1697
  const author = authorsById.get(post.author_id);
@@ -1480,7 +1746,6 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1480
1746
  published_at: formatTimestamp(post.published_at_iso, site),
1481
1747
  updated_at: formatTimestamp(post.updated_at_iso, site),
1482
1748
  reading_time: calculateReadingTime(renderedDocument.html),
1483
- comments_enabled: themeSupportsComments && site.disallow_comments !== true && post.allow_comments === true,
1484
1749
  };
1485
1750
  }
1486
1751
 
@@ -1730,7 +1995,7 @@ function buildAdjacentPostSummary(post) {
1730
1995
  };
1731
1996
  }
1732
1997
 
1733
- function buildArchiveGroups(posts, postBySlug) {
1998
+ function buildArchiveGroups(posts, postBySlug, site) {
1734
1999
  const groups = new Map();
1735
2000
 
1736
2001
  for (const post of posts) {
@@ -1739,14 +2004,12 @@ function buildArchiveGroups(posts, postBySlug) {
1739
2004
  continue;
1740
2005
  }
1741
2006
 
1742
- const date = toDate(prepared.published_at_iso);
1743
- const year = date.getUTCFullYear();
1744
- const month = date.getUTCMonth() + 1;
1745
- const label = `${year}-${String(month).padStart(2, '0')}`;
2007
+ const parts = getZonedDateParts(prepared.published_at_iso, site);
2008
+ const label = `${parts.year}-${padDatePart(parts.month)}`;
1746
2009
  const current = groups.get(label) || {
1747
2010
  label,
1748
- year,
1749
- month,
2011
+ year: parts.year,
2012
+ month: parts.month,
1750
2013
  items: [],
1751
2014
  };
1752
2015
 
@@ -1754,7 +2017,8 @@ function buildArchiveGroups(posts, postBySlug) {
1754
2017
  groups.set(label, current);
1755
2018
  }
1756
2019
 
1757
- return Array.from(groups.values());
2020
+ return Array.from(groups.values())
2021
+ .sort((left, right) => right.year - left.year || right.month - left.month);
1758
2022
  }
1759
2023
 
1760
2024
  function buildTaxonomyRouteData(kind, item, countBySlug) {
@@ -1789,25 +2053,31 @@ function buildArchiveEntries(posts, site) {
1789
2053
  }
1790
2054
 
1791
2055
  const date = toDate(publishedAt);
1792
- const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
1793
- const current = entries.get(key) || { date, count: 0 };
2056
+ const parts = getZonedDateParts(date, site);
2057
+ const key = `${parts.year}-${padDatePart(parts.month)}`;
2058
+ const current = entries.get(key) || {
2059
+ date,
2060
+ year: parts.year,
2061
+ month: parts.month,
2062
+ count: 0,
2063
+ };
1794
2064
  current.count += 1;
1795
2065
  entries.set(key, current);
1796
2066
  }
1797
2067
 
1798
2068
  return Array.from(entries.values())
1799
- .sort((left, right) => right.date.getTime() - left.date.getTime())
2069
+ .sort((left, right) => right.year - left.year || right.month - left.month)
1800
2070
  .map((entry) => ({
1801
2071
  label: formatArchiveLabel(entry.date, site),
1802
2072
  count: entry.count,
1803
- year: entry.date.getUTCFullYear(),
1804
- month: entry.date.getUTCMonth() + 1,
2073
+ year: entry.year,
2074
+ month: entry.month,
1805
2075
  }));
1806
2076
  }
1807
2077
 
1808
2078
  function formatArchiveLabel(date, site) {
1809
2079
  return new Intl.DateTimeFormat(normalizeLocale(site.locale || DEFAULT_LOCALE), {
1810
- timeZone: normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE),
2080
+ timeZone: normalizeTimezone(site.timezone),
1811
2081
  year: 'numeric',
1812
2082
  month: 'long',
1813
2083
  }).format(date);
@@ -1818,7 +2088,7 @@ function formatTimestamp(value, site) {
1818
2088
  const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
1819
2089
  const dateStyle = DATETIME_STYLES.has(site.date_style) ? site.date_style : DEFAULT_DATE_STYLE;
1820
2090
  const timeStyle = DATETIME_STYLES.has(site.time_style) ? site.time_style : DEFAULT_TIME_STYLE;
1821
- const siteTimezone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
2091
+ const siteTimezone = normalizeTimezone(site.timezone);
1822
2092
 
1823
2093
  if (dateStyle === 'none' && timeStyle === 'none') {
1824
2094
  return '';
@@ -1858,32 +2128,43 @@ function normalizeOptionalString(value) {
1858
2128
  return typeof value === 'string' && value.trim() ? value.trim() : '';
1859
2129
  }
1860
2130
 
2131
+ function preserveOpaqueNonBlankString(value) {
2132
+ return typeof value === 'string' && value.trim() ? value : '';
2133
+ }
2134
+
1861
2135
  function normalizeOptionalRawString(value) {
1862
2136
  return typeof value === 'string' && value.trim() ? value : '';
1863
2137
  }
1864
2138
 
1865
2139
  function normalizeLocale(value) {
1866
- if (typeof value !== 'string' || !value.trim()) {
1867
- return DEFAULT_LOCALE;
1868
- }
1869
-
1870
- const parts = value.trim().replace(/_/g, '-').split('-').filter(Boolean);
1871
- if (parts.length === 0) {
1872
- return DEFAULT_LOCALE;
2140
+ const candidate = typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_LOCALE;
2141
+ try {
2142
+ return Intl.getCanonicalLocales(candidate)[0] || DEFAULT_LOCALE;
2143
+ } catch {
2144
+ throw new Error(`Invalid preview-data: site.locale "${candidate}" is not a valid BCP 47 language tag.`);
1873
2145
  }
2146
+ }
1874
2147
 
1875
- return parts.map((part, index) => {
1876
- if (index === 0) {
1877
- return part.toLowerCase();
2148
+ function normalizeTimezone(value) {
2149
+ const candidate = typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_TIMEZONE;
2150
+ const offsetMatch = /^([+-])(\d{2}):(\d{2})$/u.exec(candidate);
2151
+ if (offsetMatch) {
2152
+ const hours = Number(offsetMatch[2]);
2153
+ const minutes = Number(offsetMatch[3]);
2154
+ if (minutes > 59 || hours > 14 || (hours === 14 && minutes !== 0)) {
2155
+ throw new Error(`Invalid preview-data: site.timezone "${candidate}" is outside the supported fixed-offset range.`);
1878
2156
  }
1879
- if (part.length === 2 || part.length === 3) {
1880
- return part.toUpperCase();
2157
+ if (hours === 0 && minutes === 0) {
2158
+ return DEFAULT_TIMEZONE;
1881
2159
  }
1882
- if (part.length === 4) {
1883
- return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
1884
- }
1885
- return part.toLowerCase();
1886
- }).join('-');
2160
+ return `${offsetMatch[1]}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
2161
+ }
2162
+
2163
+ try {
2164
+ return new Intl.DateTimeFormat('en-US', { timeZone: candidate }).resolvedOptions().timeZone;
2165
+ } catch {
2166
+ throw new Error(`Invalid preview-data: site.timezone "${candidate}" is not UTC, an IANA time zone, or a valid fixed offset.`);
2167
+ }
1887
2168
  }
1888
2169
 
1889
2170
  function normalizeDocumentType(value) {
@@ -2077,11 +2358,12 @@ async function buildCustomCssAsset(customCss, assetProcessor, options) {
2077
2358
  };
2078
2359
  }
2079
2360
 
2080
- function createRenderContext(site, currentUrl) {
2361
+ function createRenderContext(site, currentUrl, comments = DISABLED_COMMENTS_CONTEXT) {
2081
2362
  return {
2082
2363
  site,
2083
2364
  currentUrl,
2084
2365
  language: site.locale,
2366
+ comments,
2085
2367
  };
2086
2368
  }
2087
2369
 
@@ -2097,23 +2379,26 @@ function buildRouteContext(type, url, options = {}) {
2097
2379
 
2098
2380
  function buildPageMeta(site, options = {}) {
2099
2381
  const resolvedTitle = normalizeNonEmptyString(options.title, site.title);
2100
- const resolvedDescription = normalizeOptionalString(options.description);
2101
- const canonicalUrl = resolveMetaCanonicalUrl(site, options.canonicalUrl || options.currentUrl);
2102
- const ogImage = resolveMetaImageUrl(options.image);
2103
- const ogType = normalizeNonEmptyString(options.ogType, 'website');
2104
- const publishedTime = normalizeOptionalString(options.publishedTime);
2105
- const modifiedTime = normalizeOptionalString(options.modifiedTime);
2382
+ const includeRichMetadata = options.includeRichMetadata !== false;
2383
+ const resolvedDescription = includeRichMetadata ? normalizeOptionalString(options.description) : '';
2384
+ const canonicalUrl = includeRichMetadata
2385
+ ? resolveMetaCanonicalUrl(site, options.canonicalUrl || options.currentUrl)
2386
+ : '';
2387
+ const ogImage = includeRichMetadata ? resolveMetaImageUrl(options.image) : '';
2388
+ const ogType = includeRichMetadata ? normalizeNonEmptyString(options.ogType, 'website') : '';
2389
+ const publishedTime = includeRichMetadata ? normalizeOptionalString(options.publishedTime) : '';
2390
+ const modifiedTime = includeRichMetadata ? normalizeOptionalString(options.modifiedTime) : '';
2106
2391
  const robotsNoindex = options.robotsNoindex === true;
2107
2392
 
2108
2393
  const meta = {
2109
2394
  title: escapeHtml(resolvedTitle),
2110
2395
  description: resolvedDescription ? escapeHtml(resolvedDescription) : '',
2111
2396
  canonical_url: canonicalUrl ? escapeHtml(canonicalUrl) : '',
2112
- og_title: escapeHtml(resolvedTitle),
2113
- og_description: resolvedDescription ? escapeHtml(resolvedDescription) : '',
2397
+ og_title: includeRichMetadata ? escapeHtml(resolvedTitle) : '',
2398
+ og_description: includeRichMetadata && resolvedDescription ? escapeHtml(resolvedDescription) : '',
2114
2399
  og_type: escapeHtml(ogType),
2115
- og_url: canonicalUrl ? escapeHtml(canonicalUrl) : '',
2116
- og_site_name: escapeHtml(site.title),
2400
+ og_url: includeRichMetadata && canonicalUrl ? escapeHtml(canonicalUrl) : '',
2401
+ og_site_name: includeRichMetadata ? escapeHtml(site.title) : '',
2117
2402
  og_image: ogImage ? escapeHtml(ogImage) : '',
2118
2403
  article_published_time: publishedTime ? escapeHtml(publishedTime) : '',
2119
2404
  article_modified_time: modifiedTime ? escapeHtml(modifiedTime) : '',
@@ -2122,7 +2407,7 @@ function buildPageMeta(site, options = {}) {
2122
2407
 
2123
2408
  return {
2124
2409
  ...meta,
2125
- head_tags: buildMetaHeadTags(meta),
2410
+ head_tags: buildMetaHeadTags(meta, site, options),
2126
2411
  };
2127
2412
  }
2128
2413
 
@@ -2138,7 +2423,7 @@ function buildFrontPageTitle(site) {
2138
2423
  return resolvedDescription ? `${resolvedSiteTitle} - ${resolvedDescription}` : resolvedSiteTitle;
2139
2424
  }
2140
2425
 
2141
- function buildMetaHeadTags(meta) {
2426
+ function buildMetaHeadTags(meta, site, options = {}) {
2142
2427
  const tags = [];
2143
2428
 
2144
2429
  if (meta.description) {
@@ -2150,24 +2435,29 @@ function buildMetaHeadTags(meta) {
2150
2435
  if (meta.canonical_url) {
2151
2436
  tags.push(`<link rel="canonical" href="${meta.canonical_url}">`);
2152
2437
  }
2153
-
2154
- tags.push(`<meta property="og:title" content="${meta.og_title}">`);
2155
- if (meta.og_description) {
2156
- tags.push(`<meta property="og:description" content="${meta.og_description}">`);
2157
- }
2158
- tags.push(`<meta property="og:type" content="${meta.og_type}">`);
2159
- if (meta.og_url) {
2160
- tags.push(`<meta property="og:url" content="${meta.og_url}">`);
2438
+ if (options.includeFeedLink !== false && site.feed?.enabled === true) {
2439
+ tags.push(`<link rel="alternate" type="application/rss+xml" title="${escapeHtml(site.title)} Feed" href="${escapeHtml(site.feed.url)}">`);
2161
2440
  }
2162
- tags.push(`<meta property="og:site_name" content="${meta.og_site_name}">`);
2163
- if (meta.og_image) {
2164
- tags.push(`<meta property="og:image" content="${meta.og_image}">`);
2165
- }
2166
- if (meta.article_published_time) {
2167
- tags.push(`<meta property="article:published_time" content="${meta.article_published_time}">`);
2168
- }
2169
- if (meta.article_modified_time) {
2170
- tags.push(`<meta property="article:modified_time" content="${meta.article_modified_time}">`);
2441
+
2442
+ if (meta.og_title) {
2443
+ tags.push(`<meta property="og:title" content="${meta.og_title}">`);
2444
+ if (meta.og_description) {
2445
+ tags.push(`<meta property="og:description" content="${meta.og_description}">`);
2446
+ }
2447
+ tags.push(`<meta property="og:type" content="${meta.og_type}">`);
2448
+ if (meta.og_url) {
2449
+ tags.push(`<meta property="og:url" content="${meta.og_url}">`);
2450
+ }
2451
+ tags.push(`<meta property="og:site_name" content="${meta.og_site_name}">`);
2452
+ if (meta.og_image) {
2453
+ tags.push(`<meta property="og:image" content="${meta.og_image}">`);
2454
+ }
2455
+ if (meta.article_published_time) {
2456
+ tags.push(`<meta property="article:published_time" content="${meta.article_published_time}">`);
2457
+ }
2458
+ if (meta.article_modified_time) {
2459
+ tags.push(`<meta property="article:modified_time" content="${meta.article_modified_time}">`);
2460
+ }
2171
2461
  }
2172
2462
 
2173
2463
  return tags.length ? `${tags.join('\n')}\n` : '';
@@ -2194,7 +2484,7 @@ function resolveMetaImageUrl(image) {
2194
2484
  return '';
2195
2485
  }
2196
2486
 
2197
- function normalizeMediaField(value, media_base_url) {
2487
+ function normalizeMediaField(value, media_origin) {
2198
2488
  if (value === undefined) {
2199
2489
  return undefined;
2200
2490
  }
@@ -2204,22 +2494,145 @@ function normalizeMediaField(value, media_base_url) {
2204
2494
  return '';
2205
2495
  }
2206
2496
 
2207
- if (isAbsoluteUrl(normalizedValue)) {
2208
- return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
2497
+ const normalizedMediaUrl = normalizeMediaUrl(normalizedValue);
2498
+ if (!normalizedMediaUrl) {
2499
+ return '';
2209
2500
  }
2210
2501
 
2211
- const normalizedBaseUrl = normalizeOptionalString(media_base_url);
2212
- if (!normalizedBaseUrl) {
2213
- return normalizedValue;
2502
+ if (isAbsoluteUrl(normalizedMediaUrl)) {
2503
+ return normalizedMediaUrl;
2504
+ }
2505
+
2506
+ const normalizedOrigin = normalizeMediaOrigin(media_origin);
2507
+ if (!normalizedOrigin) {
2508
+ return normalizedMediaUrl;
2214
2509
  }
2215
2510
 
2216
2511
  try {
2217
- return decodeURI(new URL(normalizedValue, normalizedBaseUrl).toString());
2512
+ return decodeURI(new URL(normalizedMediaUrl, `${normalizedOrigin}/`).toString());
2218
2513
  } catch {
2219
2514
  return '';
2220
2515
  }
2221
2516
  }
2222
2517
 
2518
+ function normalizeMediaOrigin(value) {
2519
+ const normalizedValue = normalizeOptionalString(value);
2520
+ if (!normalizedValue) {
2521
+ return '';
2522
+ }
2523
+
2524
+ try {
2525
+ const url = new URL(normalizedValue);
2526
+ if (
2527
+ !SAFE_MEDIA_PROTOCOLS.has(url.protocol)
2528
+ || url.username
2529
+ || url.password
2530
+ || url.search
2531
+ || url.hash
2532
+ || url.pathname !== '/'
2533
+ || !isSafeUrlText(normalizedValue)
2534
+ ) {
2535
+ return '';
2536
+ }
2537
+ return url.origin;
2538
+ } catch {
2539
+ return '';
2540
+ }
2541
+ }
2542
+
2543
+ function normalizeSiteOrigin(value) {
2544
+ const normalizedValue = normalizeOptionalString(value);
2545
+ if (!normalizedValue) {
2546
+ return '';
2547
+ }
2548
+
2549
+ try {
2550
+ const url = new URL(normalizedValue);
2551
+ if (
2552
+ !SAFE_LINK_PROTOCOLS.has(url.protocol)
2553
+ || url.username
2554
+ || url.password
2555
+ || url.search
2556
+ || url.hash
2557
+ || url.pathname !== '/'
2558
+ || !isSafeUrlText(normalizedValue)
2559
+ ) {
2560
+ throw new Error('site.url must be an HTTP(S) origin without credentials, path, query, or fragment');
2561
+ }
2562
+ return url.origin;
2563
+ } catch (error) {
2564
+ const reason = error instanceof Error ? error.message : String(error);
2565
+ throw new Error(`Invalid preview-data: site.url is not a safe HTTP(S) origin: ${reason}`);
2566
+ }
2567
+ }
2568
+
2569
+ function normalizeNavigationUrl(value) {
2570
+ return normalizeSafeResourceUrl(value, { allowRoot: true });
2571
+ }
2572
+
2573
+ function normalizeMediaUrl(value) {
2574
+ return normalizeSafeResourceUrl(value, { allowRoot: false });
2575
+ }
2576
+
2577
+ function normalizeSafeResourceUrl(value, options) {
2578
+ const normalizedValue = normalizeOptionalString(value);
2579
+ if (!normalizedValue || !isSafeUrlText(normalizedValue)) {
2580
+ return '';
2581
+ }
2582
+
2583
+ if (normalizedValue.startsWith('/')) {
2584
+ if (normalizedValue.startsWith('//')) {
2585
+ return '';
2586
+ }
2587
+ const pathname = normalizedValue.split(/[?#]/u, 1)[0];
2588
+ if ((!options.allowRoot && pathname === '/') || hasDotPathSegment(pathname)) {
2589
+ return '';
2590
+ }
2591
+ return normalizedValue;
2592
+ }
2593
+
2594
+ if (!isAbsoluteUrl(normalizedValue)) {
2595
+ return '';
2596
+ }
2597
+
2598
+ const rawPath = extractAbsoluteUrlPath(normalizedValue);
2599
+ if ((!options.allowRoot && (!rawPath || rawPath === '/')) || hasDotPathSegment(rawPath || '/')) {
2600
+ return '';
2601
+ }
2602
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_LINK_PROTOCOLS);
2603
+ }
2604
+
2605
+ function isSafeUrlText(value) {
2606
+ if (/[\s\\\u0000-\u001F\u007F]/u.test(value)) {
2607
+ return false;
2608
+ }
2609
+ try {
2610
+ decodeURI(value);
2611
+ return true;
2612
+ } catch {
2613
+ return false;
2614
+ }
2615
+ }
2616
+
2617
+ function extractAbsoluteUrlPath(value) {
2618
+ const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]*(?<path>[^?#]*)/u.exec(value);
2619
+ return match?.groups?.path || '/';
2620
+ }
2621
+
2622
+ function hasDotPathSegment(pathname) {
2623
+ return String(pathname || '').split('/').some((segment) => {
2624
+ if (!segment) {
2625
+ return false;
2626
+ }
2627
+ try {
2628
+ const decoded = decodeURIComponent(segment).normalize('NFC');
2629
+ return decoded === '.' || decoded === '..';
2630
+ } catch {
2631
+ return true;
2632
+ }
2633
+ });
2634
+ }
2635
+
2223
2636
  function isAbsoluteUrl(value) {
2224
2637
  try {
2225
2638
  new URL(value);
@@ -2232,7 +2645,7 @@ function isAbsoluteUrl(value) {
2232
2645
  function normalizeAbsoluteUrl(value, allowedProtocols) {
2233
2646
  try {
2234
2647
  const url = new URL(value);
2235
- if (!allowedProtocols.has(url.protocol)) {
2648
+ if (!allowedProtocols.has(url.protocol) || url.username || url.password || !isSafeUrlText(value)) {
2236
2649
  return '';
2237
2650
  }
2238
2651
  return decodeURI(url.toString());
@@ -2242,16 +2655,7 @@ function normalizeAbsoluteUrl(value, allowedProtocols) {
2242
2655
  }
2243
2656
 
2244
2657
  function normalizeThemeLinkUrl(value) {
2245
- const normalizedValue = normalizeOptionalString(value);
2246
- if (!normalizedValue || normalizedValue.startsWith('//')) {
2247
- return '';
2248
- }
2249
-
2250
- if (isAbsoluteUrl(normalizedValue)) {
2251
- return normalizeAbsoluteUrl(normalizedValue, SAFE_LINK_PROTOCOLS);
2252
- }
2253
-
2254
- return normalizedValue;
2658
+ return normalizeNavigationUrl(value);
2255
2659
  }
2256
2660
 
2257
2661
  async function writeOutput(writer, summaries, path, content, contentType) {
@@ -2320,6 +2724,31 @@ function resolvePagePermalink(site, page) {
2320
2724
  return resolvePermalink(site, 'pages', page);
2321
2725
  }
2322
2726
 
2727
+ function resolveEffectivePageReferencePath(site, page) {
2728
+ return normalizePageReferencePath(resolvePagePermalink(site, page).path);
2729
+ }
2730
+
2731
+ function normalizePageReferencePath(value) {
2732
+ const normalized = decodeRoutePath(normalizeOptionalString(value))
2733
+ .replace(/^\/+|\/+$/gu, '')
2734
+ .normalize('NFC');
2735
+ return normalized;
2736
+ }
2737
+
2738
+ function assertUniquePageReferencePaths(pageReferencePaths) {
2739
+ const seen = new Map();
2740
+ for (const [page, pagePath] of pageReferencePaths.entries()) {
2741
+ if (!pagePath) {
2742
+ throw new Error(`Invalid preview-data: Page "${page?.slug || ''}" resolves to an empty effective path.`);
2743
+ }
2744
+ const existing = seen.get(pagePath);
2745
+ if (existing) {
2746
+ throw new Error(`Invalid preview-data: Pages "${existing.slug}" and "${page.slug}" resolve to the same effective path "${pagePath}".`);
2747
+ }
2748
+ seen.set(pagePath, page);
2749
+ }
2750
+ }
2751
+
2323
2752
  function resolvePermalink(site, kind, item) {
2324
2753
  const pattern = normalizeNonEmptyString(site.permalinks?.[kind], DEFAULT_PERMALINKS[kind]);
2325
2754
  return buildRouteInfo(applyPermalinkPattern(pattern, kind, item, site), site.permalinks.output_style);
@@ -2358,19 +2787,19 @@ function buildPermalinkTokenValues(kind, item, site) {
2358
2787
  };
2359
2788
 
2360
2789
  if (kind === 'posts') {
2361
- const parts = getPermalinkDateParts(item.published_at_iso, site);
2790
+ const parts = getZonedDateParts(item.published_at_iso, site);
2362
2791
  values.public_id = String(item.public_id);
2363
- values.year = parts.year;
2364
- values.month = parts.month;
2365
- values.day = parts.day;
2792
+ values.year = String(parts.year);
2793
+ values.month = padDatePart(parts.month);
2794
+ values.day = padDatePart(parts.day);
2366
2795
  }
2367
2796
 
2368
2797
  return values;
2369
2798
  }
2370
2799
 
2371
- function getPermalinkDateParts(value, site) {
2800
+ function getZonedDateParts(value, site) {
2372
2801
  const date = toDate(value);
2373
- const timeZone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
2802
+ const timeZone = normalizeTimezone(site.timezone);
2374
2803
  const parts = new Intl.DateTimeFormat('en-US', {
2375
2804
  timeZone,
2376
2805
  year: 'numeric',
@@ -2379,12 +2808,16 @@ function getPermalinkDateParts(value, site) {
2379
2808
  }).formatToParts(date);
2380
2809
 
2381
2810
  return {
2382
- year: parts.find((part) => part.type === 'year')?.value || String(date.getUTCFullYear()),
2383
- month: parts.find((part) => part.type === 'month')?.value || String(date.getUTCMonth() + 1).padStart(2, '0'),
2384
- day: parts.find((part) => part.type === 'day')?.value || String(date.getUTCDate()).padStart(2, '0'),
2811
+ year: Number(parts.find((part) => part.type === 'year')?.value) || date.getUTCFullYear(),
2812
+ month: Number(parts.find((part) => part.type === 'month')?.value) || date.getUTCMonth() + 1,
2813
+ day: Number(parts.find((part) => part.type === 'day')?.value) || date.getUTCDate(),
2385
2814
  };
2386
2815
  }
2387
2816
 
2817
+ function padDatePart(value) {
2818
+ return String(value).padStart(2, '0');
2819
+ }
2820
+
2388
2821
  function normalizeOutputPath(filePath) {
2389
2822
  return String(filePath || '').replace(/^\/+/, '');
2390
2823
  }
@@ -2437,29 +2870,31 @@ function assertPlannedOutputPathsSafe(state) {
2437
2870
  outputPath: pageToOutputPath(page, outputStyle),
2438
2871
  })),
2439
2872
  ];
2440
- assertUniqueRoutes(routeEntries);
2441
-
2442
- const plannedPaths = [
2443
- ...routeEntries.map((entry) => entry.outputPath),
2873
+ const nonRoutePaths = [
2444
2874
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
2445
2875
  ];
2446
2876
 
2447
- if (shouldGenerateSearchArtifacts(state, state.options)) {
2448
- plannedPaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH);
2877
+ if (shouldGenerateSearchArtifacts(state)) {
2878
+ nonRoutePaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH);
2449
2879
  }
2450
2880
 
2451
- if (state.options.generateSpecialFiles) {
2452
- plannedPaths.push('404.html');
2453
- if (shouldGenerateRobotsTxt(state.options)) {
2454
- plannedPaths.push('robots.txt');
2455
- }
2456
- if (hasCanonicalSiteUrl(state.previewData.site.url)) {
2457
- plannedPaths.push('sitemap.xml');
2458
- if (shouldGenerateFeed(state.options)) {
2459
- plannedPaths.push('feed.xml');
2460
- }
2881
+ if (hasTemplate(state, '404')) {
2882
+ nonRoutePaths.push('404.html');
2883
+ }
2884
+ if (shouldGenerateRobotsTxt(state.options)) {
2885
+ nonRoutePaths.push('robots.txt');
2886
+ }
2887
+ if (hasCanonicalSiteUrl(state.previewData.site.url)) {
2888
+ nonRoutePaths.push('sitemap.xml');
2889
+ if (shouldGenerateFeed(state)) {
2890
+ nonRoutePaths.push('feed.xml');
2461
2891
  }
2462
2892
  }
2893
+ nonRoutePaths.push(...normalizeReservedOutputPaths(state.options.reservedOutputPaths));
2894
+ const plannedPaths = [
2895
+ ...routeEntries.map((entry) => entry.outputPath),
2896
+ ...nonRoutePaths,
2897
+ ];
2463
2898
 
2464
2899
  for (const plannedPath of plannedPaths) {
2465
2900
  const rawPath = String(plannedPath || '');
@@ -2467,29 +2902,92 @@ function assertPlannedOutputPathsSafe(state) {
2467
2902
  assertSafeRelativeOutputPath(rawPath, normalizedPath);
2468
2903
  }
2469
2904
 
2905
+ const publicUrlClaims = [
2906
+ ...routeEntries.flatMap((entry, index) => {
2907
+ const owner = `route:${index}`;
2908
+ return [entry.url, ...buildOutputPublicUrlAliases(entry.outputPath)]
2909
+ .map((url) => ({ url, owner }));
2910
+ }),
2911
+ ...nonRoutePaths.flatMap((outputPath, index) => {
2912
+ const owner = `file:${index}`;
2913
+ return buildOutputPublicUrlAliases(outputPath)
2914
+ .map((url) => ({ url, owner }));
2915
+ }),
2916
+ ];
2917
+ assertUniquePublicUrlClaims(publicUrlClaims);
2470
2918
  assertUniqueOutputPaths(plannedPaths);
2471
2919
  }
2472
2920
 
2473
- function assertUniqueRoutes(routeEntries) {
2921
+ function normalizeReservedOutputPaths(value) {
2922
+ if (value === undefined) {
2923
+ return [];
2924
+ }
2925
+ if (!Array.isArray(value)) {
2926
+ throw new Error('reservedOutputPaths must be an array of relative output file paths');
2927
+ }
2928
+ return value.map((outputPath) => {
2929
+ if (typeof outputPath !== 'string') {
2930
+ throw new Error('reservedOutputPaths must contain only relative output file paths');
2931
+ }
2932
+ return outputPath;
2933
+ });
2934
+ }
2935
+
2936
+ function buildOutputPublicUrlAliases(outputPath) {
2937
+ const normalizedPath = normalizeOutputPath(outputPath);
2938
+ const aliases = new Set([`/${normalizedPath}`]);
2939
+
2940
+ if (normalizedPath.endsWith('.html')) {
2941
+ aliases.add(`/${normalizedPath.slice(0, -'.html'.length)}`);
2942
+ }
2943
+ if (normalizedPath === 'index.html') {
2944
+ aliases.add('/');
2945
+ } else if (normalizedPath.endsWith('/index.html')) {
2946
+ aliases.add(`/${normalizedPath.slice(0, -'index.html'.length)}`);
2947
+ }
2948
+
2949
+ return [...aliases];
2950
+ }
2951
+
2952
+ function assertUniquePublicUrlClaims(claims) {
2474
2953
  const seenUrls = new Map();
2475
- for (const entry of routeEntries) {
2476
- const normalizedUrl = normalizeRouteCollisionKey(entry.url);
2477
- if (seenUrls.has(normalizedUrl)) {
2478
- throw new Error(`Duplicate public URL detected: ${entry.url}`);
2954
+ for (const claim of claims) {
2955
+ const normalizedUrl = normalizeRouteCollisionKey(claim.url);
2956
+ const existingOwner = seenUrls.get(normalizedUrl);
2957
+ if (existingOwner !== undefined && existingOwner !== claim.owner) {
2958
+ throw new Error(`Duplicate public URL detected: ${claim.url}`);
2479
2959
  }
2480
- seenUrls.set(normalizedUrl, entry);
2960
+ seenUrls.set(normalizedUrl, claim.owner);
2481
2961
  }
2482
2962
  }
2483
2963
 
2484
2964
  function assertUniqueOutputPaths(plannedPaths) {
2965
+ const normalizedPaths = plannedPaths.map((plannedPath) => normalizeOutputPath(plannedPath));
2485
2966
  const seenPaths = new Set();
2486
- for (const plannedPath of plannedPaths) {
2487
- const normalizedPath = normalizeOutputPath(plannedPath);
2967
+ for (const [index, normalizedPath] of normalizedPaths.entries()) {
2488
2968
  if (seenPaths.has(normalizedPath)) {
2489
- throw new Error(`Duplicate output path detected: ${plannedPath}`);
2969
+ throw new Error(`Duplicate output path detected: ${plannedPaths[index]}`);
2490
2970
  }
2491
2971
  seenPaths.add(normalizedPath);
2492
2972
  }
2973
+
2974
+ const shallowestFirst = [...seenPaths]
2975
+ .map((outputPath) => ({ outputPath, segments: outputPath.split('/') }))
2976
+ .sort((left, right) => (
2977
+ left.segments.length - right.segments.length
2978
+ || left.outputPath.localeCompare(right.outputPath)
2979
+ ));
2980
+ const processedPaths = new Set();
2981
+ for (const { outputPath, segments } of shallowestFirst) {
2982
+ let ancestorPath = '';
2983
+ for (const segment of segments.slice(0, -1)) {
2984
+ ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
2985
+ if (processedPaths.has(ancestorPath)) {
2986
+ throw new Error(`Conflicting output path hierarchy detected: ${ancestorPath} and ${outputPath}`);
2987
+ }
2988
+ }
2989
+ processedPaths.add(outputPath);
2990
+ }
2493
2991
  }
2494
2992
 
2495
2993
  function normalizeRouteCollisionKey(url) {
@@ -2532,6 +3030,7 @@ function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputP
2532
3030
  if (
2533
3031
  originalPath.startsWith('/') ||
2534
3032
  originalPath.startsWith('\\') ||
3033
+ originalPath.includes('\\') ||
2535
3034
  /^[A-Za-z]:[\\/]/.test(originalPath) ||
2536
3035
  normalizedSeparators.startsWith('//')
2537
3036
  ) {
@@ -2554,11 +3053,11 @@ function sha256(content) {
2554
3053
  return hash.digest('hex');
2555
3054
  }
2556
3055
 
2557
- function injectSiteCustomizations(html, state) {
3056
+ function injectSiteCustomizations(html, state, target) {
2558
3057
  let next = injectFaviconLinks(html, state.favicon);
2559
3058
  next = injectGeneratorMeta(next, state.exposeGenerator);
2560
3059
  next = injectCustomCssAssetLink(next, state.customCssHref);
2561
- next = injectCustomHtml(next, state.customHtml);
3060
+ next = injectCustomHtml(next, state.customHtml, target);
2562
3061
  return next;
2563
3062
  }
2564
3063
 
@@ -2568,7 +3067,7 @@ function injectFaviconLinks(html, favicon) {
2568
3067
  return html;
2569
3068
  }
2570
3069
 
2571
- return html.replace('</head>', `${links}\n</head>`);
3070
+ return insertBeforeClosingTag(html, HEAD_CLOSING_TAG_PATTERN, `${links}\n`);
2572
3071
  }
2573
3072
 
2574
3073
  function buildFaviconLinks(favicon) {
@@ -2577,14 +3076,36 @@ function buildFaviconLinks(favicon) {
2577
3076
  }
2578
3077
 
2579
3078
  const lines = [];
2580
- if (normalizeOptionalString(favicon.icon)) {
2581
- lines.push(` <link rel="icon" href="${escapeHtml(favicon.icon)}" sizes="any">`);
2582
- }
2583
- if (normalizeOptionalString(favicon.svg)) {
2584
- lines.push(` <link rel="icon" href="${escapeHtml(favicon.svg)}" type="image/svg+xml">`);
2585
- }
2586
- if (normalizeOptionalString(favicon.png)) {
2587
- lines.push(` <link rel="icon" href="${escapeHtml(favicon.png)}" type="image/png">`);
3079
+ const icon = normalizeOptionalString(favicon.icon);
3080
+ const iconDark = normalizeOptionalString(favicon.icon_dark);
3081
+ const svg = normalizeOptionalString(favicon.svg);
3082
+ const png = normalizeOptionalString(favicon.png);
3083
+ const hasDefaultIcon = Boolean(icon || svg || png);
3084
+ const hasBothColorSchemes = Boolean(iconDark && hasDefaultIcon);
3085
+
3086
+ if (iconDark) {
3087
+ lines.push(buildFaviconLink(
3088
+ iconDark,
3089
+ undefined,
3090
+ hasBothColorSchemes ? '(prefers-color-scheme: dark)' : undefined,
3091
+ ));
3092
+ }
3093
+ if (icon) {
3094
+ lines.push(buildFaviconLink(icon, undefined, hasBothColorSchemes ? '(prefers-color-scheme: light)' : undefined));
3095
+ }
3096
+ if (svg) {
3097
+ lines.push(buildFaviconLink(
3098
+ svg,
3099
+ 'image/svg+xml',
3100
+ hasBothColorSchemes ? '(prefers-color-scheme: light)' : undefined,
3101
+ ));
3102
+ }
3103
+ if (png) {
3104
+ lines.push(buildFaviconLink(
3105
+ png,
3106
+ 'image/png',
3107
+ hasBothColorSchemes ? '(prefers-color-scheme: light)' : undefined,
3108
+ ));
2588
3109
  }
2589
3110
  if (normalizeOptionalString(favicon.apple_touch_icon)) {
2590
3111
  lines.push(` <link rel="apple-touch-icon" href="${escapeHtml(favicon.apple_touch_icon)}">`);
@@ -2593,12 +3114,20 @@ function buildFaviconLinks(favicon) {
2593
3114
  return lines.join('\n');
2594
3115
  }
2595
3116
 
3117
+ function buildFaviconLink(href, type, media) {
3118
+ return ` <link rel="icon" href="${escapeHtml(href)}"${type ? ` type="${type}"` : ''}${media ? ` media="${media}"` : ''}>`;
3119
+ }
3120
+
2596
3121
  function injectGeneratorMeta(html, exposeGenerator) {
2597
3122
  if (exposeGenerator === false) {
2598
3123
  return html;
2599
3124
  }
2600
3125
 
2601
- return html.replace('</head>', ' <meta name="generator" content="ZeroPress">\n</head>');
3126
+ return insertBeforeClosingTag(
3127
+ html,
3128
+ HEAD_CLOSING_TAG_PATTERN,
3129
+ ' <meta name="generator" content="ZeroPress">\n',
3130
+ );
2602
3131
  }
2603
3132
 
2604
3133
  function injectCustomCssAssetLink(html, href) {
@@ -2606,24 +3135,66 @@ function injectCustomCssAssetLink(html, href) {
2606
3135
  return html;
2607
3136
  }
2608
3137
 
2609
- return html.replace('</head>', ` <link rel="stylesheet" href="${escapeHtml(href)}">\n</head>`);
3138
+ return insertBeforeClosingTag(
3139
+ html,
3140
+ HEAD_CLOSING_TAG_PATTERN,
3141
+ ` <link rel="stylesheet" href="${escapeHtml(href)}">\n`,
3142
+ );
2610
3143
  }
2611
3144
 
2612
- function injectCustomHtml(html, customHtml) {
3145
+ function injectCustomHtml(html, customHtml, target) {
2613
3146
  let next = html;
2614
- const headEnd = normalizeOptionalRawString(customHtml?.head_end?.content);
2615
- const bodyEnd = normalizeOptionalRawString(customHtml?.body_end?.content);
3147
+ const headEnd = normalizeCustomHtmlSlot(customHtml?.head_end);
3148
+ const bodyEnd = normalizeCustomHtmlSlot(customHtml?.body_end);
2616
3149
 
2617
3150
  if (headEnd) {
2618
- next = next.replace('</head>', `${headEnd}\n</head>`);
3151
+ next = injectRequiredCustomHtmlSlot(
3152
+ next,
3153
+ 'head_end',
3154
+ headEnd,
3155
+ 'head',
3156
+ HEAD_CLOSING_TAG_PATTERN,
3157
+ target,
3158
+ );
2619
3159
  }
2620
3160
  if (bodyEnd) {
2621
- next = next.replace('</body>', `${bodyEnd}\n</body>`);
3161
+ next = injectRequiredCustomHtmlSlot(
3162
+ next,
3163
+ 'body_end',
3164
+ bodyEnd,
3165
+ 'body',
3166
+ BODY_CLOSING_TAG_PATTERN,
3167
+ target,
3168
+ );
2622
3169
  }
2623
3170
 
2624
3171
  return next;
2625
3172
  }
2626
3173
 
3174
+ function injectRequiredCustomHtmlSlot(html, slot, content, tagName, closingTagPattern, target) {
3175
+ let injected = false;
3176
+ const next = String(html).replace(closingTagPattern, (closingTag) => {
3177
+ injected = true;
3178
+ return `${content}\n${closingTag}`;
3179
+ });
3180
+
3181
+ if (injected) {
3182
+ return next;
3183
+ }
3184
+
3185
+ const route = normalizeOptionalString(target?.route) || '<unknown>';
3186
+ const outputPath = normalizeOptionalString(target?.outputPath) || '<unknown>';
3187
+ throw new Error(
3188
+ `Unable to inject custom_html.${slot} for route "${route}" into output "${outputPath}": `
3189
+ + `rendered theme HTML is missing a closing </${tagName}> tag. `
3190
+ + `Add </${tagName}> to the rendered theme layout or remove custom_html.${slot}.`,
3191
+ );
3192
+ }
3193
+
3194
+ function insertBeforeClosingTag(html, closingTagPattern, content) {
3195
+ return String(html).replace(closingTagPattern, (closingTag) => `${content}${closingTag}`);
3196
+ }
3197
+
2627
3198
  function buildSearchIndexJson(state) {
2628
3199
  return `${JSON.stringify(buildSearchIndexItems(state), null, 2)}\n`;
2629
3200
  }
@@ -2649,18 +3220,26 @@ function buildSearchIndexItems(state) {
2649
3220
  ? state.renderData.frontPageRoute.page
2650
3221
  : null;
2651
3222
  const frontPageItems = frontPagePage && frontPagePage.status === 'published' && !isDelistedDocument(frontPagePage)
2652
- ? [buildSearchPageItem(frontPagePage, '/')]
3223
+ ? [buildSearchPageItem(
3224
+ frontPagePage,
3225
+ '/',
3226
+ state.renderData.pageReferencePathByPage.get(frontPagePage),
3227
+ )]
2653
3228
  : [];
2654
3229
  const pageItems = state.renderData.pages
2655
3230
  .filter((page) => page.status === 'published' && !isDelistedDocument(page))
2656
- .map((page) => buildSearchPageItem(page, page.url));
3231
+ .map((page) => buildSearchPageItem(
3232
+ page,
3233
+ page.url,
3234
+ state.renderData.pageReferencePathByPage.get(page),
3235
+ ));
2657
3236
 
2658
3237
  return [...posts, ...frontPageItems, ...pageItems];
2659
3238
  }
2660
3239
 
2661
- function buildSearchPageItem(page, url) {
3240
+ function buildSearchPageItem(page, url, pageReferencePath) {
2662
3241
  return {
2663
- id: `page:${page.slug}`,
3242
+ id: `page:${pageReferencePath}`,
2664
3243
  type: 'page',
2665
3244
  title: page.title,
2666
3245
  url,
@@ -3170,7 +3749,7 @@ function buildFeedXml(site, emitted, generatedAt) {
3170
3749
 
3171
3750
  function buildRobotsTxt(site) {
3172
3751
  const lines = ['User-agent: *'];
3173
- if (site.indexing === false) {
3752
+ if (site.robots?.allow_indexing === false) {
3174
3753
  lines.push('Disallow: /');
3175
3754
  return `${lines.join('\n')}\n`;
3176
3755
  }
@@ -3183,15 +3762,15 @@ function buildRobotsTxt(site) {
3183
3762
  }
3184
3763
 
3185
3764
  function shouldGenerateRobotsTxt(options) {
3186
- return options.generateSpecialFiles && options.generateRobotsTxt !== false;
3765
+ return options.generateRobotsTxt !== false;
3187
3766
  }
3188
3767
 
3189
- function shouldGenerateFeed(options) {
3190
- return options.generateSpecialFiles && options.generateFeed !== false;
3768
+ function shouldGenerateFeed(state) {
3769
+ return state.previewData.site.feed.enabled === true;
3191
3770
  }
3192
3771
 
3193
- function shouldGenerateSearchArtifacts(state, options) {
3194
- return options.generateSpecialFiles && state.previewData.site.search === true;
3772
+ function shouldGenerateSearchArtifacts(state) {
3773
+ return state.previewData.site.search.enabled === true;
3195
3774
  }
3196
3775
 
3197
3776
  function getContentType(assetPath) {
@@ -3240,7 +3819,14 @@ function resolveSiteUrl(siteUrl, relativePath) {
3240
3819
  }
3241
3820
 
3242
3821
  function hasCanonicalSiteUrl(siteUrl) {
3243
- return typeof siteUrl === 'string' && siteUrl.trim() !== '';
3822
+ if (typeof siteUrl !== 'string' || !siteUrl.trim()) {
3823
+ return false;
3824
+ }
3825
+ try {
3826
+ return normalizeSiteOrigin(siteUrl) === siteUrl;
3827
+ } catch {
3828
+ return false;
3829
+ }
3244
3830
  }
3245
3831
 
3246
3832
  function toDate(value) {