@zeropress/build-core 0.6.13 → 0.7.1

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.
@@ -4,24 +4,29 @@ import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
4
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
5
5
  import { AssetProcessor } from '../assets/asset-processor.js';
6
6
  import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
7
+ import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
7
8
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
8
9
 
9
10
  const DEFAULT_OPTIONS = {
10
11
  assetHashing: true,
11
- generateSpecialFiles: true,
12
12
  generateFeed: true,
13
13
  generateRobotsTxt: true,
14
- writeManifest: false,
15
14
  };
16
15
 
17
16
  const DEFAULT_POSTS_PER_PAGE = 10;
18
- const DEFAULT_DATETIME_DISPLAY = 'static';
19
17
  const DEFAULT_DATE_STYLE = 'medium';
20
18
  const DEFAULT_TIME_STYLE = 'none';
21
19
  const DEFAULT_TIMEZONE = 'UTC';
22
20
  const DEFAULT_LOCALE = 'en-US';
23
- const DATETIME_DISPLAY_MODES = new Set(['static', 'client']);
21
+ const DEFAULT_COMMENTS_PROVIDER = 'zeropress';
22
+ const DEFAULT_COMMENTS_PER_PAGE = 50;
23
+ const DEFAULT_COMMENTS_ORDER = 'desc';
24
+ const DEFAULT_COMMENTS_THREADING_ENABLED = true;
25
+ const DEFAULT_COMMENTS_THREADING_MAX_DEPTH = 2;
24
26
  const DATETIME_STYLES = new Set(['none', 'short', 'medium', 'long', 'full']);
27
+ const COMMENTS_PROVIDERS = new Set(['zeropress', 'wordpress']);
28
+ const COMMENTS_ORDERS = new Set(['asc', 'desc']);
29
+ const DISABLED_COMMENTS_CONTEXT = Object.freeze({ enabled: false });
25
30
  const DEFAULT_PERMALINKS = Object.freeze({
26
31
  output_style: 'directory',
27
32
  posts: '/posts/:slug/',
@@ -43,9 +48,12 @@ const SEARCH_ADAPTER_OUTPUT_PATH = '_zeropress/search.js';
43
48
  const SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH = '_zeropress/search_pagefind.js';
44
49
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
45
50
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
46
- const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
51
+ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:']);
47
52
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
48
53
  const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
54
+ const CUSTOM_HTML_SLOT_MAX_CODE_POINTS = 65_536;
55
+ const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
56
+ const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
49
57
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
50
58
  const RESPONSIVE_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif']);
51
59
  const SEARCH_FIELD_WEIGHTS = Object.freeze({
@@ -57,6 +65,7 @@ const SEARCH_FIELD_WEIGHTS = Object.freeze({
57
65
  content_text: 1,
58
66
  });
59
67
  const SEARCH_RECENCY_BOOST_MAX = 0.15;
68
+ const RFC3339_TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:)(60|[0-5]\d)(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
60
69
  export async function buildSite(input) {
61
70
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
62
71
  const state = await createBuildState(input, options);
@@ -100,32 +109,32 @@ export async function buildSite(input) {
100
109
  await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
101
110
  }
102
111
 
103
- if (shouldGenerateSearchArtifacts(state, options)) {
112
+ if (shouldGenerateSearchArtifacts(state)) {
104
113
  await writeOutput(state.writer, state.summaries, SEARCH_INDEX_OUTPUT_PATH, buildSearchIndexJson(state), 'application/json');
105
- await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(), 'application/javascript');
114
+ await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(state.previewData.site.locale), 'application/javascript');
106
115
  await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
107
116
  }
108
117
 
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');
118
+ await maybeRenderNotFoundPage(state);
119
+ if (hasCanonicalSiteUrl(state.previewData.site.url)) {
120
+ await writeOutput(
121
+ state.writer,
122
+ state.summaries,
123
+ 'sitemap.xml',
124
+ buildSitemapXml(state.previewData.site, state.emitted, options.sitemapStylesheetHref),
125
+ 'application/xml',
126
+ );
127
+ if (shouldGenerateFeed(state)) {
128
+ await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.feedGeneratedAt), 'application/rss+xml');
125
129
  }
126
130
  }
131
+ if (shouldGenerateRobotsTxt(options)) {
132
+ await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
133
+ }
127
134
 
128
- return finalizeBuildResult(state.writer, state.summaries, options);
135
+ return {
136
+ files: state.summaries,
137
+ };
129
138
  }
130
139
 
131
140
  async function createBuildState(input, options) {
@@ -140,12 +149,12 @@ async function createBuildState(input, options) {
140
149
  const assetProcessor = new AssetProcessor();
141
150
  const summaries = [];
142
151
  const previewData = normalizePreviewData(input.previewData, options);
143
- const renderData = createRenderData(previewData, themePackage.metadata);
152
+ const renderData = createRenderData(previewData, themePackage, options);
144
153
 
145
154
  engine.initialize(themePackage);
146
155
 
147
- const assetOutputs = await buildAssetOutputs(themePackage.assets, assetProcessor, options);
148
- const customCssAsset = await buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
156
+ const assetOutputs = buildAssetOutputs(themePackage.assets, assetProcessor, options);
157
+ const customCssAsset = buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
149
158
  if (customCssAsset) {
150
159
  assetOutputs.push(customCssAsset);
151
160
  }
@@ -168,7 +177,7 @@ async function createBuildState(input, options) {
168
177
  favicon: previewData.site.favicon,
169
178
  exposeGenerator: previewData.site.expose_generator !== false,
170
179
  options,
171
- generatedAt: new Date(),
180
+ feedGeneratedAt: toDate(previewData.generated_at),
172
181
  emitted: {
173
182
  frontPage: null,
174
183
  indexRoutes: [],
@@ -193,30 +202,9 @@ function assertBuildPreviewData(previewData) {
193
202
  }
194
203
  }
195
204
 
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
205
  async function renderRoute(state, templateName, route) {
219
206
  const currentUrl = routePathToPublicUrl(route.path, state.previewData.site.permalinks.output_style);
207
+ const outputPath = routePathToOutputPath(route.path, state.previewData.site.permalinks.output_style);
220
208
  const routeContext = buildRouteContext(route.route_type || templateName, currentUrl, {
221
209
  isFrontPage: route.is_front_page === true,
222
210
  isPostIndex: route.is_post_index === true,
@@ -242,8 +230,11 @@ async function renderRoute(state, templateName, route) {
242
230
  createRenderContext(state.previewData.site, currentUrl),
243
231
  );
244
232
  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');
233
+ html = injectSiteCustomizations(html, state, {
234
+ route: currentUrl,
235
+ outputPath,
236
+ });
237
+ await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
247
238
  recordRouteEmission(state, templateName, route, currentUrl);
248
239
  }
249
240
 
@@ -288,10 +279,17 @@ async function renderFrontPage(state, route) {
288
279
  robotsNoindex: shouldNoindexDocument(page),
289
280
  }),
290
281
  },
291
- createRenderContext(state.previewData.site, currentUrl),
282
+ createRenderContext(
283
+ state.previewData.site,
284
+ currentUrl,
285
+ getTargetCommentsContext(state, 'page', state.renderData.pageReferencePathByPage.get(route.page)),
286
+ ),
292
287
  );
293
288
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
294
- html = injectSiteCustomizations(html, state);
289
+ html = injectSiteCustomizations(html, state, {
290
+ route: currentUrl,
291
+ outputPath: 'index.html',
292
+ });
295
293
  await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
296
294
  state.emitted.frontPage = {
297
295
  url: currentUrl,
@@ -323,7 +321,10 @@ async function renderFrontPage(state, route) {
323
321
  createRenderContext(state.previewData.site, currentUrl),
324
322
  );
325
323
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
326
- html = injectSiteCustomizations(html, state);
324
+ html = injectSiteCustomizations(html, state, {
325
+ route: currentUrl,
326
+ outputPath: 'index.html',
327
+ });
327
328
  await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
328
329
  state.emitted.frontPage = {
329
330
  url: currentUrl,
@@ -335,6 +336,7 @@ async function renderFrontPage(state, route) {
335
336
 
336
337
  async function renderPost(state, post) {
337
338
  const currentUrl = post.url;
339
+ const outputPath = routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style);
338
340
  let html = await state.engine.render(
339
341
  'post',
340
342
  {
@@ -355,11 +357,18 @@ async function renderPost(state, post) {
355
357
  robotsNoindex: shouldNoindexDocument(post),
356
358
  }),
357
359
  },
358
- createRenderContext(state.previewData.site, currentUrl),
360
+ createRenderContext(
361
+ state.previewData.site,
362
+ currentUrl,
363
+ getTargetCommentsContext(state, 'post', post.slug),
364
+ ),
359
365
  );
360
366
  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');
367
+ html = injectSiteCustomizations(html, state, {
368
+ route: currentUrl,
369
+ outputPath,
370
+ });
371
+ await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
363
372
  if (!isDelistedDocument(post)) {
364
373
  state.emitted.posts.push({
365
374
  url: currentUrl,
@@ -395,10 +404,17 @@ async function renderPage(state, page) {
395
404
  robotsNoindex: shouldNoindexDocument(page),
396
405
  }),
397
406
  },
398
- createRenderContext(state.previewData.site, currentUrl),
407
+ createRenderContext(
408
+ state.previewData.site,
409
+ currentUrl,
410
+ getTargetCommentsContext(state, 'page', state.renderData.pageReferencePathByPage.get(page)),
411
+ ),
399
412
  );
400
413
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
401
- html = injectSiteCustomizations(html, state);
414
+ html = injectSiteCustomizations(html, state, {
415
+ route: currentUrl,
416
+ outputPath,
417
+ });
402
418
  await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
403
419
  state.emitted.pages.push({
404
420
  url: currentUrl,
@@ -425,53 +441,72 @@ async function maybeRenderNotFoundPage(state) {
425
441
  route: buildRouteContext('not_found', '/404.html'),
426
442
  meta: buildPageMeta(state.previewData.site, {
427
443
  currentUrl: '/404.html',
428
- title: state.previewData.site.title,
429
- description: state.previewData.site.description,
430
- ogType: 'website',
444
+ title: buildDocumentTitle('Page Not Found', state.previewData.site.title),
445
+ robotsNoindex: true,
446
+ includeRichMetadata: false,
447
+ includeFeedLink: false,
431
448
  }),
432
449
  },
433
450
  createRenderContext(state.previewData.site, '/404.html'),
434
451
  );
435
452
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
436
- html = injectSiteCustomizations(html, state);
453
+ html = injectSiteCustomizations(html, state, {
454
+ route: '/404.html',
455
+ outputPath: '404.html',
456
+ });
437
457
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
438
458
  }
439
459
 
440
460
  function normalizePreviewData(previewData, options = {}) {
441
- const media_base_url = normalizeOptionalString(previewData.site.media_base_url);
461
+ const media_origin = normalizeMediaOrigin(previewData.site.media_origin);
462
+ const mediaDeliveryMode = MEDIA_DELIVERY_MODES.has(previewData.site.media_delivery_mode)
463
+ ? previewData.site.media_delivery_mode
464
+ : 'none';
465
+ if (mediaDeliveryMode === 'media_domain' && !media_origin) {
466
+ 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".');
467
+ }
468
+ const {
469
+ search: siteSearch,
470
+ feed: siteFeed,
471
+ archive: siteArchive,
472
+ comments: siteComments,
473
+ robots: siteRobots,
474
+ ...siteFields
475
+ } = previewData.site;
476
+ const normalizedComments = normalizeSiteComments(siteComments);
442
477
  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',
478
+ ...siteFields,
479
+ url: normalizeSiteOrigin(previewData.site.url),
480
+ media_origin,
481
+ media_delivery_mode: mediaDeliveryMode,
448
482
  favicon: previewData.site.favicon
449
- ? normalizeSiteFavicon(previewData.site.favicon, media_base_url)
483
+ ? normalizeSiteFavicon(previewData.site.favicon, media_origin)
450
484
  : normalizeSiteFavicon(options.favicon, ''),
451
- logo: normalizeSiteLogo(previewData.site.logo, media_base_url),
485
+ logo: normalizeSiteLogo(previewData.site.logo, media_origin),
452
486
  newsletter: normalizeSiteNewsletter(previewData.site.newsletter),
453
487
  posts_per_page: Number.isInteger(previewData.site.posts_per_page) && previewData.site.posts_per_page > 0
454
488
  ? previewData.site.posts_per_page
455
489
  : 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
490
  date_style: DATETIME_STYLES.has(previewData.site.date_style)
460
491
  ? previewData.site.date_style
461
492
  : DEFAULT_DATE_STYLE,
462
493
  time_style: DATETIME_STYLES.has(previewData.site.time_style)
463
494
  ? previewData.site.time_style
464
495
  : 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,
496
+ timezone: normalizeTimezone(previewData.site.timezone),
497
+ locale: normalizeLocale(previewData.site.locale),
468
498
  expose_generator: previewData.site.expose_generator !== false,
469
- search: previewData.site.search !== false,
470
- indexing: previewData.site.indexing !== false,
499
+ search: normalizeRequestedFeatureState(siteSearch),
500
+ feed: normalizeRequestedFeatureState(siteFeed),
501
+ archive: normalizeRequestedFeatureState(siteArchive),
502
+ robots: {
503
+ allow_indexing: siteRobots?.allow_indexing !== false,
504
+ },
471
505
  permalinks: normalizePermalinks(previewData.site.permalinks),
472
506
  front_page: normalizeFrontPage(previewData.site.front_page),
473
507
  post_index: normalizePostIndex(previewData.site.post_index),
474
508
  footer: normalizeSiteFooter(previewData.site.footer),
509
+ ...(normalizedComments ? { comments: normalizedComments } : {}),
475
510
  };
476
511
  const media = normalizeContentMedia(previewData.content.media, normalizedSite);
477
512
  const mediaRegistry = buildMediaRegistry(media);
@@ -479,15 +514,15 @@ function normalizePreviewData(previewData, options = {}) {
479
514
  return {
480
515
  ...previewData,
481
516
  site: normalizedSite,
482
- menus: normalizeRecordMap(previewData.menus),
517
+ menus: normalizeMenus(previewData.menus),
483
518
  collections: normalizeCollections(previewData.collections),
484
- widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_base_url),
519
+ widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_origin),
485
520
  custom_css: normalizeCustomCss(previewData.custom_css),
486
521
  custom_html: normalizeCustomHtml(previewData.custom_html),
487
522
  content: {
488
523
  ...previewData.content,
489
524
  authors: previewData.content.authors.map((author) => {
490
- const avatar = normalizeMediaField(author.avatar, normalizedSite.media_base_url);
525
+ const avatar = normalizeMediaField(author.avatar, normalizedSite.media_origin);
491
526
  const avatarMedia = deriveManagedMedia(avatar, mediaRegistry, normalizedSite);
492
527
  return {
493
528
  ...author,
@@ -497,27 +532,35 @@ function normalizePreviewData(previewData, options = {}) {
497
532
  }),
498
533
  posts: previewData.content.posts
499
534
  .map((post) => {
500
- const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_base_url);
535
+ const { comments, ...postFields } = post;
536
+ const normalizedPostComments = normalizeTargetComments(comments);
537
+ const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_origin);
501
538
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
502
539
  return {
503
- ...post,
540
+ ...postFields,
504
541
  published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
505
542
  updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
543
+ allow_comments: post.allow_comments === true,
506
544
  discoverability: normalizeDiscoverability(post.discoverability),
507
545
  featured_image: featuredImage,
508
546
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
547
+ ...(normalizedPostComments ? { comments: normalizedPostComments } : {}),
509
548
  };
510
549
  })
511
550
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
512
551
  pages: previewData.content.pages.map((page) => {
513
- const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_base_url);
552
+ const { comments, ...pageFields } = page;
553
+ const normalizedPageComments = normalizeTargetComments(comments);
554
+ const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_origin);
514
555
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
515
556
  return {
516
- ...page,
557
+ ...pageFields,
517
558
  ...(page.updated_at_iso ? { updated_at_iso: normalizeIsoTimestamp(page.updated_at_iso) } : {}),
559
+ allow_comments: page.allow_comments === true,
518
560
  discoverability: normalizeDiscoverability(page.discoverability),
519
561
  featured_image: featuredImage,
520
562
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
563
+ ...(normalizedPageComments ? { comments: normalizedPageComments } : {}),
521
564
  };
522
565
  }),
523
566
  categories: [...previewData.content.categories],
@@ -535,6 +578,22 @@ function normalizeRecordMap(value) {
535
578
  return { ...value };
536
579
  }
537
580
 
581
+ function normalizeMenus(menus) {
582
+ const normalized = normalizeRecordMap(menus);
583
+ return Object.fromEntries(Object.entries(normalized).map(([menuId, menu]) => [
584
+ menuId,
585
+ {
586
+ ...menu,
587
+ items: Array.isArray(menu?.items)
588
+ ? menu.items.map((item) => ({
589
+ ...item,
590
+ url: normalizeNavigationUrl(item?.url),
591
+ }))
592
+ : [],
593
+ },
594
+ ]));
595
+ }
596
+
538
597
  function normalizeDiscoverability(value) {
539
598
  return DISCOVERABILITY_VALUES.has(value) ? value : 'default';
540
599
  }
@@ -557,7 +616,7 @@ function normalizeContentMedia(mediaItems, site) {
557
616
  if (!item || typeof item !== 'object') {
558
617
  return null;
559
618
  }
560
- const src = normalizeMediaField(item.src, site.media_base_url);
619
+ const src = normalizeMediaField(item.src, site.media_origin);
561
620
  const width = Number.isInteger(item.width) && item.width > 0 ? item.width : 0;
562
621
  const height = Number.isInteger(item.height) && item.height > 0 ? item.height : 0;
563
622
  if (!src || !width || !height) {
@@ -603,8 +662,8 @@ function buildResponsiveImageSrcset(media, site) {
603
662
  return '';
604
663
  }
605
664
 
606
- const media_base_url = normalizeOptionalString(site.media_base_url);
607
- if (!media_base_url || !isUrlUnderMediaBase(media.src, media_base_url) || !isResponsiveRasterImage(media.src)) {
665
+ const media_origin = normalizeMediaOrigin(site.media_origin);
666
+ if (!media_origin || !isUrlAtMediaOrigin(media.src, media_origin) || !isResponsiveRasterImage(media.src)) {
608
667
  return '';
609
668
  }
610
669
 
@@ -631,12 +690,10 @@ function buildResponsiveImageVariantUrl(src, width) {
631
690
  }
632
691
  }
633
692
 
634
- function isUrlUnderMediaBase(src, media_base_url) {
693
+ function isUrlAtMediaOrigin(src, media_origin) {
635
694
  try {
636
695
  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);
696
+ return sourceUrl.origin === media_origin;
640
697
  } catch {
641
698
  return false;
642
699
  }
@@ -671,7 +728,7 @@ function normalizeCollections(collections) {
671
728
  );
672
729
  }
673
730
 
674
- function normalizeWidgetAreas(widget_areas, media_base_url) {
731
+ function normalizeWidgetAreas(widget_areas, media_origin) {
675
732
  if (!widget_areas || typeof widget_areas !== 'object') {
676
733
  return {};
677
734
  }
@@ -683,7 +740,7 @@ function normalizeWidgetAreas(widget_areas, media_base_url) {
683
740
  ...widgetArea,
684
741
  name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
685
742
  items: Array.isArray(widgetArea?.items)
686
- ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_base_url))
743
+ ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_origin))
687
744
  : [],
688
745
  },
689
746
  ]),
@@ -700,7 +757,7 @@ function normalizeSiteFooter(footer) {
700
757
  };
701
758
  }
702
759
 
703
- function normalizeWidgetItem(item, media_base_url) {
760
+ function normalizeWidgetItem(item, media_origin) {
704
761
  const normalizedItem = {
705
762
  ...item,
706
763
  title: typeof item?.title === 'string' ? item.title.trim() : '',
@@ -711,7 +768,7 @@ function normalizeWidgetItem(item, media_base_url) {
711
768
  ...normalizedItem,
712
769
  settings: {
713
770
  ...item.settings,
714
- avatar: normalizeMediaField(item.settings.avatar, media_base_url),
771
+ avatar: normalizeMediaField(item.settings.avatar, media_origin),
715
772
  },
716
773
  };
717
774
  }
@@ -720,49 +777,57 @@ function normalizeWidgetItem(item, media_base_url) {
720
777
  }
721
778
 
722
779
  function normalizeCustomCss(customCss) {
723
- const content = normalizeOptionalString(customCss?.content);
724
- return content ? { content } : undefined;
780
+ const content = customCss?.content;
781
+ return typeof content === 'string' && content.trim() ? { content } : undefined;
725
782
  }
726
783
 
727
784
  function normalizeCustomHtml(customHtml) {
728
- if (!customHtml || typeof customHtml !== 'object') {
785
+ if (!customHtml || typeof customHtml !== 'object' || Array.isArray(customHtml)) {
729
786
  return undefined;
730
787
  }
731
788
 
732
- const headEnd = normalizeOptionalRawString(customHtml.head_end?.content);
733
- const bodyEnd = normalizeOptionalRawString(customHtml.body_end?.content);
789
+ const headEnd = normalizeCustomHtmlSlot(customHtml.head_end);
790
+ const bodyEnd = normalizeCustomHtmlSlot(customHtml.body_end);
734
791
  if (!headEnd && !bodyEnd) {
735
792
  return undefined;
736
793
  }
737
794
 
738
795
  return {
739
- ...(headEnd ? { head_end: { content: headEnd } } : {}),
740
- ...(bodyEnd ? { body_end: { content: bodyEnd } } : {}),
796
+ ...(headEnd ? { head_end: headEnd } : {}),
797
+ ...(bodyEnd ? { body_end: bodyEnd } : {}),
741
798
  };
742
799
  }
743
800
 
744
- function normalizeSiteFavicon(favicon, media_base_url) {
801
+ function normalizeCustomHtmlSlot(value) {
802
+ if (typeof value !== 'string' || !value.trim()) {
803
+ return '';
804
+ }
805
+
806
+ return [...value].length <= CUSTOM_HTML_SLOT_MAX_CODE_POINTS ? value : '';
807
+ }
808
+
809
+ function normalizeSiteFavicon(favicon, media_origin) {
745
810
  if (!favicon || typeof favicon !== 'object') {
746
811
  return undefined;
747
812
  }
748
813
 
749
814
  const normalized = {};
750
- for (const key of ['icon', 'svg', 'png', 'apple_touch_icon']) {
815
+ for (const key of ['icon', 'icon_dark', 'svg', 'png', 'apple_touch_icon']) {
751
816
  const value = normalizeOptionalString(favicon[key]);
752
817
  if (value) {
753
- normalized[key] = normalizeMediaField(value, media_base_url);
818
+ normalized[key] = normalizeMediaField(value, media_origin);
754
819
  }
755
820
  }
756
821
 
757
822
  return Object.keys(normalized).length ? normalized : undefined;
758
823
  }
759
824
 
760
- function normalizeSiteLogo(logo, media_base_url) {
825
+ function normalizeSiteLogo(logo, media_origin) {
761
826
  if (!logo || typeof logo !== 'object') {
762
827
  return undefined;
763
828
  }
764
829
 
765
- const src = normalizeMediaField(logo.src, media_base_url);
830
+ const src = normalizeMediaField(logo.src, media_origin);
766
831
  if (!src) {
767
832
  return undefined;
768
833
  }
@@ -782,8 +847,8 @@ function normalizeSiteNewsletter(newsletter) {
782
847
  const title = normalizeOptionalString(newsletter.title);
783
848
  const description = normalizeOptionalString(newsletter.description);
784
849
  const buttonLabel = normalizeOptionalString(newsletter.button_label);
785
- const signupUrl = normalizeOptionalString(newsletter.signup_url);
786
- const embedUrl = normalizeOptionalString(newsletter.embed_url);
850
+ const signupUrl = normalizeNavigationUrl(newsletter.signup_url);
851
+ const embedUrl = normalizeNavigationUrl(newsletter.embed_url);
787
852
 
788
853
  return {
789
854
  enabled: newsletter.enabled === true,
@@ -795,6 +860,66 @@ function normalizeSiteNewsletter(newsletter) {
795
860
  };
796
861
  }
797
862
 
863
+ function normalizeSiteComments(comments) {
864
+ if (!comments || typeof comments !== 'object' || Array.isArray(comments)) {
865
+ return undefined;
866
+ }
867
+
868
+ const apiBaseUrl = normalizeCommentsApiBaseUrl(comments.api_base_url);
869
+ if (!apiBaseUrl) {
870
+ return undefined;
871
+ }
872
+
873
+ const threading = comments.threading && typeof comments.threading === 'object' && !Array.isArray(comments.threading)
874
+ ? comments.threading
875
+ : {};
876
+
877
+ return {
878
+ enabled: comments.enabled === true,
879
+ provider: COMMENTS_PROVIDERS.has(comments.provider)
880
+ ? comments.provider
881
+ : DEFAULT_COMMENTS_PROVIDER,
882
+ api_base_url: apiBaseUrl,
883
+ per_page: Number.isInteger(comments.per_page) && comments.per_page >= 1 && comments.per_page <= 100
884
+ ? comments.per_page
885
+ : DEFAULT_COMMENTS_PER_PAGE,
886
+ order: COMMENTS_ORDERS.has(comments.order)
887
+ ? comments.order
888
+ : DEFAULT_COMMENTS_ORDER,
889
+ threading: {
890
+ enabled: typeof threading.enabled === 'boolean'
891
+ ? threading.enabled
892
+ : DEFAULT_COMMENTS_THREADING_ENABLED,
893
+ max_depth: Number.isInteger(threading.max_depth) && threading.max_depth >= 2 && threading.max_depth <= 10
894
+ ? threading.max_depth
895
+ : DEFAULT_COMMENTS_THREADING_MAX_DEPTH,
896
+ },
897
+ };
898
+ }
899
+
900
+ function normalizeRequestedFeatureState(value) {
901
+ return {
902
+ enabled: value?.enabled !== false,
903
+ };
904
+ }
905
+
906
+ function normalizeCommentsApiBaseUrl(value) {
907
+ const normalized = normalizeOptionalString(value);
908
+ if (!normalized || normalized === '/') {
909
+ return normalized;
910
+ }
911
+ return normalized.replace(/\/+$/u, '');
912
+ }
913
+
914
+ function normalizeTargetComments(comments) {
915
+ if (!comments || typeof comments !== 'object' || Array.isArray(comments)) {
916
+ return undefined;
917
+ }
918
+
919
+ const requestToken = preserveOpaqueNonBlankString(comments.request_token);
920
+ return requestToken ? { request_token: requestToken } : undefined;
921
+ }
922
+
798
923
  function normalizePermalinks(permalinks) {
799
924
  const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
800
925
  const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
@@ -821,7 +946,7 @@ function normalizeFrontPage(frontPage) {
821
946
 
822
947
  return {
823
948
  type,
824
- ...(type === 'page' ? { page_slug: normalizeOptionalString(frontPage.page_slug) } : {}),
949
+ ...(type === 'page' ? { page_path: normalizePageReferencePath(frontPage.page_path) } : {}),
825
950
  ...(type === 'standalone_html' ? { html: normalizeOptionalRawString(frontPage.html) } : {}),
826
951
  };
827
952
  }
@@ -838,11 +963,31 @@ function normalizePostIndex(post_index) {
838
963
  };
839
964
  }
840
965
 
841
- function createRenderData(previewData, themeMetadata = {}) {
966
+ function createRenderData(previewData, themePackage = {}, options = {}) {
967
+ const themeMetadata = themePackage.metadata || {};
842
968
  const themeSupportsComments = themeMetadata?.features?.comments === true;
843
969
  const themeSupportsPostIndex = themeMetadata?.features?.post_index !== false;
844
970
  const themeSupportsSearch = themeMetadata?.features?.search === true;
845
- previewData.site.search = previewData.site.search !== false && themeSupportsSearch;
971
+ const outputStyle = previewData.site.permalinks.output_style;
972
+ const requestedComments = previewData.site.comments;
973
+ previewData.site.search = {
974
+ enabled: previewData.site.search.enabled === true && themeSupportsSearch,
975
+ };
976
+ previewData.site.feed = previewData.site.feed.enabled === true
977
+ && hasCanonicalSiteUrl(previewData.site.url)
978
+ && options.generateFeed !== false
979
+ ? { enabled: true, url: '/feed.xml' }
980
+ : { enabled: false };
981
+ previewData.site.archive = previewData.site.archive.enabled === true
982
+ && themePackage.templates?.has('archive') === true
983
+ ? {
984
+ enabled: true,
985
+ url: outputStyle === 'html-extension' ? '/archive' : '/archive/',
986
+ }
987
+ : { enabled: false };
988
+ previewData.site.comments = requestedComments?.enabled === true && themeSupportsComments
989
+ ? { ...requestedComments, enabled: true }
990
+ : { enabled: false };
846
991
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
847
992
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
848
993
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -864,7 +1009,38 @@ function createRenderData(previewData, themeMetadata = {}) {
864
1009
  }
865
1010
  }
866
1011
 
867
- const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
1012
+ const rawPageReferencePaths = new Map(previewData.content.pages.map((page) => [
1013
+ page,
1014
+ resolveEffectivePageReferencePath(previewData.site, page),
1015
+ ]));
1016
+ assertUniquePageReferencePaths(rawPageReferencePaths);
1017
+ const commentsByTarget = {
1018
+ posts: new Map(previewData.content.posts.map((post) => [
1019
+ post.slug,
1020
+ buildTargetCommentsContext({
1021
+ site: previewData.site,
1022
+ target: post,
1023
+ targetType: 'post',
1024
+ themeSupportsComments,
1025
+ }),
1026
+ ])),
1027
+ pages: new Map(previewData.content.pages.map((page) => [
1028
+ rawPageReferencePaths.get(page),
1029
+ buildTargetCommentsContext({
1030
+ site: previewData.site,
1031
+ target: page,
1032
+ targetType: 'page',
1033
+ themeSupportsComments,
1034
+ }),
1035
+ ])),
1036
+ };
1037
+ for (const target of previewData.content.posts) {
1038
+ delete target.comments;
1039
+ }
1040
+ for (const target of previewData.content.pages) {
1041
+ delete target.comments;
1042
+ }
1043
+ const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
868
1044
  const discoverablePreparedPosts = preparedPosts.filter((post) => !isDelistedDocument(post));
869
1045
  const adjacentPostsBySlug = new Map(
870
1046
  discoverablePreparedPosts.map((post, index) => [post.slug, {
@@ -878,30 +1054,50 @@ function createRenderData(previewData, themeMetadata = {}) {
878
1054
  next: adjacentPostsBySlug.get(post.slug)?.next || null,
879
1055
  }));
880
1056
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
1057
+ const pageReferencePathByPage = new Map(pages.map((page, index) => [
1058
+ page,
1059
+ rawPageReferencePaths.get(previewData.content.pages[index]),
1060
+ ]));
881
1061
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
882
- const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
1062
+ const pageByPath = new Map(pages.map((page) => [pageReferencePathByPage.get(page), page]));
883
1063
  const frontPage = previewData.site.front_page;
884
- const collections = resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage);
885
- attachCollectionCursors(posts, pages, collections);
1064
+ const collectionTargetByItem = new WeakMap();
1065
+ const collections = resolveCollections(
1066
+ previewData.collections,
1067
+ postBySlug,
1068
+ pageByPath,
1069
+ frontPage,
1070
+ pageReferencePathByPage,
1071
+ collectionTargetByItem,
1072
+ );
1073
+ attachCollectionCursors(collections, collectionTargetByItem);
886
1074
  const post_index = previewData.site.post_index;
887
1075
  const effectivePostIndexEnabled = post_index.enabled !== false && themeSupportsPostIndex;
888
1076
  const effectivePostIndexPaginate = effectivePostIndexEnabled && post_index.paginate !== false;
889
1077
  const post_indexBasePath = normalizeRoutePath(post_index.path || DEFAULT_POST_INDEX.path);
1078
+ previewData.site.post_index = {
1079
+ ...post_index,
1080
+ enabled: effectivePostIndexEnabled,
1081
+ paginate: effectivePostIndexPaginate,
1082
+ };
890
1083
 
891
1084
  if (frontPage.type !== 'theme_index' && effectivePostIndexEnabled && post_indexBasePath === '/') {
892
1085
  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
1086
  }
894
1087
 
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)
1088
+ const frontPageRoute = buildFrontPageRoute(frontPage, pageByPath, effectivePostIndexEnabled, post_indexBasePath);
1089
+ const frontPagePage = frontPageRoute?.front_page_type === 'page' ? frontPageRoute.page : null;
1090
+ const preparedPages = frontPagePage
1091
+ ? pages.filter((page) => page !== frontPagePage)
899
1092
  : pages;
900
1093
 
901
1094
  return {
902
1095
  posts,
903
1096
  pages: preparedPages,
904
1097
  postBySlug,
1098
+ pageByPath,
1099
+ pageReferencePathByPage,
1100
+ commentsByTarget,
905
1101
  collections,
906
1102
  taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
907
1103
  frontPageRoute,
@@ -915,20 +1111,22 @@ function createRenderData(previewData, themeMetadata = {}) {
915
1111
  postBySlug,
916
1112
  frontPage,
917
1113
  }),
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
- })),
1114
+ archiveRoutes: previewData.site.archive.enabled
1115
+ ? buildPaginatedCollection({
1116
+ items: discoverableSourcePosts,
1117
+ posts_per_page: previewData.site.posts_per_page,
1118
+ basePath: '/archive/',
1119
+ outputStyle: previewData.site.permalinks.output_style,
1120
+ }).map((entry) => ({
1121
+ path: entry.path,
1122
+ page: entry.page,
1123
+ totalPages: entry.totalPages,
1124
+ archive: {
1125
+ groups: buildArchiveGroups(entry.items, postBySlug, previewData.site),
1126
+ },
1127
+ pagination: buildStructuredPagination(entry.paginationData),
1128
+ }))
1129
+ : [],
932
1130
  categoryRoutes: buildTaxonomyRoutes({
933
1131
  items: previewData.content.categories,
934
1132
  postsBySlug: categoryPostsBySlug,
@@ -954,6 +1152,55 @@ function createRenderData(previewData, themeMetadata = {}) {
954
1152
  };
955
1153
  }
956
1154
 
1155
+ export function buildTargetCommentsContext({ site, target, targetType, themeSupportsComments }) {
1156
+ const targetPublicId = target?.public_id;
1157
+ const comments = site?.comments;
1158
+
1159
+ if (
1160
+ themeSupportsComments !== true ||
1161
+ comments?.enabled !== true ||
1162
+ target?.allow_comments !== true ||
1163
+ !Number.isInteger(targetPublicId) ||
1164
+ targetPublicId <= 0
1165
+ ) {
1166
+ return DISABLED_COMMENTS_CONTEXT;
1167
+ }
1168
+
1169
+ const common = {
1170
+ enabled: true,
1171
+ target_type: targetType,
1172
+ target_public_id: targetPublicId,
1173
+ provider: comments.provider,
1174
+ api_base_url: comments.api_base_url,
1175
+ per_page: comments.per_page,
1176
+ order: comments.order,
1177
+ threading: {
1178
+ enabled: comments.threading.enabled,
1179
+ max_depth: comments.threading.max_depth,
1180
+ },
1181
+ };
1182
+
1183
+ if (comments.provider === 'wordpress') {
1184
+ return common;
1185
+ }
1186
+
1187
+ if (comments.provider !== 'zeropress') {
1188
+ return DISABLED_COMMENTS_CONTEXT;
1189
+ }
1190
+
1191
+ const requestToken = preserveOpaqueNonBlankString(target?.comments?.request_token);
1192
+ return requestToken
1193
+ ? { ...common, request_token: requestToken }
1194
+ : DISABLED_COMMENTS_CONTEXT;
1195
+ }
1196
+
1197
+ function getTargetCommentsContext(state, targetType, slug) {
1198
+ const targetMap = targetType === 'post'
1199
+ ? state.renderData.commentsByTarget.posts
1200
+ : state.renderData.commentsByTarget.pages;
1201
+ return targetMap.get(slug) || DISABLED_COMMENTS_CONTEXT;
1202
+ }
1203
+
957
1204
  function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug) {
958
1205
  return {
959
1206
  categories: buildGlobalTaxonomyItems(previewData.site, 'categories', previewData.content.categories, categoryCountBySlug),
@@ -961,14 +1208,22 @@ function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug)
961
1208
  };
962
1209
  }
963
1210
 
964
- function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
1211
+ function resolveCollections(collections, postBySlug, pageByPath, frontPage, pageReferencePathByPage, collectionTargetByItem) {
965
1212
  if (!collections || typeof collections !== 'object') {
966
1213
  return {};
967
1214
  }
968
1215
 
969
1216
  return Object.fromEntries(
970
1217
  Object.entries(collections).map(([collectionId, collection]) => {
971
- const items = resolveCollectionItems(collectionId, collection?.items, postBySlug, pageBySlug, frontPage);
1218
+ const items = resolveCollectionItems(
1219
+ collectionId,
1220
+ collection?.items,
1221
+ postBySlug,
1222
+ pageByPath,
1223
+ frontPage,
1224
+ pageReferencePathByPage,
1225
+ collectionTargetByItem,
1226
+ );
972
1227
  return [
973
1228
  collectionId,
974
1229
  {
@@ -983,44 +1238,58 @@ function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
983
1238
  );
984
1239
  }
985
1240
 
986
- function resolveCollectionItems(collectionId, items, postBySlug, pageBySlug, frontPage) {
1241
+ function resolveCollectionItems(collectionId, items, postBySlug, pageByPath, frontPage, pageReferencePathByPage, collectionTargetByItem) {
987
1242
  if (!Array.isArray(items)) {
988
1243
  return [];
989
1244
  }
990
1245
 
991
- return items.map((item, index) => resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage));
1246
+ return items.map((item, index) => resolveCollectionItem(
1247
+ collectionId,
1248
+ item,
1249
+ index,
1250
+ postBySlug,
1251
+ pageByPath,
1252
+ frontPage,
1253
+ pageReferencePathByPage,
1254
+ collectionTargetByItem,
1255
+ ));
992
1256
  }
993
1257
 
994
- function resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage) {
1258
+ function resolveCollectionItem(collectionId, item, index, postBySlug, pageByPath, frontPage, pageReferencePathByPage, collectionTargetByItem) {
995
1259
  if (item?.type === 'post') {
996
1260
  const post = postBySlug.get(item.slug);
997
1261
  if (!post) {
998
1262
  throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing post slug "${item.slug}".`);
999
1263
  }
1000
- return {
1264
+ const resolved = {
1001
1265
  type: 'post',
1002
1266
  meta: post.meta,
1003
1267
  ...buildStructuredPostSummary(post),
1004
1268
  };
1269
+ collectionTargetByItem.set(resolved, post);
1270
+ return resolved;
1005
1271
  }
1006
1272
 
1007
1273
  if (item?.type === 'page') {
1008
- const page = pageBySlug.get(item.slug);
1274
+ const pagePath = normalizePageReferencePath(item.path);
1275
+ const page = pageByPath.get(pagePath);
1009
1276
  if (!page) {
1010
- throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing page slug "${item.slug}".`);
1277
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing page path "${item.path}".`);
1011
1278
  }
1012
- return buildCollectionPageSummary(page, frontPage);
1279
+ const resolved = buildCollectionPageSummary(page, frontPage, pageReferencePathByPage.get(page));
1280
+ collectionTargetByItem.set(resolved, page);
1281
+ return resolved;
1013
1282
  }
1014
1283
 
1015
1284
  throw new Error(`Invalid collection "${collectionId}": item ${index + 1} has unsupported type "${item?.type}".`);
1016
1285
  }
1017
1286
 
1018
- function buildCollectionPageSummary(page, frontPage) {
1287
+ function buildCollectionPageSummary(page, frontPage, pageReferencePath) {
1019
1288
  return {
1020
1289
  type: 'page',
1021
1290
  title: page.title,
1022
1291
  slug: page.slug,
1023
- url: frontPage?.type === 'page' && frontPage.page_slug === page.slug ? '/' : page.url,
1292
+ url: frontPage?.type === 'page' && frontPage.page_path === pageReferencePath ? '/' : page.url,
1024
1293
  excerpt: page.excerpt || '',
1025
1294
  featured_image: page.featured_image || '',
1026
1295
  updated_at: page.updated_at || '',
@@ -1031,19 +1300,12 @@ function buildCollectionPageSummary(page, frontPage) {
1031
1300
  };
1032
1301
  }
1033
1302
 
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
-
1303
+ function attachCollectionCursors(collections, collectionTargetByItem) {
1038
1304
  for (const [collectionId, collection] of Object.entries(collections || {})) {
1039
1305
  const items = Array.isArray(collection.items) ? collection.items : [];
1040
1306
 
1041
1307
  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;
1308
+ const target = collectionTargetByItem.get(item);
1047
1309
 
1048
1310
  if (!target) {
1049
1311
  return;
@@ -1094,7 +1356,7 @@ function buildCollectionCursorItemSummary(item) {
1094
1356
  };
1095
1357
  }
1096
1358
 
1097
- function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath) {
1359
+ function buildFrontPageRoute(frontPage, pageByPath, effectivePostIndexEnabled, post_indexBasePath) {
1098
1360
  if (frontPage.type === 'theme_index') {
1099
1361
  if (effectivePostIndexEnabled && post_indexBasePath === '/') {
1100
1362
  return null;
@@ -1109,9 +1371,9 @@ function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_i
1109
1371
  }
1110
1372
 
1111
1373
  if (frontPage.type === 'page') {
1112
- const page = pages.find((entry) => entry.slug === frontPage.page_slug);
1374
+ const page = pageByPath.get(frontPage.page_path);
1113
1375
  if (!page) {
1114
- throw new Error(`Invalid front page configuration: site.front_page.page_slug "${frontPage.page_slug}" does not match a page.`);
1376
+ throw new Error(`Invalid front page configuration: site.front_page.page_path "${frontPage.page_path}" does not match a page.`);
1115
1377
  }
1116
1378
 
1117
1379
  return {
@@ -1231,13 +1493,17 @@ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
1231
1493
  case 'tags':
1232
1494
  return resolveTagsWidget(baseWidget, item.settings, previewData);
1233
1495
  case 'archives':
1234
- return resolveArchivesWidget(baseWidget, item.settings, previewData);
1496
+ return previewData.site.archive.enabled === true
1497
+ ? resolveArchivesWidget(baseWidget, item.settings, previewData)
1498
+ : null;
1235
1499
  case 'text':
1236
1500
  return resolveTextWidget(baseWidget, item.settings);
1237
1501
  case 'link-list':
1238
1502
  return resolveLinkListWidget(baseWidget, item.settings);
1239
1503
  case 'search':
1240
- return resolveSearchWidget(baseWidget, item.settings, widgetAreaId, index);
1504
+ return previewData.site.search.enabled === true
1505
+ ? resolveSearchWidget(baseWidget, item.settings, widgetAreaId, index)
1506
+ : null;
1241
1507
  case 'profile':
1242
1508
  return resolveProfileWidget(baseWidget, item.settings);
1243
1509
  default:
@@ -1322,7 +1588,7 @@ function resolveArchivesWidget(baseWidget, settings, previewData) {
1322
1588
  .slice(0, limit)
1323
1589
  .map((entry) => ({
1324
1590
  label: entry.label,
1325
- url: routePathToPublicUrl('/archive/', previewData.site.permalinks.output_style),
1591
+ url: previewData.site.archive.url,
1326
1592
  count: entry.count,
1327
1593
  year: entry.year,
1328
1594
  month: entry.month,
@@ -1414,9 +1680,11 @@ function preparePage(page, site) {
1414
1680
  const documentType = normalizeDocumentType(page.document_type);
1415
1681
  const renderedDocument = renderDocument(page.content, documentType);
1416
1682
  const permalink = resolvePagePermalink(site, page);
1683
+ const pageFields = { ...page };
1684
+ delete pageFields.comments;
1417
1685
 
1418
1686
  return {
1419
- ...page,
1687
+ ...pageFields,
1420
1688
  url: permalink.url,
1421
1689
  document_type: documentType,
1422
1690
  html: renderedDocument.html,
@@ -1425,7 +1693,7 @@ function preparePage(page, site) {
1425
1693
  };
1426
1694
  }
1427
1695
 
1428
- function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
1696
+ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1429
1697
  const documentType = normalizeDocumentType(post.document_type);
1430
1698
  const renderedDocument = renderDocument(post.content, documentType);
1431
1699
  const author = authorsById.get(post.author_id);
@@ -1480,7 +1748,6 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1480
1748
  published_at: formatTimestamp(post.published_at_iso, site),
1481
1749
  updated_at: formatTimestamp(post.updated_at_iso, site),
1482
1750
  reading_time: calculateReadingTime(renderedDocument.html),
1483
- comments_enabled: themeSupportsComments && site.disallow_comments !== true && post.allow_comments === true,
1484
1751
  };
1485
1752
  }
1486
1753
 
@@ -1730,7 +1997,7 @@ function buildAdjacentPostSummary(post) {
1730
1997
  };
1731
1998
  }
1732
1999
 
1733
- function buildArchiveGroups(posts, postBySlug) {
2000
+ function buildArchiveGroups(posts, postBySlug, site) {
1734
2001
  const groups = new Map();
1735
2002
 
1736
2003
  for (const post of posts) {
@@ -1739,14 +2006,12 @@ function buildArchiveGroups(posts, postBySlug) {
1739
2006
  continue;
1740
2007
  }
1741
2008
 
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')}`;
2009
+ const parts = getZonedDateParts(prepared.published_at_iso, site);
2010
+ const label = `${parts.year}-${padDatePart(parts.month)}`;
1746
2011
  const current = groups.get(label) || {
1747
2012
  label,
1748
- year,
1749
- month,
2013
+ year: parts.year,
2014
+ month: parts.month,
1750
2015
  items: [],
1751
2016
  };
1752
2017
 
@@ -1754,7 +2019,8 @@ function buildArchiveGroups(posts, postBySlug) {
1754
2019
  groups.set(label, current);
1755
2020
  }
1756
2021
 
1757
- return Array.from(groups.values());
2022
+ return Array.from(groups.values())
2023
+ .sort((left, right) => right.year - left.year || right.month - left.month);
1758
2024
  }
1759
2025
 
1760
2026
  function buildTaxonomyRouteData(kind, item, countBySlug) {
@@ -1789,25 +2055,31 @@ function buildArchiveEntries(posts, site) {
1789
2055
  }
1790
2056
 
1791
2057
  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 };
2058
+ const parts = getZonedDateParts(date, site);
2059
+ const key = `${parts.year}-${padDatePart(parts.month)}`;
2060
+ const current = entries.get(key) || {
2061
+ date,
2062
+ year: parts.year,
2063
+ month: parts.month,
2064
+ count: 0,
2065
+ };
1794
2066
  current.count += 1;
1795
2067
  entries.set(key, current);
1796
2068
  }
1797
2069
 
1798
2070
  return Array.from(entries.values())
1799
- .sort((left, right) => right.date.getTime() - left.date.getTime())
2071
+ .sort((left, right) => right.year - left.year || right.month - left.month)
1800
2072
  .map((entry) => ({
1801
2073
  label: formatArchiveLabel(entry.date, site),
1802
2074
  count: entry.count,
1803
- year: entry.date.getUTCFullYear(),
1804
- month: entry.date.getUTCMonth() + 1,
2075
+ year: entry.year,
2076
+ month: entry.month,
1805
2077
  }));
1806
2078
  }
1807
2079
 
1808
2080
  function formatArchiveLabel(date, site) {
1809
2081
  return new Intl.DateTimeFormat(normalizeLocale(site.locale || DEFAULT_LOCALE), {
1810
- timeZone: normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE),
2082
+ timeZone: normalizeTimezone(site.timezone),
1811
2083
  year: 'numeric',
1812
2084
  month: 'long',
1813
2085
  }).format(date);
@@ -1818,7 +2090,7 @@ function formatTimestamp(value, site) {
1818
2090
  const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
1819
2091
  const dateStyle = DATETIME_STYLES.has(site.date_style) ? site.date_style : DEFAULT_DATE_STYLE;
1820
2092
  const timeStyle = DATETIME_STYLES.has(site.time_style) ? site.time_style : DEFAULT_TIME_STYLE;
1821
- const siteTimezone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
2093
+ const siteTimezone = normalizeTimezone(site.timezone);
1822
2094
 
1823
2095
  if (dateStyle === 'none' && timeStyle === 'none') {
1824
2096
  return '';
@@ -1858,32 +2130,43 @@ function normalizeOptionalString(value) {
1858
2130
  return typeof value === 'string' && value.trim() ? value.trim() : '';
1859
2131
  }
1860
2132
 
2133
+ function preserveOpaqueNonBlankString(value) {
2134
+ return typeof value === 'string' && value.trim() ? value : '';
2135
+ }
2136
+
1861
2137
  function normalizeOptionalRawString(value) {
1862
2138
  return typeof value === 'string' && value.trim() ? value : '';
1863
2139
  }
1864
2140
 
1865
2141
  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;
2142
+ const candidate = typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_LOCALE;
2143
+ try {
2144
+ return Intl.getCanonicalLocales(candidate)[0] || DEFAULT_LOCALE;
2145
+ } catch {
2146
+ throw new Error(`Invalid preview-data: site.locale "${candidate}" is not a valid BCP 47 language tag.`);
1873
2147
  }
2148
+ }
1874
2149
 
1875
- return parts.map((part, index) => {
1876
- if (index === 0) {
1877
- return part.toLowerCase();
1878
- }
1879
- if (part.length === 2 || part.length === 3) {
1880
- return part.toUpperCase();
2150
+ function normalizeTimezone(value) {
2151
+ const candidate = typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_TIMEZONE;
2152
+ const offsetMatch = /^([+-])(\d{2}):(\d{2})$/u.exec(candidate);
2153
+ if (offsetMatch) {
2154
+ const hours = Number(offsetMatch[2]);
2155
+ const minutes = Number(offsetMatch[3]);
2156
+ if (minutes > 59 || hours > 14 || (hours === 14 && minutes !== 0)) {
2157
+ throw new Error(`Invalid preview-data: site.timezone "${candidate}" is outside the supported fixed-offset range.`);
1881
2158
  }
1882
- if (part.length === 4) {
1883
- return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
2159
+ if (hours === 0 && minutes === 0) {
2160
+ return DEFAULT_TIMEZONE;
1884
2161
  }
1885
- return part.toLowerCase();
1886
- }).join('-');
2162
+ return `${offsetMatch[1]}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
2163
+ }
2164
+
2165
+ try {
2166
+ return new Intl.DateTimeFormat('en-US', { timeZone: candidate }).resolvedOptions().timeZone;
2167
+ } catch {
2168
+ throw new Error(`Invalid preview-data: site.timezone "${candidate}" is not UTC, an IANA time zone, or a valid fixed offset.`);
2169
+ }
1887
2170
  }
1888
2171
 
1889
2172
  function normalizeDocumentType(value) {
@@ -1967,61 +2250,6 @@ async function normalizeAndValidateThemePackage(themePackage) {
1967
2250
  };
1968
2251
  }
1969
2252
 
1970
- function formatThemeValidationFailure(validation) {
1971
- const blocks = [
1972
- [
1973
- 'Theme validation failed',
1974
- `Errors: ${validation.errors.length}`,
1975
- `Checked files: ${validation.checkedFiles}`,
1976
- ].join('\n'),
1977
- ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
1978
- ];
1979
- return blocks.join('\n\n');
1980
- }
1981
-
1982
- function formatThemeValidationIssue(issue) {
1983
- if (!issue) {
1984
- return 'Reason: Unknown error';
1985
- }
1986
-
1987
- const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
1988
- const location = splitIssuePath(issue.path);
1989
- if (location.file) {
1990
- lines.push(`File: ${location.file}`);
1991
- }
1992
- if (location.path) {
1993
- lines.push(`Path: ${location.path}`);
1994
- }
1995
- if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
1996
- lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
1997
- }
1998
- if (issue.category) {
1999
- lines.push(`Category: ${issue.category}`);
2000
- }
2001
- lines.push(`Reason: ${issue.message || 'Unknown error'}`);
2002
- if (issue.snippet) {
2003
- const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
2004
- lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
2005
- }
2006
- if (issue.hint) {
2007
- lines.push('', 'Hint:', issue.hint);
2008
- }
2009
-
2010
- return lines.join('\n');
2011
- }
2012
-
2013
- function splitIssuePath(issuePath) {
2014
- const normalizedPath = String(issuePath || '');
2015
- if (normalizedPath.startsWith('theme.json.')) {
2016
- return {
2017
- file: 'theme.json',
2018
- path: normalizedPath.slice('theme.json.'.length),
2019
- };
2020
- }
2021
-
2022
- return { file: normalizedPath, path: '' };
2023
- }
2024
-
2025
2253
  function normalizeThemePackageMetadata(sourceMetadata, manifest) {
2026
2254
  return {
2027
2255
  ...manifest,
@@ -2029,59 +2257,47 @@ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
2029
2257
  };
2030
2258
  }
2031
2259
 
2032
- async function buildAssetOutputs(assets, assetProcessor, options) {
2260
+ function buildAssetOutputs(assets, assetProcessor, options) {
2033
2261
  const outputs = [];
2034
2262
 
2035
2263
  for (const [assetPath, content] of assets.entries()) {
2036
- const ext = assetPath.split('.').pop()?.toLowerCase();
2037
- let processedContent = content;
2038
- let contentType = getContentType(assetPath);
2039
-
2040
- if (ext === 'css') {
2041
- processedContent = await assetProcessor.processCSS(new TextDecoder().decode(content));
2042
- contentType = 'text/css';
2043
- } else if (ext === 'js') {
2044
- processedContent = await assetProcessor.processJavaScript(new TextDecoder().decode(content));
2045
- contentType = 'application/javascript';
2046
- }
2047
-
2048
2264
  const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(content)}` : '';
2049
2265
  const targetPath = `assets/${assetPath.replace(/(\.[^.]+)$/, `${hash}$1`)}`;
2050
2266
 
2051
2267
  outputs.push({
2052
2268
  originalPath: assetPath,
2053
2269
  path: targetPath,
2054
- content: processedContent,
2055
- contentType,
2270
+ content,
2271
+ contentType: getContentType(assetPath),
2056
2272
  });
2057
2273
  }
2058
2274
 
2059
2275
  return outputs;
2060
2276
  }
2061
2277
 
2062
- async function buildCustomCssAsset(customCss, assetProcessor, options) {
2063
- const content = normalizeOptionalString(customCss?.content);
2064
- if (!content) {
2278
+ function buildCustomCssAsset(customCss, assetProcessor, options) {
2279
+ const content = customCss?.content;
2280
+ if (typeof content !== 'string' || !content.trim()) {
2065
2281
  return null;
2066
2282
  }
2067
2283
 
2068
2284
  const sourceBuffer = new TextEncoder().encode(content);
2069
- const processedContent = await assetProcessor.processCSS(content);
2070
2285
  const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
2071
2286
 
2072
2287
  return {
2073
2288
  originalPath: '__zeropress_custom.css',
2074
2289
  path: `assets/zeropress-custom${hash}.css`,
2075
- content: processedContent,
2290
+ content,
2076
2291
  contentType: 'text/css',
2077
2292
  };
2078
2293
  }
2079
2294
 
2080
- function createRenderContext(site, currentUrl) {
2295
+ function createRenderContext(site, currentUrl, comments = DISABLED_COMMENTS_CONTEXT) {
2081
2296
  return {
2082
2297
  site,
2083
2298
  currentUrl,
2084
2299
  language: site.locale,
2300
+ comments,
2085
2301
  };
2086
2302
  }
2087
2303
 
@@ -2097,23 +2313,26 @@ function buildRouteContext(type, url, options = {}) {
2097
2313
 
2098
2314
  function buildPageMeta(site, options = {}) {
2099
2315
  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);
2316
+ const includeRichMetadata = options.includeRichMetadata !== false;
2317
+ const resolvedDescription = includeRichMetadata ? normalizeOptionalString(options.description) : '';
2318
+ const canonicalUrl = includeRichMetadata
2319
+ ? resolveMetaCanonicalUrl(site, options.canonicalUrl || options.currentUrl)
2320
+ : '';
2321
+ const ogImage = includeRichMetadata ? resolveMetaImageUrl(options.image) : '';
2322
+ const ogType = includeRichMetadata ? normalizeNonEmptyString(options.ogType, 'website') : '';
2323
+ const publishedTime = includeRichMetadata ? normalizeOptionalString(options.publishedTime) : '';
2324
+ const modifiedTime = includeRichMetadata ? normalizeOptionalString(options.modifiedTime) : '';
2106
2325
  const robotsNoindex = options.robotsNoindex === true;
2107
2326
 
2108
2327
  const meta = {
2109
2328
  title: escapeHtml(resolvedTitle),
2110
2329
  description: resolvedDescription ? escapeHtml(resolvedDescription) : '',
2111
2330
  canonical_url: canonicalUrl ? escapeHtml(canonicalUrl) : '',
2112
- og_title: escapeHtml(resolvedTitle),
2113
- og_description: resolvedDescription ? escapeHtml(resolvedDescription) : '',
2331
+ og_title: includeRichMetadata ? escapeHtml(resolvedTitle) : '',
2332
+ og_description: includeRichMetadata && resolvedDescription ? escapeHtml(resolvedDescription) : '',
2114
2333
  og_type: escapeHtml(ogType),
2115
- og_url: canonicalUrl ? escapeHtml(canonicalUrl) : '',
2116
- og_site_name: escapeHtml(site.title),
2334
+ og_url: includeRichMetadata && canonicalUrl ? escapeHtml(canonicalUrl) : '',
2335
+ og_site_name: includeRichMetadata ? escapeHtml(site.title) : '',
2117
2336
  og_image: ogImage ? escapeHtml(ogImage) : '',
2118
2337
  article_published_time: publishedTime ? escapeHtml(publishedTime) : '',
2119
2338
  article_modified_time: modifiedTime ? escapeHtml(modifiedTime) : '',
@@ -2122,7 +2341,7 @@ function buildPageMeta(site, options = {}) {
2122
2341
 
2123
2342
  return {
2124
2343
  ...meta,
2125
- head_tags: buildMetaHeadTags(meta),
2344
+ head_tags: buildMetaHeadTags(meta, site, options),
2126
2345
  };
2127
2346
  }
2128
2347
 
@@ -2138,7 +2357,7 @@ function buildFrontPageTitle(site) {
2138
2357
  return resolvedDescription ? `${resolvedSiteTitle} - ${resolvedDescription}` : resolvedSiteTitle;
2139
2358
  }
2140
2359
 
2141
- function buildMetaHeadTags(meta) {
2360
+ function buildMetaHeadTags(meta, site, options = {}) {
2142
2361
  const tags = [];
2143
2362
 
2144
2363
  if (meta.description) {
@@ -2150,24 +2369,29 @@ function buildMetaHeadTags(meta) {
2150
2369
  if (meta.canonical_url) {
2151
2370
  tags.push(`<link rel="canonical" href="${meta.canonical_url}">`);
2152
2371
  }
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}">`);
2161
- }
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}">`);
2372
+ if (options.includeFeedLink !== false && site.feed?.enabled === true) {
2373
+ tags.push(`<link rel="alternate" type="application/rss+xml" title="${escapeHtml(site.title)} Feed" href="${escapeHtml(site.feed.url)}">`);
2165
2374
  }
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}">`);
2375
+
2376
+ if (meta.og_title) {
2377
+ tags.push(`<meta property="og:title" content="${meta.og_title}">`);
2378
+ if (meta.og_description) {
2379
+ tags.push(`<meta property="og:description" content="${meta.og_description}">`);
2380
+ }
2381
+ tags.push(`<meta property="og:type" content="${meta.og_type}">`);
2382
+ if (meta.og_url) {
2383
+ tags.push(`<meta property="og:url" content="${meta.og_url}">`);
2384
+ }
2385
+ tags.push(`<meta property="og:site_name" content="${meta.og_site_name}">`);
2386
+ if (meta.og_image) {
2387
+ tags.push(`<meta property="og:image" content="${meta.og_image}">`);
2388
+ }
2389
+ if (meta.article_published_time) {
2390
+ tags.push(`<meta property="article:published_time" content="${meta.article_published_time}">`);
2391
+ }
2392
+ if (meta.article_modified_time) {
2393
+ tags.push(`<meta property="article:modified_time" content="${meta.article_modified_time}">`);
2394
+ }
2171
2395
  }
2172
2396
 
2173
2397
  return tags.length ? `${tags.join('\n')}\n` : '';
@@ -2194,7 +2418,7 @@ function resolveMetaImageUrl(image) {
2194
2418
  return '';
2195
2419
  }
2196
2420
 
2197
- function normalizeMediaField(value, media_base_url) {
2421
+ function normalizeMediaField(value, media_origin) {
2198
2422
  if (value === undefined) {
2199
2423
  return undefined;
2200
2424
  }
@@ -2204,22 +2428,145 @@ function normalizeMediaField(value, media_base_url) {
2204
2428
  return '';
2205
2429
  }
2206
2430
 
2207
- if (isAbsoluteUrl(normalizedValue)) {
2208
- return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
2431
+ const normalizedMediaUrl = normalizeMediaUrl(normalizedValue);
2432
+ if (!normalizedMediaUrl) {
2433
+ return '';
2209
2434
  }
2210
2435
 
2211
- const normalizedBaseUrl = normalizeOptionalString(media_base_url);
2212
- if (!normalizedBaseUrl) {
2213
- return normalizedValue;
2436
+ if (isAbsoluteUrl(normalizedMediaUrl)) {
2437
+ return normalizedMediaUrl;
2438
+ }
2439
+
2440
+ const normalizedOrigin = normalizeMediaOrigin(media_origin);
2441
+ if (!normalizedOrigin) {
2442
+ return normalizedMediaUrl;
2443
+ }
2444
+
2445
+ try {
2446
+ return decodeURI(new URL(normalizedMediaUrl, `${normalizedOrigin}/`).toString());
2447
+ } catch {
2448
+ return '';
2449
+ }
2450
+ }
2451
+
2452
+ function normalizeMediaOrigin(value) {
2453
+ const normalizedValue = normalizeOptionalString(value);
2454
+ if (!normalizedValue) {
2455
+ return '';
2214
2456
  }
2215
2457
 
2216
2458
  try {
2217
- return decodeURI(new URL(normalizedValue, normalizedBaseUrl).toString());
2459
+ const url = new URL(normalizedValue);
2460
+ if (
2461
+ !SAFE_MEDIA_PROTOCOLS.has(url.protocol)
2462
+ || url.username
2463
+ || url.password
2464
+ || url.search
2465
+ || url.hash
2466
+ || url.pathname !== '/'
2467
+ || !isSafeUrlText(normalizedValue)
2468
+ ) {
2469
+ return '';
2470
+ }
2471
+ return url.origin;
2218
2472
  } catch {
2219
2473
  return '';
2220
2474
  }
2221
2475
  }
2222
2476
 
2477
+ function normalizeSiteOrigin(value) {
2478
+ const normalizedValue = normalizeOptionalString(value);
2479
+ if (!normalizedValue) {
2480
+ return '';
2481
+ }
2482
+
2483
+ try {
2484
+ const url = new URL(normalizedValue);
2485
+ if (
2486
+ !SAFE_LINK_PROTOCOLS.has(url.protocol)
2487
+ || url.username
2488
+ || url.password
2489
+ || url.search
2490
+ || url.hash
2491
+ || url.pathname !== '/'
2492
+ || !isSafeUrlText(normalizedValue)
2493
+ ) {
2494
+ throw new Error('site.url must be an HTTP(S) origin without credentials, path, query, or fragment');
2495
+ }
2496
+ return url.origin;
2497
+ } catch (error) {
2498
+ const reason = error instanceof Error ? error.message : String(error);
2499
+ throw new Error(`Invalid preview-data: site.url is not a safe HTTP(S) origin: ${reason}`);
2500
+ }
2501
+ }
2502
+
2503
+ function normalizeNavigationUrl(value) {
2504
+ return normalizeSafeResourceUrl(value, { allowRoot: true });
2505
+ }
2506
+
2507
+ function normalizeMediaUrl(value) {
2508
+ return normalizeSafeResourceUrl(value, { allowRoot: false });
2509
+ }
2510
+
2511
+ function normalizeSafeResourceUrl(value, options) {
2512
+ const normalizedValue = normalizeOptionalString(value);
2513
+ if (!normalizedValue || !isSafeUrlText(normalizedValue)) {
2514
+ return '';
2515
+ }
2516
+
2517
+ if (normalizedValue.startsWith('/')) {
2518
+ if (normalizedValue.startsWith('//')) {
2519
+ return '';
2520
+ }
2521
+ const pathname = normalizedValue.split(/[?#]/u, 1)[0];
2522
+ if ((!options.allowRoot && pathname === '/') || hasDotPathSegment(pathname)) {
2523
+ return '';
2524
+ }
2525
+ return normalizedValue;
2526
+ }
2527
+
2528
+ if (!isAbsoluteUrl(normalizedValue)) {
2529
+ return '';
2530
+ }
2531
+
2532
+ const rawPath = extractAbsoluteUrlPath(normalizedValue);
2533
+ if ((!options.allowRoot && (!rawPath || rawPath === '/')) || hasDotPathSegment(rawPath || '/')) {
2534
+ return '';
2535
+ }
2536
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_LINK_PROTOCOLS);
2537
+ }
2538
+
2539
+ function isSafeUrlText(value) {
2540
+ if (/[\s\\\u0000-\u001F\u007F]/u.test(value)) {
2541
+ return false;
2542
+ }
2543
+ try {
2544
+ decodeURI(value);
2545
+ return true;
2546
+ } catch {
2547
+ return false;
2548
+ }
2549
+ }
2550
+
2551
+ function extractAbsoluteUrlPath(value) {
2552
+ const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]*(?<path>[^?#]*)/u.exec(value);
2553
+ return match?.groups?.path || '/';
2554
+ }
2555
+
2556
+ function hasDotPathSegment(pathname) {
2557
+ return String(pathname || '').split('/').some((segment) => {
2558
+ if (!segment) {
2559
+ return false;
2560
+ }
2561
+ try {
2562
+ const decoded = decodeURIComponent(segment).normalize('NFC');
2563
+ return decoded === '.' || decoded === '..';
2564
+ } catch {
2565
+ return true;
2566
+ }
2567
+ });
2568
+ }
2569
+
2223
2570
  function isAbsoluteUrl(value) {
2224
2571
  try {
2225
2572
  new URL(value);
@@ -2232,7 +2579,7 @@ function isAbsoluteUrl(value) {
2232
2579
  function normalizeAbsoluteUrl(value, allowedProtocols) {
2233
2580
  try {
2234
2581
  const url = new URL(value);
2235
- if (!allowedProtocols.has(url.protocol)) {
2582
+ if (!allowedProtocols.has(url.protocol) || url.username || url.password || !isSafeUrlText(value)) {
2236
2583
  return '';
2237
2584
  }
2238
2585
  return decodeURI(url.toString());
@@ -2242,16 +2589,7 @@ function normalizeAbsoluteUrl(value, allowedProtocols) {
2242
2589
  }
2243
2590
 
2244
2591
  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;
2592
+ return normalizeNavigationUrl(value);
2255
2593
  }
2256
2594
 
2257
2595
  async function writeOutput(writer, summaries, path, content, contentType) {
@@ -2320,6 +2658,31 @@ function resolvePagePermalink(site, page) {
2320
2658
  return resolvePermalink(site, 'pages', page);
2321
2659
  }
2322
2660
 
2661
+ function resolveEffectivePageReferencePath(site, page) {
2662
+ return normalizePageReferencePath(resolvePagePermalink(site, page).path);
2663
+ }
2664
+
2665
+ function normalizePageReferencePath(value) {
2666
+ const normalized = decodeRoutePath(normalizeOptionalString(value))
2667
+ .replace(/^\/+|\/+$/gu, '')
2668
+ .normalize('NFC');
2669
+ return normalized;
2670
+ }
2671
+
2672
+ function assertUniquePageReferencePaths(pageReferencePaths) {
2673
+ const seen = new Map();
2674
+ for (const [page, pagePath] of pageReferencePaths.entries()) {
2675
+ if (!pagePath) {
2676
+ throw new Error(`Invalid preview-data: Page "${page?.slug || ''}" resolves to an empty effective path.`);
2677
+ }
2678
+ const existing = seen.get(pagePath);
2679
+ if (existing) {
2680
+ throw new Error(`Invalid preview-data: Pages "${existing.slug}" and "${page.slug}" resolve to the same effective path "${pagePath}".`);
2681
+ }
2682
+ seen.set(pagePath, page);
2683
+ }
2684
+ }
2685
+
2323
2686
  function resolvePermalink(site, kind, item) {
2324
2687
  const pattern = normalizeNonEmptyString(site.permalinks?.[kind], DEFAULT_PERMALINKS[kind]);
2325
2688
  return buildRouteInfo(applyPermalinkPattern(pattern, kind, item, site), site.permalinks.output_style);
@@ -2358,19 +2721,19 @@ function buildPermalinkTokenValues(kind, item, site) {
2358
2721
  };
2359
2722
 
2360
2723
  if (kind === 'posts') {
2361
- const parts = getPermalinkDateParts(item.published_at_iso, site);
2724
+ const parts = getZonedDateParts(item.published_at_iso, site);
2362
2725
  values.public_id = String(item.public_id);
2363
- values.year = parts.year;
2364
- values.month = parts.month;
2365
- values.day = parts.day;
2726
+ values.year = String(parts.year);
2727
+ values.month = padDatePart(parts.month);
2728
+ values.day = padDatePart(parts.day);
2366
2729
  }
2367
2730
 
2368
2731
  return values;
2369
2732
  }
2370
2733
 
2371
- function getPermalinkDateParts(value, site) {
2734
+ function getZonedDateParts(value, site) {
2372
2735
  const date = toDate(value);
2373
- const timeZone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
2736
+ const timeZone = normalizeTimezone(site.timezone);
2374
2737
  const parts = new Intl.DateTimeFormat('en-US', {
2375
2738
  timeZone,
2376
2739
  year: 'numeric',
@@ -2379,12 +2742,16 @@ function getPermalinkDateParts(value, site) {
2379
2742
  }).formatToParts(date);
2380
2743
 
2381
2744
  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'),
2745
+ year: Number(parts.find((part) => part.type === 'year')?.value) || date.getUTCFullYear(),
2746
+ month: Number(parts.find((part) => part.type === 'month')?.value) || date.getUTCMonth() + 1,
2747
+ day: Number(parts.find((part) => part.type === 'day')?.value) || date.getUTCDate(),
2385
2748
  };
2386
2749
  }
2387
2750
 
2751
+ function padDatePart(value) {
2752
+ return String(value).padStart(2, '0');
2753
+ }
2754
+
2388
2755
  function normalizeOutputPath(filePath) {
2389
2756
  return String(filePath || '').replace(/^\/+/, '');
2390
2757
  }
@@ -2437,29 +2804,31 @@ function assertPlannedOutputPathsSafe(state) {
2437
2804
  outputPath: pageToOutputPath(page, outputStyle),
2438
2805
  })),
2439
2806
  ];
2440
- assertUniqueRoutes(routeEntries);
2441
-
2442
- const plannedPaths = [
2443
- ...routeEntries.map((entry) => entry.outputPath),
2807
+ const nonRoutePaths = [
2444
2808
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
2445
2809
  ];
2446
2810
 
2447
- if (shouldGenerateSearchArtifacts(state, state.options)) {
2448
- plannedPaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH);
2811
+ if (shouldGenerateSearchArtifacts(state)) {
2812
+ nonRoutePaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH);
2449
2813
  }
2450
2814
 
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
- }
2815
+ if (hasTemplate(state, '404')) {
2816
+ nonRoutePaths.push('404.html');
2817
+ }
2818
+ if (shouldGenerateRobotsTxt(state.options)) {
2819
+ nonRoutePaths.push('robots.txt');
2820
+ }
2821
+ if (hasCanonicalSiteUrl(state.previewData.site.url)) {
2822
+ nonRoutePaths.push('sitemap.xml');
2823
+ if (shouldGenerateFeed(state)) {
2824
+ nonRoutePaths.push('feed.xml');
2461
2825
  }
2462
2826
  }
2827
+ nonRoutePaths.push(...normalizeReservedOutputPaths(state.options.reservedOutputPaths));
2828
+ const plannedPaths = [
2829
+ ...routeEntries.map((entry) => entry.outputPath),
2830
+ ...nonRoutePaths,
2831
+ ];
2463
2832
 
2464
2833
  for (const plannedPath of plannedPaths) {
2465
2834
  const rawPath = String(plannedPath || '');
@@ -2467,29 +2836,92 @@ function assertPlannedOutputPathsSafe(state) {
2467
2836
  assertSafeRelativeOutputPath(rawPath, normalizedPath);
2468
2837
  }
2469
2838
 
2839
+ const publicUrlClaims = [
2840
+ ...routeEntries.flatMap((entry, index) => {
2841
+ const owner = `route:${index}`;
2842
+ return [entry.url, ...buildOutputPublicUrlAliases(entry.outputPath)]
2843
+ .map((url) => ({ url, owner }));
2844
+ }),
2845
+ ...nonRoutePaths.flatMap((outputPath, index) => {
2846
+ const owner = `file:${index}`;
2847
+ return buildOutputPublicUrlAliases(outputPath)
2848
+ .map((url) => ({ url, owner }));
2849
+ }),
2850
+ ];
2851
+ assertUniquePublicUrlClaims(publicUrlClaims);
2470
2852
  assertUniqueOutputPaths(plannedPaths);
2471
2853
  }
2472
2854
 
2473
- function assertUniqueRoutes(routeEntries) {
2855
+ function normalizeReservedOutputPaths(value) {
2856
+ if (value === undefined) {
2857
+ return [];
2858
+ }
2859
+ if (!Array.isArray(value)) {
2860
+ throw new Error('reservedOutputPaths must be an array of relative output file paths');
2861
+ }
2862
+ return value.map((outputPath) => {
2863
+ if (typeof outputPath !== 'string') {
2864
+ throw new Error('reservedOutputPaths must contain only relative output file paths');
2865
+ }
2866
+ return outputPath;
2867
+ });
2868
+ }
2869
+
2870
+ function buildOutputPublicUrlAliases(outputPath) {
2871
+ const normalizedPath = normalizeOutputPath(outputPath);
2872
+ const aliases = new Set([`/${normalizedPath}`]);
2873
+
2874
+ if (normalizedPath.endsWith('.html')) {
2875
+ aliases.add(`/${normalizedPath.slice(0, -'.html'.length)}`);
2876
+ }
2877
+ if (normalizedPath === 'index.html') {
2878
+ aliases.add('/');
2879
+ } else if (normalizedPath.endsWith('/index.html')) {
2880
+ aliases.add(`/${normalizedPath.slice(0, -'index.html'.length)}`);
2881
+ }
2882
+
2883
+ return [...aliases];
2884
+ }
2885
+
2886
+ function assertUniquePublicUrlClaims(claims) {
2474
2887
  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}`);
2888
+ for (const claim of claims) {
2889
+ const normalizedUrl = normalizeRouteCollisionKey(claim.url);
2890
+ const existingOwner = seenUrls.get(normalizedUrl);
2891
+ if (existingOwner !== undefined && existingOwner !== claim.owner) {
2892
+ throw new Error(`Duplicate public URL detected: ${claim.url}`);
2479
2893
  }
2480
- seenUrls.set(normalizedUrl, entry);
2894
+ seenUrls.set(normalizedUrl, claim.owner);
2481
2895
  }
2482
2896
  }
2483
2897
 
2484
2898
  function assertUniqueOutputPaths(plannedPaths) {
2899
+ const normalizedPaths = plannedPaths.map((plannedPath) => normalizeOutputPath(plannedPath));
2485
2900
  const seenPaths = new Set();
2486
- for (const plannedPath of plannedPaths) {
2487
- const normalizedPath = normalizeOutputPath(plannedPath);
2901
+ for (const [index, normalizedPath] of normalizedPaths.entries()) {
2488
2902
  if (seenPaths.has(normalizedPath)) {
2489
- throw new Error(`Duplicate output path detected: ${plannedPath}`);
2903
+ throw new Error(`Duplicate output path detected: ${plannedPaths[index]}`);
2490
2904
  }
2491
2905
  seenPaths.add(normalizedPath);
2492
2906
  }
2907
+
2908
+ const shallowestFirst = [...seenPaths]
2909
+ .map((outputPath) => ({ outputPath, segments: outputPath.split('/') }))
2910
+ .sort((left, right) => (
2911
+ left.segments.length - right.segments.length
2912
+ || left.outputPath.localeCompare(right.outputPath)
2913
+ ));
2914
+ const processedPaths = new Set();
2915
+ for (const { outputPath, segments } of shallowestFirst) {
2916
+ let ancestorPath = '';
2917
+ for (const segment of segments.slice(0, -1)) {
2918
+ ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
2919
+ if (processedPaths.has(ancestorPath)) {
2920
+ throw new Error(`Conflicting output path hierarchy detected: ${ancestorPath} and ${outputPath}`);
2921
+ }
2922
+ }
2923
+ processedPaths.add(outputPath);
2924
+ }
2493
2925
  }
2494
2926
 
2495
2927
  function normalizeRouteCollisionKey(url) {
@@ -2532,6 +2964,7 @@ function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputP
2532
2964
  if (
2533
2965
  originalPath.startsWith('/') ||
2534
2966
  originalPath.startsWith('\\') ||
2967
+ originalPath.includes('\\') ||
2535
2968
  /^[A-Za-z]:[\\/]/.test(originalPath) ||
2536
2969
  normalizedSeparators.startsWith('//')
2537
2970
  ) {
@@ -2554,11 +2987,11 @@ function sha256(content) {
2554
2987
  return hash.digest('hex');
2555
2988
  }
2556
2989
 
2557
- function injectSiteCustomizations(html, state) {
2990
+ function injectSiteCustomizations(html, state, target) {
2558
2991
  let next = injectFaviconLinks(html, state.favicon);
2559
2992
  next = injectGeneratorMeta(next, state.exposeGenerator);
2560
2993
  next = injectCustomCssAssetLink(next, state.customCssHref);
2561
- next = injectCustomHtml(next, state.customHtml);
2994
+ next = injectCustomHtml(next, state.customHtml, target);
2562
2995
  return next;
2563
2996
  }
2564
2997
 
@@ -2568,7 +3001,7 @@ function injectFaviconLinks(html, favicon) {
2568
3001
  return html;
2569
3002
  }
2570
3003
 
2571
- return html.replace('</head>', `${links}\n</head>`);
3004
+ return insertBeforeClosingTag(html, HEAD_CLOSING_TAG_PATTERN, `${links}\n`);
2572
3005
  }
2573
3006
 
2574
3007
  function buildFaviconLinks(favicon) {
@@ -2577,14 +3010,36 @@ function buildFaviconLinks(favicon) {
2577
3010
  }
2578
3011
 
2579
3012
  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">`);
3013
+ const icon = normalizeOptionalString(favicon.icon);
3014
+ const iconDark = normalizeOptionalString(favicon.icon_dark);
3015
+ const svg = normalizeOptionalString(favicon.svg);
3016
+ const png = normalizeOptionalString(favicon.png);
3017
+ const hasDefaultIcon = Boolean(icon || svg || png);
3018
+ const hasBothColorSchemes = Boolean(iconDark && hasDefaultIcon);
3019
+
3020
+ if (iconDark) {
3021
+ lines.push(buildFaviconLink(
3022
+ iconDark,
3023
+ undefined,
3024
+ hasBothColorSchemes ? '(prefers-color-scheme: dark)' : undefined,
3025
+ ));
3026
+ }
3027
+ if (icon) {
3028
+ lines.push(buildFaviconLink(icon, undefined, hasBothColorSchemes ? '(prefers-color-scheme: light)' : undefined));
3029
+ }
3030
+ if (svg) {
3031
+ lines.push(buildFaviconLink(
3032
+ svg,
3033
+ 'image/svg+xml',
3034
+ hasBothColorSchemes ? '(prefers-color-scheme: light)' : undefined,
3035
+ ));
3036
+ }
3037
+ if (png) {
3038
+ lines.push(buildFaviconLink(
3039
+ png,
3040
+ 'image/png',
3041
+ hasBothColorSchemes ? '(prefers-color-scheme: light)' : undefined,
3042
+ ));
2588
3043
  }
2589
3044
  if (normalizeOptionalString(favicon.apple_touch_icon)) {
2590
3045
  lines.push(` <link rel="apple-touch-icon" href="${escapeHtml(favicon.apple_touch_icon)}">`);
@@ -2593,12 +3048,20 @@ function buildFaviconLinks(favicon) {
2593
3048
  return lines.join('\n');
2594
3049
  }
2595
3050
 
3051
+ function buildFaviconLink(href, type, media) {
3052
+ return ` <link rel="icon" href="${escapeHtml(href)}"${type ? ` type="${type}"` : ''}${media ? ` media="${media}"` : ''}>`;
3053
+ }
3054
+
2596
3055
  function injectGeneratorMeta(html, exposeGenerator) {
2597
3056
  if (exposeGenerator === false) {
2598
3057
  return html;
2599
3058
  }
2600
3059
 
2601
- return html.replace('</head>', ' <meta name="generator" content="ZeroPress">\n</head>');
3060
+ return insertBeforeClosingTag(
3061
+ html,
3062
+ HEAD_CLOSING_TAG_PATTERN,
3063
+ ' <meta name="generator" content="ZeroPress">\n',
3064
+ );
2602
3065
  }
2603
3066
 
2604
3067
  function injectCustomCssAssetLink(html, href) {
@@ -2606,24 +3069,66 @@ function injectCustomCssAssetLink(html, href) {
2606
3069
  return html;
2607
3070
  }
2608
3071
 
2609
- return html.replace('</head>', ` <link rel="stylesheet" href="${escapeHtml(href)}">\n</head>`);
3072
+ return insertBeforeClosingTag(
3073
+ html,
3074
+ HEAD_CLOSING_TAG_PATTERN,
3075
+ ` <link rel="stylesheet" href="${escapeHtml(href)}">\n`,
3076
+ );
2610
3077
  }
2611
3078
 
2612
- function injectCustomHtml(html, customHtml) {
3079
+ function injectCustomHtml(html, customHtml, target) {
2613
3080
  let next = html;
2614
- const headEnd = normalizeOptionalRawString(customHtml?.head_end?.content);
2615
- const bodyEnd = normalizeOptionalRawString(customHtml?.body_end?.content);
3081
+ const headEnd = normalizeCustomHtmlSlot(customHtml?.head_end);
3082
+ const bodyEnd = normalizeCustomHtmlSlot(customHtml?.body_end);
2616
3083
 
2617
3084
  if (headEnd) {
2618
- next = next.replace('</head>', `${headEnd}\n</head>`);
3085
+ next = injectRequiredCustomHtmlSlot(
3086
+ next,
3087
+ 'head_end',
3088
+ headEnd,
3089
+ 'head',
3090
+ HEAD_CLOSING_TAG_PATTERN,
3091
+ target,
3092
+ );
2619
3093
  }
2620
3094
  if (bodyEnd) {
2621
- next = next.replace('</body>', `${bodyEnd}\n</body>`);
3095
+ next = injectRequiredCustomHtmlSlot(
3096
+ next,
3097
+ 'body_end',
3098
+ bodyEnd,
3099
+ 'body',
3100
+ BODY_CLOSING_TAG_PATTERN,
3101
+ target,
3102
+ );
2622
3103
  }
2623
3104
 
2624
3105
  return next;
2625
3106
  }
2626
3107
 
3108
+ function injectRequiredCustomHtmlSlot(html, slot, content, tagName, closingTagPattern, target) {
3109
+ let injected = false;
3110
+ const next = String(html).replace(closingTagPattern, (closingTag) => {
3111
+ injected = true;
3112
+ return `${content}\n${closingTag}`;
3113
+ });
3114
+
3115
+ if (injected) {
3116
+ return next;
3117
+ }
3118
+
3119
+ const route = normalizeOptionalString(target?.route) || '<unknown>';
3120
+ const outputPath = normalizeOptionalString(target?.outputPath) || '<unknown>';
3121
+ throw new Error(
3122
+ `Unable to inject custom_html.${slot} for route "${route}" into output "${outputPath}": `
3123
+ + `rendered theme HTML is missing a closing </${tagName}> tag. `
3124
+ + `Add </${tagName}> to the rendered theme layout or remove custom_html.${slot}.`,
3125
+ );
3126
+ }
3127
+
3128
+ function insertBeforeClosingTag(html, closingTagPattern, content) {
3129
+ return String(html).replace(closingTagPattern, (closingTag) => `${content}${closingTag}`);
3130
+ }
3131
+
2627
3132
  function buildSearchIndexJson(state) {
2628
3133
  return `${JSON.stringify(buildSearchIndexItems(state), null, 2)}\n`;
2629
3134
  }
@@ -2649,18 +3154,26 @@ function buildSearchIndexItems(state) {
2649
3154
  ? state.renderData.frontPageRoute.page
2650
3155
  : null;
2651
3156
  const frontPageItems = frontPagePage && frontPagePage.status === 'published' && !isDelistedDocument(frontPagePage)
2652
- ? [buildSearchPageItem(frontPagePage, '/')]
3157
+ ? [buildSearchPageItem(
3158
+ frontPagePage,
3159
+ '/',
3160
+ state.renderData.pageReferencePathByPage.get(frontPagePage),
3161
+ )]
2653
3162
  : [];
2654
3163
  const pageItems = state.renderData.pages
2655
3164
  .filter((page) => page.status === 'published' && !isDelistedDocument(page))
2656
- .map((page) => buildSearchPageItem(page, page.url));
3165
+ .map((page) => buildSearchPageItem(
3166
+ page,
3167
+ page.url,
3168
+ state.renderData.pageReferencePathByPage.get(page),
3169
+ ));
2657
3170
 
2658
3171
  return [...posts, ...frontPageItems, ...pageItems];
2659
3172
  }
2660
3173
 
2661
- function buildSearchPageItem(page, url) {
3174
+ function buildSearchPageItem(page, url, pageReferencePath) {
2662
3175
  return {
2663
- id: `page:${page.slug}`,
3176
+ id: `page:${pageReferencePath}`,
2664
3177
  type: 'page',
2665
3178
  title: page.title,
2666
3179
  url,
@@ -2717,11 +3230,13 @@ function normalizeSearchText(value) {
2717
3230
  return String(value || '').replace(/\s+/g, ' ').trim();
2718
3231
  }
2719
3232
 
2720
- function buildSearchAdapterJs() {
3233
+ function buildSearchAdapterJs(locale) {
2721
3234
  const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
3235
+ const localeJson = JSON.stringify(locale);
2722
3236
  return `const FIELD_WEIGHTS = ${fieldWeightsJson};
2723
3237
  const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
2724
3238
  const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
3239
+ const SEGMENTER_LOCALE = ${localeJson};
2725
3240
  const DEFAULT_LIMIT = 20;
2726
3241
  const BM25_K1 = 1.2;
2727
3242
  const BM25_B = 0.75;
@@ -2961,44 +3476,59 @@ function tokenize(value) {
2961
3476
  }
2962
3477
 
2963
3478
  const tokens = [];
3479
+ const cjkRuns = Array.from(
3480
+ text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu),
3481
+ (match) => match[0],
3482
+ );
3483
+ const nonCjkText = text.replace(
3484
+ /[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu,
3485
+ ' ',
3486
+ );
3487
+ let segmented = false;
3488
+
2964
3489
  if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2965
3490
  try {
2966
- const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
2967
- for (const part of segmenter.segment(text)) {
3491
+ const segmenter = new Intl.Segmenter(SEGMENTER_LOCALE, { granularity: 'word' });
3492
+ for (const part of segmenter.segment(nonCjkText)) {
2968
3493
  if (part.isWordLike && isUsefulToken(part.segment)) {
2969
3494
  tokens.push(part.segment);
2970
3495
  }
2971
3496
  }
3497
+ segmented = true;
2972
3498
  } catch {
2973
3499
  // Fall through to regex tokenization.
2974
3500
  }
2975
3501
  }
2976
3502
 
2977
- for (const match of text.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
2978
- if (isUsefulToken(match[0])) {
2979
- tokens.push(match[0]);
3503
+ if (!segmented) {
3504
+ for (const match of nonCjkText.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
3505
+ if (isUsefulToken(match[0])) {
3506
+ tokens.push(match[0]);
3507
+ }
2980
3508
  }
2981
3509
  }
2982
3510
 
2983
- for (const match of text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu)) {
2984
- tokens.push(...buildNgrams(match[0], 2));
3511
+ for (const run of cjkRuns) {
3512
+ tokens.push(...buildCjkTokens(run));
2985
3513
  }
2986
3514
 
2987
3515
  return tokens;
2988
3516
  }
2989
3517
 
2990
- function buildNgrams(value, size) {
2991
- const normalized = Array.from(value);
2992
- if (normalized.length <= size) {
2993
- return isUsefulToken(value) ? [value] : [];
3518
+ function buildCjkTokens(value) {
3519
+ const characters = Array.from(value);
3520
+ const tokens = new Set();
3521
+ if (isUsefulToken(value)) {
3522
+ tokens.add(value);
2994
3523
  }
2995
3524
 
2996
- const tokens = [];
2997
- for (let index = 0; index <= normalized.length - size; index += 1) {
2998
- tokens.push(normalized.slice(index, index + size).join(''));
3525
+ if (characters.length > 2) {
3526
+ for (let index = 0; index <= characters.length - 2; index += 1) {
3527
+ tokens.add(characters.slice(index, index + 2).join(''));
3528
+ }
2999
3529
  }
3000
- tokens.push(value);
3001
- return tokens;
3530
+
3531
+ return Array.from(tokens);
3002
3532
  }
3003
3533
 
3004
3534
  function isUsefulToken(value) {
@@ -3104,7 +3634,7 @@ function normalizeLimit(value) {
3104
3634
  `;
3105
3635
  }
3106
3636
 
3107
- function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
3637
+ function buildSitemapXml(site, emitted, stylesheetHref = '') {
3108
3638
  const entries = [
3109
3639
  ...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
3110
3640
  ? [{
@@ -3153,7 +3683,7 @@ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
3153
3683
  return `<?xml version="1.0" encoding="UTF-8"?>${stylesheet}\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
3154
3684
  }
3155
3685
 
3156
- function buildFeedXml(site, emitted, generatedAt) {
3686
+ function buildFeedXml(site, emitted, feedGeneratedAt) {
3157
3687
  const channelLink = resolveSiteUrl(site.url, '/');
3158
3688
  const selfLink = resolveSiteUrl(site.url, '/feed.xml');
3159
3689
  const items = [...emitted.posts]
@@ -3165,12 +3695,12 @@ function buildFeedXml(site, emitted, generatedAt) {
3165
3695
  })
3166
3696
  .join('\n');
3167
3697
 
3168
- return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${generatedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
3698
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${feedGeneratedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
3169
3699
  }
3170
3700
 
3171
3701
  function buildRobotsTxt(site) {
3172
3702
  const lines = ['User-agent: *'];
3173
- if (site.indexing === false) {
3703
+ if (site.robots?.allow_indexing === false) {
3174
3704
  lines.push('Disallow: /');
3175
3705
  return `${lines.join('\n')}\n`;
3176
3706
  }
@@ -3183,15 +3713,15 @@ function buildRobotsTxt(site) {
3183
3713
  }
3184
3714
 
3185
3715
  function shouldGenerateRobotsTxt(options) {
3186
- return options.generateSpecialFiles && options.generateRobotsTxt !== false;
3716
+ return options.generateRobotsTxt !== false;
3187
3717
  }
3188
3718
 
3189
- function shouldGenerateFeed(options) {
3190
- return options.generateSpecialFiles && options.generateFeed !== false;
3719
+ function shouldGenerateFeed(state) {
3720
+ return state.previewData.site.feed.enabled === true;
3191
3721
  }
3192
3722
 
3193
- function shouldGenerateSearchArtifacts(state, options) {
3194
- return options.generateSpecialFiles && state.previewData.site.search === true;
3723
+ function shouldGenerateSearchArtifacts(state) {
3724
+ return state.previewData.site.search.enabled === true;
3195
3725
  }
3196
3726
 
3197
3727
  function getContentType(assetPath) {
@@ -3200,6 +3730,7 @@ function getContentType(assetPath) {
3200
3730
  html: 'text/html',
3201
3731
  css: 'text/css',
3202
3732
  js: 'application/javascript',
3733
+ mjs: 'application/javascript',
3203
3734
  json: 'application/json',
3204
3735
  xml: 'application/xml',
3205
3736
  txt: 'text/plain',
@@ -3240,11 +3771,44 @@ function resolveSiteUrl(siteUrl, relativePath) {
3240
3771
  }
3241
3772
 
3242
3773
  function hasCanonicalSiteUrl(siteUrl) {
3243
- return typeof siteUrl === 'string' && siteUrl.trim() !== '';
3774
+ if (typeof siteUrl !== 'string' || !siteUrl.trim()) {
3775
+ return false;
3776
+ }
3777
+ try {
3778
+ return normalizeSiteOrigin(siteUrl) === siteUrl;
3779
+ } catch {
3780
+ return false;
3781
+ }
3244
3782
  }
3245
3783
 
3246
3784
  function toDate(value) {
3247
- return value ? new Date(value) : new Date();
3785
+ if (value instanceof Date) {
3786
+ const timestamp = value.getTime();
3787
+ if (!Number.isFinite(timestamp)) {
3788
+ throw new Error('Invalid RFC 3339 timestamp');
3789
+ }
3790
+ return new Date(timestamp);
3791
+ }
3792
+
3793
+ if (typeof value !== 'string' || !value) {
3794
+ throw new Error(`Invalid RFC 3339 timestamp: ${String(value)}`);
3795
+ }
3796
+
3797
+ const timestampMatch = value.match(RFC3339_TIMESTAMP_PATTERN);
3798
+ if (!timestampMatch) {
3799
+ throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
3800
+ }
3801
+
3802
+ const isLeapSecond = timestampMatch[2] === '60';
3803
+ const parseValue = isLeapSecond
3804
+ ? `${timestampMatch[1]}59${timestampMatch[3] || ''}${timestampMatch[4]}`
3805
+ : value;
3806
+ const timestamp = Date.parse(parseValue);
3807
+ if (!Number.isFinite(timestamp)) {
3808
+ throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
3809
+ }
3810
+
3811
+ return new Date(timestamp + (isLeapSecond ? 1000 : 0));
3248
3812
  }
3249
3813
 
3250
3814
  function formatUtcIsoSeconds(value) {