@zeropress/build-core 0.5.2 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,10 +14,13 @@ const DEFAULT_OPTIONS = {
14
14
  };
15
15
 
16
16
  const DEFAULT_POSTS_PER_PAGE = 10;
17
- const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
18
- const DEFAULT_TIME_FORMAT = 'HH:mm';
17
+ const DEFAULT_DATETIME_DISPLAY = 'static';
18
+ const DEFAULT_DATE_STYLE = 'medium';
19
+ const DEFAULT_TIME_STYLE = 'none';
19
20
  const DEFAULT_TIMEZONE = 'UTC';
20
21
  const DEFAULT_LOCALE = 'en-US';
22
+ const DATETIME_DISPLAY_MODES = new Set(['static', 'client']);
23
+ const DATETIME_STYLES = new Set(['none', 'short', 'medium', 'long', 'full']);
21
24
  const DEFAULT_PERMALINKS = Object.freeze({
22
25
  output_style: 'directory',
23
26
  posts: '/posts/:slug/',
@@ -35,12 +38,25 @@ const DEFAULT_POST_INDEX = Object.freeze({
35
38
  });
36
39
  const PERMALINK_OUTPUT_STYLES = new Set(['directory', 'html-extension']);
37
40
  const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
41
+ const SEARCH_INDEX_OUTPUT_PATH = '_zeropress/search.json';
42
+ const SEARCH_ADAPTER_OUTPUT_PATH = '_zeropress/search.js';
43
+ const SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH = '_zeropress/search_pagefind.js';
38
44
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
39
45
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
40
46
  const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
41
47
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
48
+ const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
42
49
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
43
50
  const RESPONSIVE_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif']);
51
+ const SEARCH_FIELD_WEIGHTS = Object.freeze({
52
+ title: 5,
53
+ headings: 3,
54
+ tags: 2.5,
55
+ categories: 2,
56
+ excerpt: 1.5,
57
+ content_text: 1,
58
+ });
59
+ const SEARCH_RECENCY_BOOST_MAX = 0.15;
44
60
  export async function buildSite(input) {
45
61
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
46
62
  const state = await createBuildState(input, options);
@@ -92,10 +108,22 @@ export async function buildSite(input) {
92
108
  'application/json',
93
109
  );
94
110
 
111
+ if (shouldGenerateSearchArtifacts(state, options)) {
112
+ await writeOutput(state.writer, state.summaries, SEARCH_INDEX_OUTPUT_PATH, buildSearchIndexJson(state), 'application/json');
113
+ await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(), 'application/javascript');
114
+ await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
115
+ }
116
+
95
117
  if (options.generateSpecialFiles) {
96
118
  await maybeRenderNotFoundPage(state);
97
119
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
98
- await writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt), 'application/xml');
120
+ await writeOutput(
121
+ state.writer,
122
+ state.summaries,
123
+ 'sitemap.xml',
124
+ buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt, options.sitemapStylesheetHref),
125
+ 'application/xml',
126
+ );
99
127
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
100
128
  }
101
129
  if (shouldGenerateRobotsTxt(options)) {
@@ -144,6 +172,7 @@ async function createBuildState(input, options) {
144
172
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
145
173
  customHtml: previewData.custom_html,
146
174
  favicon: previewData.site.favicon,
175
+ exposeGenerator: previewData.site.expose_generator !== false,
147
176
  commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
148
177
  options,
149
178
  generatedAt: new Date(),
@@ -261,6 +290,7 @@ async function renderFrontPage(state, route) {
261
290
  description: page.excerpt,
262
291
  ogType: 'website',
263
292
  image: page.featured_image,
293
+ robotsNoindex: shouldNoindexDocument(page),
264
294
  }),
265
295
  },
266
296
  createRenderContext(state.previewData.site, currentUrl),
@@ -273,6 +303,7 @@ async function renderFrontPage(state, route) {
273
303
  title: page.title,
274
304
  description: page.excerpt,
275
305
  includeInFeed: false,
306
+ includeInSitemap: !isDelistedDocument(page),
276
307
  };
277
308
  return;
278
309
  }
@@ -325,6 +356,7 @@ async function renderPost(state, post) {
325
356
  image: post.featured_image,
326
357
  publishedTime: post.published_at_iso,
327
358
  modifiedTime: post.updated_at_iso,
359
+ robotsNoindex: shouldNoindexDocument(post),
328
360
  }),
329
361
  },
330
362
  createRenderContext(state.previewData.site, currentUrl),
@@ -332,14 +364,16 @@ async function renderPost(state, post) {
332
364
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
333
365
  html = injectSiteCustomizations(html, state);
334
366
  await writeOutput(state.writer, state.summaries, routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style), html, 'text/html');
335
- state.emitted.posts.push({
336
- url: currentUrl,
337
- title: post.title,
338
- description: post.excerpt,
339
- publishedAt: post.published_at_iso,
340
- updatedAt: post.updated_at_iso,
341
- status: post.status,
342
- });
367
+ if (!isDelistedDocument(post)) {
368
+ state.emitted.posts.push({
369
+ url: currentUrl,
370
+ title: post.title,
371
+ description: post.excerpt,
372
+ publishedAt: post.published_at_iso,
373
+ updatedAt: post.updated_at_iso,
374
+ status: post.status,
375
+ });
376
+ }
343
377
  }
344
378
 
345
379
  async function renderPage(state, page) {
@@ -362,6 +396,7 @@ async function renderPage(state, page) {
362
396
  description: page.excerpt,
363
397
  ogType: 'website',
364
398
  image: page.featured_image,
399
+ robotsNoindex: shouldNoindexDocument(page),
365
400
  }),
366
401
  },
367
402
  createRenderContext(state.previewData.site, currentUrl),
@@ -374,7 +409,7 @@ async function renderPage(state, page) {
374
409
  title: page.title,
375
410
  description: page.excerpt,
376
411
  status: page.status,
377
- includeInSitemap: page.omit_from_sitemap !== true,
412
+ includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
378
413
  });
379
414
  }
380
415
 
@@ -408,19 +443,28 @@ async function maybeRenderNotFoundPage(state) {
408
443
  function normalizePreviewData(previewData, options = {}) {
409
444
  const normalizedSite = {
410
445
  ...previewData.site,
411
- mediaBaseUrl: normalizeOptionalString(previewData.site.mediaBaseUrl),
412
- mediaDeliveryMode: MEDIA_DELIVERY_MODES.has(previewData.site.mediaDeliveryMode)
413
- ? previewData.site.mediaDeliveryMode
446
+ media_base_url: normalizeOptionalString(previewData.site.media_base_url),
447
+ media_delivery_mode: MEDIA_DELIVERY_MODES.has(previewData.site.media_delivery_mode)
448
+ ? previewData.site.media_delivery_mode
414
449
  : 'none',
415
450
  favicon: normalizeSiteFavicon(previewData.site.favicon || options.favicon),
416
- postsPerPage: Number.isInteger(previewData.site.postsPerPage) && previewData.site.postsPerPage > 0
417
- ? previewData.site.postsPerPage
451
+ posts_per_page: Number.isInteger(previewData.site.posts_per_page) && previewData.site.posts_per_page > 0
452
+ ? previewData.site.posts_per_page
418
453
  : DEFAULT_POSTS_PER_PAGE,
419
- dateFormat: normalizeNonEmptyString(previewData.site.dateFormat, DEFAULT_DATE_FORMAT),
420
- timeFormat: typeof previewData.site.timeFormat === 'string' ? previewData.site.timeFormat : DEFAULT_TIME_FORMAT,
454
+ datetime_display: DATETIME_DISPLAY_MODES.has(previewData.site.datetime_display)
455
+ ? previewData.site.datetime_display
456
+ : DEFAULT_DATETIME_DISPLAY,
457
+ date_style: DATETIME_STYLES.has(previewData.site.date_style)
458
+ ? previewData.site.date_style
459
+ : DEFAULT_DATE_STYLE,
460
+ time_style: DATETIME_STYLES.has(previewData.site.time_style)
461
+ ? previewData.site.time_style
462
+ : DEFAULT_TIME_STYLE,
421
463
  timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
422
464
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
423
- disallowComments: previewData.site.disallowComments === true,
465
+ disallow_comments: previewData.site.disallow_comments === true,
466
+ expose_generator: previewData.site.expose_generator !== false,
467
+ search: previewData.site.search !== false,
424
468
  indexing: previewData.site.indexing !== false,
425
469
  permalinks: normalizePermalinks(previewData.site.permalinks),
426
470
  front_page: normalizeFrontPage(previewData.site.front_page),
@@ -435,13 +479,13 @@ function normalizePreviewData(previewData, options = {}) {
435
479
  site: normalizedSite,
436
480
  menus: normalizeRecordMap(previewData.menus),
437
481
  collections: normalizeCollections(previewData.collections),
438
- widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
482
+ widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_base_url),
439
483
  custom_css: normalizeCustomCss(previewData.custom_css),
440
484
  custom_html: normalizeCustomHtml(previewData.custom_html),
441
485
  content: {
442
486
  ...previewData.content,
443
487
  authors: previewData.content.authors.map((author) => {
444
- const avatar = normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl);
488
+ const avatar = normalizeMediaField(author.avatar, normalizedSite.media_base_url);
445
489
  const avatarMedia = deriveManagedMedia(avatar, mediaRegistry, normalizedSite);
446
490
  return {
447
491
  ...author,
@@ -451,22 +495,24 @@ function normalizePreviewData(previewData, options = {}) {
451
495
  }),
452
496
  posts: previewData.content.posts
453
497
  .map((post) => {
454
- const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl);
498
+ const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_base_url);
455
499
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
456
500
  return {
457
501
  ...post,
458
502
  published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
459
503
  updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
504
+ discoverability: normalizeDiscoverability(post.discoverability),
460
505
  featured_image: featuredImage,
461
506
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
462
507
  };
463
508
  })
464
509
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
465
510
  pages: previewData.content.pages.map((page) => {
466
- const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.mediaBaseUrl);
511
+ const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_base_url);
467
512
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
468
513
  return {
469
514
  ...page,
515
+ discoverability: normalizeDiscoverability(page.discoverability),
470
516
  featured_image: featuredImage,
471
517
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
472
518
  };
@@ -486,6 +532,18 @@ function normalizeRecordMap(value) {
486
532
  return { ...value };
487
533
  }
488
534
 
535
+ function normalizeDiscoverability(value) {
536
+ return DISCOVERABILITY_VALUES.has(value) ? value : 'default';
537
+ }
538
+
539
+ function isDelistedDocument(document) {
540
+ return document?.discoverability === 'delist';
541
+ }
542
+
543
+ function shouldNoindexDocument(document) {
544
+ return document?.discoverability === 'noindex' || document?.discoverability === 'delist';
545
+ }
546
+
489
547
  function normalizeContentMedia(mediaItems, site) {
490
548
  if (!Array.isArray(mediaItems)) {
491
549
  return [];
@@ -496,7 +554,7 @@ function normalizeContentMedia(mediaItems, site) {
496
554
  if (!item || typeof item !== 'object') {
497
555
  return null;
498
556
  }
499
- const src = normalizeMediaField(item.src, site.mediaBaseUrl);
557
+ const src = normalizeMediaField(item.src, site.media_base_url);
500
558
  const width = Number.isInteger(item.width) && item.width > 0 ? item.width : 0;
501
559
  const height = Number.isInteger(item.height) && item.height > 0 ? item.height : 0;
502
560
  if (!src || !width || !height) {
@@ -538,12 +596,12 @@ function deriveManagedMedia(src, mediaRegistry, site) {
538
596
  }
539
597
 
540
598
  function buildResponsiveImageSrcset(media, site) {
541
- if (site.mediaDeliveryMode !== 'media_domain') {
599
+ if (site.media_delivery_mode !== 'media_domain') {
542
600
  return '';
543
601
  }
544
602
 
545
- const mediaBaseUrl = normalizeOptionalString(site.mediaBaseUrl);
546
- if (!mediaBaseUrl || !isUrlUnderMediaBase(media.src, mediaBaseUrl) || !isResponsiveRasterImage(media.src)) {
603
+ const media_base_url = normalizeOptionalString(site.media_base_url);
604
+ if (!media_base_url || !isUrlUnderMediaBase(media.src, media_base_url) || !isResponsiveRasterImage(media.src)) {
547
605
  return '';
548
606
  }
549
607
 
@@ -570,10 +628,10 @@ function buildResponsiveImageVariantUrl(src, width) {
570
628
  }
571
629
  }
572
630
 
573
- function isUrlUnderMediaBase(src, mediaBaseUrl) {
631
+ function isUrlUnderMediaBase(src, media_base_url) {
574
632
  try {
575
633
  const sourceUrl = new URL(src);
576
- const baseUrl = new URL(mediaBaseUrl);
634
+ const baseUrl = new URL(media_base_url);
577
635
  const basePath = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/`;
578
636
  return sourceUrl.origin === baseUrl.origin && sourceUrl.pathname.startsWith(basePath);
579
637
  } catch {
@@ -610,19 +668,19 @@ function normalizeCollections(collections) {
610
668
  );
611
669
  }
612
670
 
613
- function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
614
- if (!widgetAreas || typeof widgetAreas !== 'object') {
671
+ function normalizeWidgetAreas(widget_areas, media_base_url) {
672
+ if (!widget_areas || typeof widget_areas !== 'object') {
615
673
  return {};
616
674
  }
617
675
 
618
676
  return Object.fromEntries(
619
- Object.entries(widgetAreas).map(([widgetAreaId, widgetArea]) => [
677
+ Object.entries(widget_areas).map(([widgetAreaId, widgetArea]) => [
620
678
  widgetAreaId,
621
679
  {
622
680
  ...widgetArea,
623
681
  name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
624
682
  items: Array.isArray(widgetArea?.items)
625
- ? widgetArea.items.map((item) => normalizeWidgetItem(item, mediaBaseUrl))
683
+ ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_base_url))
626
684
  : [],
627
685
  },
628
686
  ]),
@@ -631,21 +689,15 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
631
689
 
632
690
  function normalizeSiteFooter(footer) {
633
691
  const source = footer && typeof footer === 'object' && !Array.isArray(footer) ? footer : {};
634
- const attribution = source.attribution && typeof source.attribution === 'object' && !Array.isArray(source.attribution)
635
- ? source.attribution
636
- : {};
637
692
 
638
693
  return {
639
694
  ...source,
640
695
  copyright_text: normalizeOptionalString(source.copyright_text),
641
- attribution: {
642
- ...attribution,
643
- enabled: attribution.enabled !== false,
644
- },
696
+ attribution: source.attribution !== false,
645
697
  };
646
698
  }
647
699
 
648
- function normalizeWidgetItem(item, mediaBaseUrl) {
700
+ function normalizeWidgetItem(item, media_base_url) {
649
701
  const normalizedItem = {
650
702
  ...item,
651
703
  title: typeof item?.title === 'string' ? item.title.trim() : '',
@@ -656,7 +708,7 @@ function normalizeWidgetItem(item, mediaBaseUrl) {
656
708
  ...normalizedItem,
657
709
  settings: {
658
710
  ...item.settings,
659
- avatar: normalizeMediaField(item.settings.avatar, mediaBaseUrl),
711
+ avatar: normalizeMediaField(item.settings.avatar, media_base_url),
660
712
  },
661
713
  };
662
714
  }
@@ -733,21 +785,23 @@ function normalizeFrontPage(frontPage) {
733
785
  };
734
786
  }
735
787
 
736
- function normalizePostIndex(postIndex) {
737
- if (!postIndex || typeof postIndex !== 'object') {
788
+ function normalizePostIndex(post_index) {
789
+ if (!post_index || typeof post_index !== 'object') {
738
790
  return { ...DEFAULT_POST_INDEX };
739
791
  }
740
792
 
741
793
  return {
742
- enabled: postIndex.enabled !== false,
743
- path: normalizeNonEmptyString(postIndex.path, DEFAULT_POST_INDEX.path),
744
- paginate: postIndex.paginate !== false,
794
+ enabled: post_index.enabled !== false,
795
+ path: normalizeNonEmptyString(post_index.path, DEFAULT_POST_INDEX.path),
796
+ paginate: post_index.paginate !== false,
745
797
  };
746
798
  }
747
799
 
748
800
  function createRenderData(previewData, themeMetadata = {}) {
749
801
  const themeSupportsComments = themeMetadata?.features?.comments === true;
750
- const themeSupportsPostIndex = themeMetadata?.features?.postIndex !== false;
802
+ const themeSupportsPostIndex = themeMetadata?.features?.post_index !== false;
803
+ const themeSupportsSearch = themeMetadata?.features?.search === true;
804
+ previewData.site.search = previewData.site.search !== false && themeSupportsSearch;
751
805
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
752
806
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
753
807
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -755,8 +809,9 @@ function createRenderData(previewData, themeMetadata = {}) {
755
809
  const tagPostsBySlug = new Map();
756
810
  const categoryCountBySlug = new Map();
757
811
  const tagCountBySlug = new Map();
812
+ const discoverableSourcePosts = previewData.content.posts.filter((post) => !isDelistedDocument(post));
758
813
 
759
- for (const post of previewData.content.posts) {
814
+ for (const post of discoverableSourcePosts) {
760
815
  for (const slug of post.category_slugs) {
761
816
  pushToSlugMap(categoryPostsBySlug, slug, post);
762
817
  categoryCountBySlug.set(slug, (categoryCountBySlug.get(slug) || 0) + 1);
@@ -769,25 +824,34 @@ function createRenderData(previewData, themeMetadata = {}) {
769
824
  }
770
825
 
771
826
  const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
772
- const posts = preparedPosts.map((post, index) => ({
827
+ const discoverablePreparedPosts = preparedPosts.filter((post) => !isDelistedDocument(post));
828
+ const adjacentPostsBySlug = new Map(
829
+ discoverablePreparedPosts.map((post, index) => [post.slug, {
830
+ prev: index > 0 ? buildAdjacentPostSummary(discoverablePreparedPosts[index - 1]) : null,
831
+ next: index < discoverablePreparedPosts.length - 1 ? buildAdjacentPostSummary(discoverablePreparedPosts[index + 1]) : null,
832
+ }]),
833
+ );
834
+ const posts = preparedPosts.map((post) => ({
773
835
  ...post,
774
- prev: index > 0 ? buildAdjacentPostSummary(preparedPosts[index - 1]) : null,
775
- next: index < preparedPosts.length - 1 ? buildAdjacentPostSummary(preparedPosts[index + 1]) : null,
836
+ prev: adjacentPostsBySlug.get(post.slug)?.prev || null,
837
+ next: adjacentPostsBySlug.get(post.slug)?.next || null,
776
838
  }));
777
839
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
778
840
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
779
841
  const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
780
842
  const frontPage = previewData.site.front_page;
781
- const postIndex = previewData.site.post_index;
782
- const effectivePostIndexEnabled = postIndex.enabled !== false && themeSupportsPostIndex;
783
- const effectivePostIndexPaginate = effectivePostIndexEnabled && postIndex.paginate !== false;
784
- const postIndexBasePath = normalizeRoutePath(postIndex.path || DEFAULT_POST_INDEX.path);
785
-
786
- if (frontPage.type !== 'theme_index' && effectivePostIndexEnabled && postIndexBasePath === '/') {
843
+ const collections = resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage);
844
+ attachCollectionCursors(posts, pages, collections);
845
+ const post_index = previewData.site.post_index;
846
+ const effectivePostIndexEnabled = post_index.enabled !== false && themeSupportsPostIndex;
847
+ const effectivePostIndexPaginate = effectivePostIndexEnabled && post_index.paginate !== false;
848
+ const post_indexBasePath = normalizeRoutePath(post_index.path || DEFAULT_POST_INDEX.path);
849
+
850
+ if (frontPage.type !== 'theme_index' && effectivePostIndexEnabled && post_indexBasePath === '/') {
787
851
  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.');
788
852
  }
789
853
 
790
- const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath);
854
+ const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath);
791
855
  const pageFrontPageSlug = frontPage.type === 'page' ? frontPage.page_slug : '';
792
856
  const preparedPages = pageFrontPageSlug
793
857
  ? pages.filter((page) => page.slug !== pageFrontPageSlug)
@@ -797,22 +861,22 @@ function createRenderData(previewData, themeMetadata = {}) {
797
861
  posts,
798
862
  pages: preparedPages,
799
863
  postBySlug,
800
- collections: resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage),
864
+ collections,
801
865
  taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
802
866
  frontPageRoute,
803
867
  indexRoutes: buildPostIndexRoutes({
804
868
  enabled: effectivePostIndexEnabled,
805
869
  paginate: effectivePostIndexPaginate,
806
- items: previewData.content.posts,
807
- postsPerPage: previewData.site.postsPerPage,
808
- basePath: postIndexBasePath,
870
+ items: discoverableSourcePosts,
871
+ posts_per_page: previewData.site.posts_per_page,
872
+ basePath: post_indexBasePath,
809
873
  outputStyle: previewData.site.permalinks.output_style,
810
874
  postBySlug,
811
875
  frontPage,
812
876
  }),
813
877
  archiveRoutes: buildPaginatedCollection({
814
- items: previewData.content.posts,
815
- postsPerPage: previewData.site.postsPerPage,
878
+ items: discoverableSourcePosts,
879
+ posts_per_page: previewData.site.posts_per_page,
816
880
  basePath: '/archive/',
817
881
  outputStyle: previewData.site.permalinks.output_style,
818
882
  }).map((entry) => ({
@@ -828,7 +892,7 @@ function createRenderData(previewData, themeMetadata = {}) {
828
892
  items: previewData.content.categories,
829
893
  postsBySlug: categoryPostsBySlug,
830
894
  postBySlug,
831
- postsPerPage: previewData.site.postsPerPage,
895
+ posts_per_page: previewData.site.posts_per_page,
832
896
  outputStyle: previewData.site.permalinks.output_style,
833
897
  buildBasePath: (category) => resolvePermalink(previewData.site, 'categories', category).path,
834
898
  renderExtras: (category) => ({
@@ -839,7 +903,7 @@ function createRenderData(previewData, themeMetadata = {}) {
839
903
  items: previewData.content.tags,
840
904
  postsBySlug: tagPostsBySlug,
841
905
  postBySlug,
842
- postsPerPage: previewData.site.postsPerPage,
906
+ posts_per_page: previewData.site.posts_per_page,
843
907
  outputStyle: previewData.site.permalinks.output_style,
844
908
  buildBasePath: (tag) => resolvePermalink(previewData.site, 'tags', tag).path,
845
909
  renderExtras: (tag) => ({
@@ -862,15 +926,19 @@ function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
862
926
  }
863
927
 
864
928
  return Object.fromEntries(
865
- Object.entries(collections).map(([collectionId, collection]) => [
866
- collectionId,
867
- {
868
- id: collectionId,
869
- title: normalizeOptionalString(collection?.title),
870
- description: normalizeOptionalString(collection?.description),
871
- items: resolveCollectionItems(collectionId, collection?.items, postBySlug, pageBySlug, frontPage),
872
- },
873
- ]),
929
+ Object.entries(collections).map(([collectionId, collection]) => {
930
+ const items = resolveCollectionItems(collectionId, collection?.items, postBySlug, pageBySlug, frontPage);
931
+ return [
932
+ collectionId,
933
+ {
934
+ id: collectionId,
935
+ title: normalizeOptionalString(collection?.title),
936
+ description: normalizeOptionalString(collection?.description),
937
+ count: items.length,
938
+ items,
939
+ },
940
+ ];
941
+ }),
874
942
  );
875
943
  }
876
944
 
@@ -916,12 +984,70 @@ function buildCollectionPageSummary(page, frontPage) {
916
984
  featured_image: page.featured_image || '',
917
985
  ...(page.featured_media ? { featured_media: { ...page.featured_media } } : {}),
918
986
  meta: page.meta,
987
+ data: page.data,
919
988
  };
920
989
  }
921
990
 
922
- function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath) {
991
+ function attachCollectionCursors(posts, pages, collections) {
992
+ const postTargets = new Map(posts.map((post) => [post.slug, post]));
993
+ const pageTargets = new Map(pages.map((page) => [page.slug, page]));
994
+
995
+ for (const [collectionId, collection] of Object.entries(collections || {})) {
996
+ const items = Array.isArray(collection.items) ? collection.items : [];
997
+
998
+ items.forEach((item, index) => {
999
+ const target = item.type === 'post'
1000
+ ? postTargets.get(item.slug)
1001
+ : item.type === 'page'
1002
+ ? pageTargets.get(item.slug)
1003
+ : null;
1004
+
1005
+ if (!target) {
1006
+ return;
1007
+ }
1008
+
1009
+ target.collection_cursors = target.collection_cursors || {};
1010
+ target.collection_cursors[collectionId] = buildCollectionCursor(collectionId, collection, items, index);
1011
+ });
1012
+ }
1013
+ }
1014
+
1015
+ function buildCollectionCursor(collectionId, collection, items, index) {
1016
+ const count = items.length;
1017
+
1018
+ return {
1019
+ collection_id: collectionId,
1020
+ collection_title: collection.title || '',
1021
+ index,
1022
+ position: index + 1,
1023
+ count,
1024
+ first: index === 0,
1025
+ last: index === count - 1,
1026
+ prev: index > 0 ? buildCollectionCursorItemSummary(items[index - 1]) : null,
1027
+ next: index < count - 1 ? buildCollectionCursorItemSummary(items[index + 1]) : null,
1028
+ };
1029
+ }
1030
+
1031
+ function buildCollectionCursorItemSummary(item) {
1032
+ if (!item) {
1033
+ return null;
1034
+ }
1035
+
1036
+ return {
1037
+ type: item.type,
1038
+ title: item.title,
1039
+ slug: item.slug,
1040
+ url: item.url,
1041
+ excerpt: item.excerpt || '',
1042
+ featured_image: item.featured_image || '',
1043
+ meta: item.meta,
1044
+ data: item.data,
1045
+ };
1046
+ }
1047
+
1048
+ function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath) {
923
1049
  if (frontPage.type === 'theme_index') {
924
- if (effectivePostIndexEnabled && postIndexBasePath === '/') {
1050
+ if (effectivePostIndexEnabled && post_indexBasePath === '/') {
925
1051
  return null;
926
1052
  }
927
1053
 
@@ -967,7 +1093,7 @@ function buildPostIndexRoutes(options) {
967
1093
  }
968
1094
 
969
1095
  if (!options.paginate) {
970
- const items = options.items.slice(0, options.postsPerPage);
1096
+ const items = options.items.slice(0, options.posts_per_page);
971
1097
  return [{
972
1098
  path: options.basePath,
973
1099
  route_type: 'post_index',
@@ -982,7 +1108,7 @@ function buildPostIndexRoutes(options) {
982
1108
 
983
1109
  return buildPaginatedCollection({
984
1110
  items: options.items,
985
- postsPerPage: options.postsPerPage,
1111
+ posts_per_page: options.posts_per_page,
986
1112
  basePath: options.basePath,
987
1113
  outputStyle: options.outputStyle,
988
1114
  }).map((entry) => ({
@@ -1073,6 +1199,7 @@ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
1073
1199
  function resolveRecentPostsWidget(baseWidget, settings, renderData) {
1074
1200
  const limit = clampInteger(settings?.limit, 5, 1, 20);
1075
1201
  const items = renderData.posts
1202
+ .filter((post) => !isDelistedDocument(post))
1076
1203
  .slice(0, limit)
1077
1204
  .map((post) => ({
1078
1205
  title: post.title,
@@ -1284,7 +1411,9 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1284
1411
  featured_image: post.featured_image,
1285
1412
  ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1286
1413
  meta: post.meta,
1414
+ data: post.data,
1287
1415
  status: post.status,
1416
+ discoverability: post.discoverability,
1288
1417
  allow_comments: post.allow_comments,
1289
1418
  category_slugs: post.category_slugs,
1290
1419
  tag_slugs: post.tag_slugs,
@@ -1301,7 +1430,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1301
1430
  published_at: formatTimestamp(post.published_at_iso, site),
1302
1431
  updated_at: formatTimestamp(post.updated_at_iso, site),
1303
1432
  reading_time: calculateReadingTime(renderedDocument.html),
1304
- comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
1433
+ comments_enabled: themeSupportsComments && site.disallow_comments !== true && post.allow_comments === true,
1305
1434
  };
1306
1435
  }
1307
1436
 
@@ -1328,7 +1457,7 @@ function buildTaxonomyRoutes(options) {
1328
1457
 
1329
1458
  const paginated = buildPaginatedCollection({
1330
1459
  items: matchedPosts,
1331
- postsPerPage: options.postsPerPage,
1460
+ posts_per_page: options.posts_per_page,
1332
1461
  basePath: options.buildBasePath(item),
1333
1462
  outputStyle: options.outputStyle,
1334
1463
  });
@@ -1352,14 +1481,14 @@ function buildTaxonomyRoutes(options) {
1352
1481
  function buildPaginatedCollection(options) {
1353
1482
  const basePath = normalizePaginationBasePath(options.basePath);
1354
1483
  const outputStyle = PERMALINK_OUTPUT_STYLES.has(options.outputStyle) ? options.outputStyle : DEFAULT_PERMALINKS.output_style;
1355
- const postsPerPage = Number.isInteger(options.postsPerPage) && options.postsPerPage > 0 ? options.postsPerPage : DEFAULT_POSTS_PER_PAGE;
1484
+ const posts_per_page = Number.isInteger(options.posts_per_page) && options.posts_per_page > 0 ? options.posts_per_page : DEFAULT_POSTS_PER_PAGE;
1356
1485
  const totalPosts = options.items.length;
1357
- const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
1486
+ const totalPages = Math.max(1, Math.ceil(totalPosts / posts_per_page));
1358
1487
  const pages = [];
1359
1488
 
1360
1489
  for (let page = 1; page <= totalPages; page += 1) {
1361
- const start = (page - 1) * postsPerPage;
1362
- const end = start + postsPerPage;
1490
+ const start = (page - 1) * posts_per_page;
1491
+ const end = start + posts_per_page;
1363
1492
 
1364
1493
  pages.push({
1365
1494
  path: buildPaginatedPath(basePath, page),
@@ -1535,6 +1664,8 @@ function buildStructuredPostSummary(post) {
1535
1664
  reading_time: post.reading_time,
1536
1665
  featured_image: post.featured_image,
1537
1666
  ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1667
+ meta: post.meta,
1668
+ data: post.data,
1538
1669
  author: {
1539
1670
  display_name: post.author?.display_name || '',
1540
1671
  avatar: post.author?.avatar || '',
@@ -1557,6 +1688,7 @@ function buildAdjacentPostSummary(post) {
1557
1688
  excerpt: post.excerpt,
1558
1689
  published_at: post.published_at,
1559
1690
  published_at_iso: post.published_at_iso,
1691
+ data: post.data,
1560
1692
  };
1561
1693
  }
1562
1694
 
@@ -1646,50 +1778,25 @@ function formatArchiveLabel(date, site) {
1646
1778
  function formatTimestamp(value, site) {
1647
1779
  const date = toDate(value);
1648
1780
  const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
1649
- const dateFormat = normalizeNonEmptyString(site.dateFormat, DEFAULT_DATE_FORMAT);
1650
- const timeFormat = typeof site.timeFormat === 'string' ? site.timeFormat : DEFAULT_TIME_FORMAT;
1781
+ const dateStyle = DATETIME_STYLES.has(site.date_style) ? site.date_style : DEFAULT_DATE_STYLE;
1782
+ const timeStyle = DATETIME_STYLES.has(site.time_style) ? site.time_style : DEFAULT_TIME_STYLE;
1651
1783
  const siteTimezone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
1652
1784
 
1653
- const dateParts = new Intl.DateTimeFormat(locale, {
1654
- timeZone: siteTimezone,
1655
- year: 'numeric',
1656
- month: dateFormat === 'MMMM D, YYYY' ? 'long' : '2-digit',
1657
- day: '2-digit',
1658
- }).formatToParts(date);
1659
-
1660
- const year = dateParts.find((part) => part.type === 'year')?.value || '';
1661
- const month = dateParts.find((part) => part.type === 'month')?.value || '';
1662
- const day = dateParts.find((part) => part.type === 'day')?.value || '';
1663
-
1664
- let formattedDate = `${year}-${month}-${day}`;
1665
- if (dateFormat === 'DD/MM/YYYY') {
1666
- formattedDate = `${day}/${month}/${year}`;
1667
- } else if (dateFormat === 'MM/DD/YYYY') {
1668
- formattedDate = `${month}/${day}/${year}`;
1669
- } else if (dateFormat === 'MMMM D, YYYY') {
1670
- formattedDate = `${month} ${String(Number(day))}, ${year}`;
1671
- }
1672
-
1673
- if (!timeFormat) {
1674
- return formattedDate;
1785
+ if (dateStyle === 'none' && timeStyle === 'none') {
1786
+ return '';
1675
1787
  }
1676
1788
 
1677
- const timeParts = new Intl.DateTimeFormat(locale, {
1789
+ const options = {
1678
1790
  timeZone: siteTimezone,
1679
- hour: '2-digit',
1680
- minute: '2-digit',
1681
- hour12: timeFormat === 'hh:mm A',
1682
- }).formatToParts(date);
1683
-
1684
- const hour = timeParts.find((part) => part.type === 'hour')?.value || '';
1685
- const minute = timeParts.find((part) => part.type === 'minute')?.value || '';
1686
- const dayPeriod = (timeParts.find((part) => part.type === 'dayPeriod')?.value || '').toUpperCase();
1687
-
1688
- const formattedTime = timeFormat === 'hh:mm A'
1689
- ? `${hour}:${minute}${dayPeriod ? ` ${dayPeriod}` : ''}`
1690
- : `${hour}:${minute}`;
1791
+ };
1792
+ if (dateStyle !== 'none') {
1793
+ options.dateStyle = dateStyle;
1794
+ }
1795
+ if (timeStyle !== 'none') {
1796
+ options.timeStyle = timeStyle;
1797
+ }
1691
1798
 
1692
- return `${formattedDate} ${formattedTime}`.trim();
1799
+ return new Intl.DateTimeFormat(locale, options).format(date);
1693
1800
  }
1694
1801
 
1695
1802
  function calculateReadingTime(html) {
@@ -1785,11 +1892,12 @@ async function normalizeAndValidateThemePackage(themePackage) {
1785
1892
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
1786
1893
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
1787
1894
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1895
+ ...(themePackage.metadata.links ? { links: themePackage.metadata.links } : {}),
1788
1896
  ...(themePackage.metadata.features ? { features: themePackage.metadata.features } : {}),
1789
- ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1790
- ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
1791
- ...(themePackage.metadata.siteMeta ? { siteMeta: themePackage.metadata.siteMeta } : {}),
1792
- ...(themePackage.metadata.collectionSlots ? { collectionSlots: themePackage.metadata.collectionSlots } : {}),
1897
+ ...(themePackage.metadata.menu_slots ? { menu_slots: themePackage.metadata.menu_slots } : {}),
1898
+ ...(themePackage.metadata.widget_areas ? { widget_areas: themePackage.metadata.widget_areas } : {}),
1899
+ ...(themePackage.metadata.site_meta ? { site_meta: themePackage.metadata.site_meta } : {}),
1900
+ ...(themePackage.metadata.collection_slots ? { collection_slots: themePackage.metadata.collection_slots } : {}),
1793
1901
  }));
1794
1902
 
1795
1903
  for (const [templateName, templateContent] of themePackage.templates.entries()) {
@@ -1806,7 +1914,7 @@ async function normalizeAndValidateThemePackage(themePackage) {
1806
1914
 
1807
1915
  const validation = await validateThemeFiles(fileMap);
1808
1916
  if (!validation.ok) {
1809
- throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
1917
+ throw new Error(formatThemeValidationFailure(validation));
1810
1918
  }
1811
1919
 
1812
1920
  if (!validation.manifest) {
@@ -1821,6 +1929,61 @@ async function normalizeAndValidateThemePackage(themePackage) {
1821
1929
  };
1822
1930
  }
1823
1931
 
1932
+ function formatThemeValidationFailure(validation) {
1933
+ const blocks = [
1934
+ [
1935
+ 'Theme validation failed',
1936
+ `Errors: ${validation.errors.length}`,
1937
+ `Checked files: ${validation.checkedFiles}`,
1938
+ ].join('\n'),
1939
+ ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
1940
+ ];
1941
+ return blocks.join('\n\n');
1942
+ }
1943
+
1944
+ function formatThemeValidationIssue(issue) {
1945
+ if (!issue) {
1946
+ return 'Reason: Unknown error';
1947
+ }
1948
+
1949
+ const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
1950
+ const location = splitIssuePath(issue.path);
1951
+ if (location.file) {
1952
+ lines.push(`File: ${location.file}`);
1953
+ }
1954
+ if (location.path) {
1955
+ lines.push(`Path: ${location.path}`);
1956
+ }
1957
+ if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
1958
+ lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
1959
+ }
1960
+ if (issue.category) {
1961
+ lines.push(`Category: ${issue.category}`);
1962
+ }
1963
+ lines.push(`Reason: ${issue.message || 'Unknown error'}`);
1964
+ if (issue.snippet) {
1965
+ const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
1966
+ lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
1967
+ }
1968
+ if (issue.hint) {
1969
+ lines.push('', 'Hint:', issue.hint);
1970
+ }
1971
+
1972
+ return lines.join('\n');
1973
+ }
1974
+
1975
+ function splitIssuePath(issuePath) {
1976
+ const normalizedPath = String(issuePath || '');
1977
+ if (normalizedPath.startsWith('theme.json.')) {
1978
+ return {
1979
+ file: 'theme.json',
1980
+ path: normalizedPath.slice('theme.json.'.length),
1981
+ };
1982
+ }
1983
+
1984
+ return { file: normalizedPath, path: '' };
1985
+ }
1986
+
1824
1987
  function normalizeThemePackageMetadata(sourceMetadata, manifest) {
1825
1988
  return {
1826
1989
  ...manifest,
@@ -1902,6 +2065,7 @@ function buildPageMeta(site, options = {}) {
1902
2065
  const ogType = normalizeNonEmptyString(options.ogType, 'website');
1903
2066
  const publishedTime = normalizeOptionalString(options.publishedTime);
1904
2067
  const modifiedTime = normalizeOptionalString(options.modifiedTime);
2068
+ const robotsNoindex = options.robotsNoindex === true;
1905
2069
 
1906
2070
  const meta = {
1907
2071
  title: escapeHtml(resolvedTitle),
@@ -1915,6 +2079,7 @@ function buildPageMeta(site, options = {}) {
1915
2079
  og_image: ogImage ? escapeHtml(ogImage) : '',
1916
2080
  article_published_time: publishedTime ? escapeHtml(publishedTime) : '',
1917
2081
  article_modified_time: modifiedTime ? escapeHtml(modifiedTime) : '',
2082
+ robots_noindex: robotsNoindex,
1918
2083
  };
1919
2084
 
1920
2085
  return {
@@ -1935,6 +2100,9 @@ function buildMetaHeadTags(meta) {
1935
2100
  if (meta.description) {
1936
2101
  tags.push(`<meta name="description" content="${meta.description}">`);
1937
2102
  }
2103
+ if (meta.robots_noindex) {
2104
+ tags.push('<meta name="robots" content="noindex">');
2105
+ }
1938
2106
  if (meta.canonical_url) {
1939
2107
  tags.push(`<link rel="canonical" href="${meta.canonical_url}">`);
1940
2108
  }
@@ -1982,7 +2150,7 @@ function resolveMetaImageUrl(image) {
1982
2150
  return '';
1983
2151
  }
1984
2152
 
1985
- function normalizeMediaField(value, mediaBaseUrl) {
2153
+ function normalizeMediaField(value, media_base_url) {
1986
2154
  if (value === undefined) {
1987
2155
  return undefined;
1988
2156
  }
@@ -1996,7 +2164,7 @@ function normalizeMediaField(value, mediaBaseUrl) {
1996
2164
  return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
1997
2165
  }
1998
2166
 
1999
- const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
2167
+ const normalizedBaseUrl = normalizeOptionalString(media_base_url);
2000
2168
  if (!normalizedBaseUrl) {
2001
2169
  return normalizedValue;
2002
2170
  }
@@ -2233,6 +2401,10 @@ function assertPlannedOutputPathsSafe(state) {
2233
2401
  COMMENT_POLICY_OUTPUT_PATH,
2234
2402
  ];
2235
2403
 
2404
+ if (shouldGenerateSearchArtifacts(state, state.options)) {
2405
+ plannedPaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH);
2406
+ }
2407
+
2236
2408
  if (state.options.generateSpecialFiles) {
2237
2409
  plannedPaths.push('404.html');
2238
2410
  if (shouldGenerateRobotsTxt(state.options)) {
@@ -2338,6 +2510,7 @@ function sha256(content) {
2338
2510
 
2339
2511
  function injectSiteCustomizations(html, state) {
2340
2512
  let next = injectFaviconLinks(html, state.favicon);
2513
+ next = injectGeneratorMeta(next, state.exposeGenerator);
2341
2514
  next = injectCustomCssAssetLink(next, state.customCssHref);
2342
2515
  next = injectCustomHtml(next, state.customHtml);
2343
2516
  return next;
@@ -2374,6 +2547,14 @@ function buildFaviconLinks(favicon) {
2374
2547
  return lines.join('\n');
2375
2548
  }
2376
2549
 
2550
+ function injectGeneratorMeta(html, exposeGenerator) {
2551
+ if (exposeGenerator === false) {
2552
+ return html;
2553
+ }
2554
+
2555
+ return html.replace('</head>', ' <meta name="generator" content="ZeroPress">\n</head>');
2556
+ }
2557
+
2377
2558
  function injectCustomCssAssetLink(html, href) {
2378
2559
  if (!normalizeOptionalString(href)) {
2379
2560
  return html;
@@ -2397,9 +2578,489 @@ function injectCustomHtml(html, customHtml) {
2397
2578
  return next;
2398
2579
  }
2399
2580
 
2400
- function buildSitemapXml(site, emitted, generatedAt) {
2581
+ function buildSearchIndexJson(state) {
2582
+ return `${JSON.stringify(buildSearchIndexItems(state), null, 2)}\n`;
2583
+ }
2584
+
2585
+ function buildSearchIndexItems(state) {
2586
+ const posts = state.renderData.posts
2587
+ .filter((post) => post.status === 'published' && !isDelistedDocument(post))
2588
+ .map((post) => ({
2589
+ id: `post:${post.slug}`,
2590
+ type: 'post',
2591
+ title: post.title,
2592
+ url: post.url,
2593
+ excerpt: normalizeSearchText(post.excerpt),
2594
+ headings: buildSearchHeadings(post.toc),
2595
+ categories: Array.isArray(post.categories) ? post.categories.map((category) => category.name).filter(Boolean) : [],
2596
+ tags: Array.isArray(post.tags) ? post.tags.map((tag) => tag.name).filter(Boolean) : [],
2597
+ published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
2598
+ updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
2599
+ content_text: htmlToSearchText(post.html),
2600
+ }));
2601
+
2602
+ const frontPagePage = state.renderData.frontPageRoute?.front_page_type === 'page'
2603
+ ? state.renderData.frontPageRoute.page
2604
+ : null;
2605
+ const frontPageItems = frontPagePage && frontPagePage.status === 'published' && !isDelistedDocument(frontPagePage)
2606
+ ? [buildSearchPageItem(frontPagePage, '/')]
2607
+ : [];
2608
+ const pageItems = state.renderData.pages
2609
+ .filter((page) => page.status === 'published' && !isDelistedDocument(page))
2610
+ .map((page) => buildSearchPageItem(page, page.url));
2611
+
2612
+ return [...posts, ...frontPageItems, ...pageItems];
2613
+ }
2614
+
2615
+ function buildSearchPageItem(page, url) {
2616
+ return {
2617
+ id: `page:${page.slug}`,
2618
+ type: 'page',
2619
+ title: page.title,
2620
+ url,
2621
+ excerpt: normalizeSearchText(page.excerpt),
2622
+ headings: buildSearchHeadings(page.toc),
2623
+ categories: [],
2624
+ tags: [],
2625
+ published_at_iso: '',
2626
+ updated_at_iso: '',
2627
+ content_text: htmlToSearchText(page.html),
2628
+ };
2629
+ }
2630
+
2631
+ function buildSearchHeadings(toc) {
2632
+ return Array.isArray(toc)
2633
+ ? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
2634
+ : [];
2635
+ }
2636
+
2637
+ function htmlToSearchText(html) {
2638
+ return normalizeSearchText(decodeHtmlEntities(
2639
+ String(html || '')
2640
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
2641
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
2642
+ .replace(/<!--[\s\S]*?-->/g, ' ')
2643
+ .replace(/<[^>]+>/g, ' '),
2644
+ ));
2645
+ }
2646
+
2647
+ function decodeHtmlEntities(value) {
2648
+ const namedEntities = {
2649
+ amp: '&',
2650
+ lt: '<',
2651
+ gt: '>',
2652
+ quot: '"',
2653
+ apos: "'",
2654
+ nbsp: ' ',
2655
+ };
2656
+
2657
+ return String(value || '').replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]+);/g, (match, entity) => {
2658
+ if (entity.startsWith('#x')) {
2659
+ const codePoint = Number.parseInt(entity.slice(2), 16);
2660
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
2661
+ }
2662
+ if (entity.startsWith('#')) {
2663
+ const codePoint = Number.parseInt(entity.slice(1), 10);
2664
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
2665
+ }
2666
+ return Object.prototype.hasOwnProperty.call(namedEntities, entity) ? namedEntities[entity] : match;
2667
+ });
2668
+ }
2669
+
2670
+ function normalizeSearchText(value) {
2671
+ return String(value || '').replace(/\s+/g, ' ').trim();
2672
+ }
2673
+
2674
+ function buildSearchAdapterJs() {
2675
+ const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
2676
+ return `const FIELD_WEIGHTS = ${fieldWeightsJson};
2677
+ const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
2678
+ const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
2679
+ const DEFAULT_LIMIT = 20;
2680
+ const BM25_K1 = 1.2;
2681
+ const BM25_B = 0.75;
2682
+
2683
+ let preparedIndexPromise;
2684
+
2685
+ export async function preload() {
2686
+ await loadPreparedIndex();
2687
+ }
2688
+
2689
+ export async function search(query, options = {}) {
2690
+ const prepared = await loadPreparedIndex();
2691
+ const terms = tokenize(query);
2692
+ const phrase = normalizeText(query);
2693
+ if (terms.length === 0 && !phrase) {
2694
+ return { results: [] };
2695
+ }
2696
+
2697
+ const hits = [];
2698
+ for (const document of prepared.documents) {
2699
+ const score = scoreDocument(document, terms, phrase, prepared);
2700
+ if (score > 0) {
2701
+ hits.push({ document, score });
2702
+ }
2703
+ }
2704
+
2705
+ const limit = normalizeLimit(options.limit);
2706
+ hits.sort((left, right) => right.score - left.score || left.document.raw.title.localeCompare(right.document.raw.title));
2707
+
2708
+ return {
2709
+ results: hits.slice(0, limit).map((hit) => ({
2710
+ id: hit.document.raw.id,
2711
+ score: Number(hit.score.toFixed(6)),
2712
+ data: async () => buildResultData(hit.document.raw, query),
2713
+ })),
2714
+ };
2715
+ }
2716
+
2717
+ async function loadPreparedIndex() {
2718
+ if (!preparedIndexPromise) {
2719
+ preparedIndexPromise = fetch(new URL('./search.json', import.meta.url))
2720
+ .then((response) => {
2721
+ if (!response.ok) {
2722
+ throw new Error('ZeroPress search index not found');
2723
+ }
2724
+ return response.json();
2725
+ })
2726
+ .then((items) => prepareIndex(Array.isArray(items) ? items : []));
2727
+ }
2728
+
2729
+ return preparedIndexPromise;
2730
+ }
2731
+
2732
+ function prepareIndex(items) {
2733
+ const documents = items.map((item) => prepareDocument(item));
2734
+ const documentFrequencies = new Map();
2735
+ const averageLengths = Object.fromEntries(FIELD_NAMES.map((fieldName) => [fieldName, 1]));
2736
+ const newestPostTime = documents.reduce((newest, document) => {
2737
+ if (document.raw.type !== 'post') {
2738
+ return newest;
2739
+ }
2740
+ return Math.max(newest, document.publishedTime || 0);
2741
+ }, 0);
2742
+
2743
+ for (const document of documents) {
2744
+ const seenTerms = new Set();
2745
+ for (const fieldName of FIELD_NAMES) {
2746
+ for (const term of document.fieldTokens[fieldName]) {
2747
+ seenTerms.add(term);
2748
+ }
2749
+ }
2750
+ for (const term of seenTerms) {
2751
+ documentFrequencies.set(term, (documentFrequencies.get(term) || 0) + 1);
2752
+ }
2753
+ }
2754
+
2755
+ for (const fieldName of FIELD_NAMES) {
2756
+ const total = documents.reduce((sum, document) => sum + document.fieldLengths[fieldName], 0);
2757
+ averageLengths[fieldName] = documents.length > 0 ? Math.max(1, total / documents.length) : 1;
2758
+ }
2759
+
2760
+ return {
2761
+ documents,
2762
+ documentFrequencies,
2763
+ averageLengths,
2764
+ documentCount: documents.length,
2765
+ newestPostTime,
2766
+ };
2767
+ }
2768
+
2769
+ function prepareDocument(item) {
2770
+ const raw = normalizeItem(item);
2771
+ const fields = {
2772
+ title: raw.title,
2773
+ headings: raw.headings.join(' '),
2774
+ tags: raw.tags.join(' '),
2775
+ categories: raw.categories.join(' '),
2776
+ excerpt: raw.excerpt,
2777
+ content_text: raw.content_text,
2778
+ };
2779
+ const fieldTexts = {};
2780
+ const fieldTokens = {};
2781
+ const fieldTermCounts = {};
2782
+ const fieldLengths = {};
2783
+
2784
+ for (const [fieldName, value] of Object.entries(fields)) {
2785
+ const normalizedText = normalizeText(value);
2786
+ const tokens = tokenize(normalizedText);
2787
+ fieldTexts[fieldName] = normalizedText;
2788
+ fieldTokens[fieldName] = tokens;
2789
+ fieldTermCounts[fieldName] = countTerms(tokens);
2790
+ fieldLengths[fieldName] = Math.max(1, tokens.length);
2791
+ }
2792
+
2793
+ return {
2794
+ raw,
2795
+ fieldTexts,
2796
+ fieldTokens,
2797
+ fieldTermCounts,
2798
+ fieldLengths,
2799
+ publishedTime: Date.parse(raw.published_at_iso) || 0,
2800
+ };
2801
+ }
2802
+
2803
+ function normalizeItem(item) {
2804
+ return {
2805
+ id: String(item && item.id || ''),
2806
+ type: item && item.type === 'page' ? 'page' : 'post',
2807
+ title: String(item && item.title || ''),
2808
+ url: String(item && item.url || ''),
2809
+ excerpt: String(item && item.excerpt || ''),
2810
+ headings: Array.isArray(item && item.headings) ? item.headings.map(String) : [],
2811
+ categories: Array.isArray(item && item.categories) ? item.categories.map(String) : [],
2812
+ tags: Array.isArray(item && item.tags) ? item.tags.map(String) : [],
2813
+ published_at_iso: String(item && item.published_at_iso || ''),
2814
+ updated_at_iso: String(item && item.updated_at_iso || ''),
2815
+ content_text: String(item && item.content_text || ''),
2816
+ };
2817
+ }
2818
+
2819
+ function scoreDocument(document, terms, phrase, prepared) {
2820
+ let score = 0;
2821
+ const uniqueTerms = Array.from(new Set(terms));
2822
+
2823
+ for (const term of uniqueTerms) {
2824
+ const documentFrequency = prepared.documentFrequencies.get(term) || 0;
2825
+ const idf = Math.log(1 + (prepared.documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5));
2826
+
2827
+ for (const fieldName of FIELD_NAMES) {
2828
+ const termFrequency = document.fieldTermCounts[fieldName].get(term) || 0;
2829
+ if (termFrequency === 0) {
2830
+ continue;
2831
+ }
2832
+
2833
+ const fieldLength = document.fieldLengths[fieldName];
2834
+ const averageLength = prepared.averageLengths[fieldName];
2835
+ const denominator = termFrequency + BM25_K1 * (1 - BM25_B + BM25_B * (fieldLength / averageLength));
2836
+ score += FIELD_WEIGHTS[fieldName] * idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
2837
+ }
2838
+ }
2839
+
2840
+ if (phrase && phrase.length > 1) {
2841
+ for (const fieldName of FIELD_NAMES) {
2842
+ if (document.fieldTexts[fieldName].includes(phrase)) {
2843
+ score += FIELD_WEIGHTS[fieldName] * 0.6;
2844
+ }
2845
+ }
2846
+ }
2847
+
2848
+ if (score <= 0) {
2849
+ return 0;
2850
+ }
2851
+
2852
+ return score * (1 + recencyBoost(document, prepared.newestPostTime));
2853
+ }
2854
+
2855
+ function recencyBoost(document, newestPostTime) {
2856
+ if (document.raw.type !== 'post' || !document.publishedTime || !newestPostTime) {
2857
+ return 0;
2858
+ }
2859
+ const ageMs = Math.max(0, newestPostTime - document.publishedTime);
2860
+ const halfLifeMs = 180 * 24 * 60 * 60 * 1000;
2861
+ return RECENCY_BOOST_MAX * Math.exp(-ageMs / halfLifeMs);
2862
+ }
2863
+
2864
+ function buildResultData(item, query) {
2865
+ const excerpt = buildExcerpt(item, query);
2866
+ return {
2867
+ url: item.url,
2868
+ excerpt,
2869
+ plain_excerpt: excerpt,
2870
+ meta: {
2871
+ title: item.title,
2872
+ type: item.type,
2873
+ published_at_iso: item.published_at_iso,
2874
+ updated_at_iso: item.updated_at_iso,
2875
+ categories: item.categories,
2876
+ tags: item.tags,
2877
+ },
2878
+ sub_results: [],
2879
+ };
2880
+ }
2881
+
2882
+ function buildExcerpt(item, query) {
2883
+ const explicitExcerpt = String(item.excerpt || '').trim();
2884
+ if (explicitExcerpt) {
2885
+ return explicitExcerpt;
2886
+ }
2887
+
2888
+ const text = String(item.content_text || '').replace(/\\s+/g, ' ').trim();
2889
+ if (!text) {
2890
+ return '';
2891
+ }
2892
+
2893
+ const normalizedText = normalizeText(text);
2894
+ const terms = tokenize(query);
2895
+ const firstMatch = terms.map((term) => normalizedText.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
2896
+ const start = Math.max(0, (firstMatch || 0) - 80);
2897
+ const end = Math.min(text.length, start + 180);
2898
+ const prefix = start > 0 ? '...' : '';
2899
+ const suffix = end < text.length ? '...' : '';
2900
+ return prefix + text.slice(start, end).trim() + suffix;
2901
+ }
2902
+
2903
+ function countTerms(tokens) {
2904
+ const counts = new Map();
2905
+ for (const token of tokens) {
2906
+ counts.set(token, (counts.get(token) || 0) + 1);
2907
+ }
2908
+ return counts;
2909
+ }
2910
+
2911
+ function tokenize(value) {
2912
+ const text = normalizeText(value);
2913
+ if (!text) {
2914
+ return [];
2915
+ }
2916
+
2917
+ const tokens = [];
2918
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2919
+ try {
2920
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
2921
+ for (const part of segmenter.segment(text)) {
2922
+ if (part.isWordLike && isUsefulToken(part.segment)) {
2923
+ tokens.push(part.segment);
2924
+ }
2925
+ }
2926
+ } catch {
2927
+ // Fall through to regex tokenization.
2928
+ }
2929
+ }
2930
+
2931
+ for (const match of text.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
2932
+ if (isUsefulToken(match[0])) {
2933
+ tokens.push(match[0]);
2934
+ }
2935
+ }
2936
+
2937
+ for (const match of text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu)) {
2938
+ tokens.push(...buildNgrams(match[0], 2));
2939
+ }
2940
+
2941
+ return tokens;
2942
+ }
2943
+
2944
+ function buildNgrams(value, size) {
2945
+ const normalized = Array.from(value);
2946
+ if (normalized.length <= size) {
2947
+ return isUsefulToken(value) ? [value] : [];
2948
+ }
2949
+
2950
+ const tokens = [];
2951
+ for (let index = 0; index <= normalized.length - size; index += 1) {
2952
+ tokens.push(normalized.slice(index, index + size).join(''));
2953
+ }
2954
+ tokens.push(value);
2955
+ return tokens;
2956
+ }
2957
+
2958
+ function isUsefulToken(value) {
2959
+ const token = String(value || '').trim();
2960
+ return token.length > 1 || /^\\d$/.test(token);
2961
+ }
2962
+
2963
+ function normalizeText(value) {
2964
+ return String(value || '')
2965
+ .normalize('NFKC')
2966
+ .toLowerCase()
2967
+ .replace(/[\\u2018\\u2019]/g, "'")
2968
+ .replace(/[_-]+/g, ' ')
2969
+ .replace(/\\s+/g, ' ')
2970
+ .trim();
2971
+ }
2972
+
2973
+ function normalizeLimit(value) {
2974
+ return Number.isInteger(value) && value > 0 ? Math.min(value, 100) : DEFAULT_LIMIT;
2975
+ }
2976
+ `;
2977
+ }
2978
+
2979
+ function buildSearchPagefindAdapterJs() {
2980
+ return `let pagefindPromise;
2981
+
2982
+ export async function preload() {
2983
+ if (!pagefindPromise) {
2984
+ pagefindPromise = import(new URL('./pagefind/pagefind.js', import.meta.url).href).then(async (pagefind) => {
2985
+ if (typeof pagefind.options === 'function') {
2986
+ await pagefind.options({ baseUrl: '/' });
2987
+ }
2988
+ return pagefind;
2989
+ });
2990
+ }
2991
+
2992
+ return pagefindPromise;
2993
+ }
2994
+
2995
+ export async function search(query, options = {}) {
2996
+ const pagefind = await preload();
2997
+ const result = await pagefind.search(query, options);
2998
+ const limit = normalizeLimit(options.limit);
2999
+ if (!Array.isArray(result?.results)) {
3000
+ return result;
3001
+ }
3002
+
3003
+ const results = result.results.map(normalizeResult);
3004
+ return {
3005
+ ...result,
3006
+ results: limit ? results.slice(0, limit) : results,
3007
+ };
3008
+ }
3009
+
3010
+ function normalizeResult(result) {
3011
+ if (!result || typeof result.data !== 'function') {
3012
+ return result;
3013
+ }
3014
+
3015
+ return {
3016
+ ...result,
3017
+ data: async () => normalizeResultData(await result.data()),
3018
+ };
3019
+ }
3020
+
3021
+ function normalizeResultData(data) {
3022
+ if (!data || typeof data !== 'object') {
3023
+ return data;
3024
+ }
3025
+
3026
+ return {
3027
+ ...data,
3028
+ url: normalizeUrl(data.url),
3029
+ sub_results: Array.isArray(data.sub_results)
3030
+ ? data.sub_results.map((item) => ({ ...item, url: normalizeUrl(item.url) }))
3031
+ : data.sub_results,
3032
+ };
3033
+ }
3034
+
3035
+ function normalizeUrl(value) {
3036
+ const url = String(value || '');
3037
+ if (url.startsWith('/_zeropress/') && !url.startsWith('/_zeropress/pagefind/')) {
3038
+ return url.replace(/^\\/_zeropress/, '') || '/';
3039
+ }
3040
+ if (url.startsWith('_zeropress/') && !url.startsWith('_zeropress/pagefind/')) {
3041
+ return url.replace(/^_zeropress/, '') || '/';
3042
+ }
3043
+ return url;
3044
+ }
3045
+
3046
+ function normalizeLimit(value) {
3047
+ if (value === undefined || value === null) {
3048
+ return null;
3049
+ }
3050
+
3051
+ const limit = Number(value);
3052
+ if (!Number.isFinite(limit) || limit <= 0) {
3053
+ return null;
3054
+ }
3055
+
3056
+ return Math.floor(limit);
3057
+ }
3058
+ `;
3059
+ }
3060
+
3061
+ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
2401
3062
  const entries = [
2402
- ...(emitted.frontPage
3063
+ ...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
2403
3064
  ? [{
2404
3065
  url: emitted.frontPage.url,
2405
3066
  changefreq: 'daily',
@@ -2436,7 +3097,12 @@ function buildSitemapXml(site, emitted, generatedAt) {
2436
3097
  return ` <url>\n <loc>${loc}</loc>${lastmod}\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
2437
3098
  }).join('\n');
2438
3099
 
2439
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
3100
+ const normalizedStylesheetHref = normalizeOptionalString(stylesheetHref);
3101
+ const stylesheet = normalizedStylesheetHref
3102
+ ? `\n<?xml-stylesheet type="text/xsl" href="${escapeXml(normalizedStylesheetHref)}"?>`
3103
+ : '';
3104
+
3105
+ return `<?xml version="1.0" encoding="UTF-8"?>${stylesheet}\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
2440
3106
  }
2441
3107
 
2442
3108
  function buildFeedXml(site, emitted, generatedAt) {
@@ -2472,6 +3138,10 @@ function shouldGenerateRobotsTxt(options) {
2472
3138
  return options.generateSpecialFiles && options.generateRobotsTxt !== false;
2473
3139
  }
2474
3140
 
3141
+ function shouldGenerateSearchArtifacts(state, options) {
3142
+ return options.generateSpecialFiles && state.previewData.site.search === true;
3143
+ }
3144
+
2475
3145
  function getContentType(assetPath) {
2476
3146
  const ext = assetPath.split('.').pop()?.toLowerCase();
2477
3147
  const contentTypes = {