@zeropress/build-core 0.5.2 → 0.6.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.
@@ -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,24 @@ 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';
38
43
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
39
44
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
40
45
  const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
41
46
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
47
+ const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
42
48
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
43
49
  const RESPONSIVE_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif']);
50
+ const SEARCH_FIELD_WEIGHTS = Object.freeze({
51
+ title: 5,
52
+ headings: 3,
53
+ tags: 2.5,
54
+ categories: 2,
55
+ excerpt: 1.5,
56
+ content_text: 1,
57
+ });
58
+ const SEARCH_RECENCY_BOOST_MAX = 0.15;
44
59
  export async function buildSite(input) {
45
60
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
46
61
  const state = await createBuildState(input, options);
@@ -93,9 +108,17 @@ export async function buildSite(input) {
93
108
  );
94
109
 
95
110
  if (options.generateSpecialFiles) {
111
+ await writeOutput(state.writer, state.summaries, SEARCH_INDEX_OUTPUT_PATH, buildSearchIndexJson(state), 'application/json');
112
+ await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(), 'application/javascript');
96
113
  await maybeRenderNotFoundPage(state);
97
114
  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');
115
+ await writeOutput(
116
+ state.writer,
117
+ state.summaries,
118
+ 'sitemap.xml',
119
+ buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt, options.sitemapStylesheetHref),
120
+ 'application/xml',
121
+ );
99
122
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
100
123
  }
101
124
  if (shouldGenerateRobotsTxt(options)) {
@@ -144,6 +167,7 @@ async function createBuildState(input, options) {
144
167
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
145
168
  customHtml: previewData.custom_html,
146
169
  favicon: previewData.site.favicon,
170
+ exposeGenerator: previewData.site.expose_generator !== false,
147
171
  commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
148
172
  options,
149
173
  generatedAt: new Date(),
@@ -261,6 +285,7 @@ async function renderFrontPage(state, route) {
261
285
  description: page.excerpt,
262
286
  ogType: 'website',
263
287
  image: page.featured_image,
288
+ robotsNoindex: shouldNoindexDocument(page),
264
289
  }),
265
290
  },
266
291
  createRenderContext(state.previewData.site, currentUrl),
@@ -273,6 +298,7 @@ async function renderFrontPage(state, route) {
273
298
  title: page.title,
274
299
  description: page.excerpt,
275
300
  includeInFeed: false,
301
+ includeInSitemap: !isDelistedDocument(page),
276
302
  };
277
303
  return;
278
304
  }
@@ -325,6 +351,7 @@ async function renderPost(state, post) {
325
351
  image: post.featured_image,
326
352
  publishedTime: post.published_at_iso,
327
353
  modifiedTime: post.updated_at_iso,
354
+ robotsNoindex: shouldNoindexDocument(post),
328
355
  }),
329
356
  },
330
357
  createRenderContext(state.previewData.site, currentUrl),
@@ -332,14 +359,16 @@ async function renderPost(state, post) {
332
359
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
333
360
  html = injectSiteCustomizations(html, state);
334
361
  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
- });
362
+ if (!isDelistedDocument(post)) {
363
+ state.emitted.posts.push({
364
+ url: currentUrl,
365
+ title: post.title,
366
+ description: post.excerpt,
367
+ publishedAt: post.published_at_iso,
368
+ updatedAt: post.updated_at_iso,
369
+ status: post.status,
370
+ });
371
+ }
343
372
  }
344
373
 
345
374
  async function renderPage(state, page) {
@@ -362,6 +391,7 @@ async function renderPage(state, page) {
362
391
  description: page.excerpt,
363
392
  ogType: 'website',
364
393
  image: page.featured_image,
394
+ robotsNoindex: shouldNoindexDocument(page),
365
395
  }),
366
396
  },
367
397
  createRenderContext(state.previewData.site, currentUrl),
@@ -374,7 +404,7 @@ async function renderPage(state, page) {
374
404
  title: page.title,
375
405
  description: page.excerpt,
376
406
  status: page.status,
377
- includeInSitemap: page.omit_from_sitemap !== true,
407
+ includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
378
408
  });
379
409
  }
380
410
 
@@ -408,19 +438,27 @@ async function maybeRenderNotFoundPage(state) {
408
438
  function normalizePreviewData(previewData, options = {}) {
409
439
  const normalizedSite = {
410
440
  ...previewData.site,
411
- mediaBaseUrl: normalizeOptionalString(previewData.site.mediaBaseUrl),
412
- mediaDeliveryMode: MEDIA_DELIVERY_MODES.has(previewData.site.mediaDeliveryMode)
413
- ? previewData.site.mediaDeliveryMode
441
+ media_base_url: normalizeOptionalString(previewData.site.media_base_url),
442
+ media_delivery_mode: MEDIA_DELIVERY_MODES.has(previewData.site.media_delivery_mode)
443
+ ? previewData.site.media_delivery_mode
414
444
  : 'none',
415
445
  favicon: normalizeSiteFavicon(previewData.site.favicon || options.favicon),
416
- postsPerPage: Number.isInteger(previewData.site.postsPerPage) && previewData.site.postsPerPage > 0
417
- ? previewData.site.postsPerPage
446
+ posts_per_page: Number.isInteger(previewData.site.posts_per_page) && previewData.site.posts_per_page > 0
447
+ ? previewData.site.posts_per_page
418
448
  : 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,
449
+ datetime_display: DATETIME_DISPLAY_MODES.has(previewData.site.datetime_display)
450
+ ? previewData.site.datetime_display
451
+ : DEFAULT_DATETIME_DISPLAY,
452
+ date_style: DATETIME_STYLES.has(previewData.site.date_style)
453
+ ? previewData.site.date_style
454
+ : DEFAULT_DATE_STYLE,
455
+ time_style: DATETIME_STYLES.has(previewData.site.time_style)
456
+ ? previewData.site.time_style
457
+ : DEFAULT_TIME_STYLE,
421
458
  timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
422
459
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
423
- disallowComments: previewData.site.disallowComments === true,
460
+ disallow_comments: previewData.site.disallow_comments === true,
461
+ expose_generator: previewData.site.expose_generator !== false,
424
462
  indexing: previewData.site.indexing !== false,
425
463
  permalinks: normalizePermalinks(previewData.site.permalinks),
426
464
  front_page: normalizeFrontPage(previewData.site.front_page),
@@ -435,13 +473,13 @@ function normalizePreviewData(previewData, options = {}) {
435
473
  site: normalizedSite,
436
474
  menus: normalizeRecordMap(previewData.menus),
437
475
  collections: normalizeCollections(previewData.collections),
438
- widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
476
+ widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_base_url),
439
477
  custom_css: normalizeCustomCss(previewData.custom_css),
440
478
  custom_html: normalizeCustomHtml(previewData.custom_html),
441
479
  content: {
442
480
  ...previewData.content,
443
481
  authors: previewData.content.authors.map((author) => {
444
- const avatar = normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl);
482
+ const avatar = normalizeMediaField(author.avatar, normalizedSite.media_base_url);
445
483
  const avatarMedia = deriveManagedMedia(avatar, mediaRegistry, normalizedSite);
446
484
  return {
447
485
  ...author,
@@ -451,22 +489,24 @@ function normalizePreviewData(previewData, options = {}) {
451
489
  }),
452
490
  posts: previewData.content.posts
453
491
  .map((post) => {
454
- const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl);
492
+ const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_base_url);
455
493
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
456
494
  return {
457
495
  ...post,
458
496
  published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
459
497
  updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
498
+ discoverability: normalizeDiscoverability(post.discoverability),
460
499
  featured_image: featuredImage,
461
500
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
462
501
  };
463
502
  })
464
503
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
465
504
  pages: previewData.content.pages.map((page) => {
466
- const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.mediaBaseUrl);
505
+ const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_base_url);
467
506
  const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
468
507
  return {
469
508
  ...page,
509
+ discoverability: normalizeDiscoverability(page.discoverability),
470
510
  featured_image: featuredImage,
471
511
  ...(featuredMedia ? { featured_media: featuredMedia } : {}),
472
512
  };
@@ -486,6 +526,18 @@ function normalizeRecordMap(value) {
486
526
  return { ...value };
487
527
  }
488
528
 
529
+ function normalizeDiscoverability(value) {
530
+ return DISCOVERABILITY_VALUES.has(value) ? value : 'default';
531
+ }
532
+
533
+ function isDelistedDocument(document) {
534
+ return document?.discoverability === 'delist';
535
+ }
536
+
537
+ function shouldNoindexDocument(document) {
538
+ return document?.discoverability === 'noindex' || document?.discoverability === 'delist';
539
+ }
540
+
489
541
  function normalizeContentMedia(mediaItems, site) {
490
542
  if (!Array.isArray(mediaItems)) {
491
543
  return [];
@@ -496,7 +548,7 @@ function normalizeContentMedia(mediaItems, site) {
496
548
  if (!item || typeof item !== 'object') {
497
549
  return null;
498
550
  }
499
- const src = normalizeMediaField(item.src, site.mediaBaseUrl);
551
+ const src = normalizeMediaField(item.src, site.media_base_url);
500
552
  const width = Number.isInteger(item.width) && item.width > 0 ? item.width : 0;
501
553
  const height = Number.isInteger(item.height) && item.height > 0 ? item.height : 0;
502
554
  if (!src || !width || !height) {
@@ -538,12 +590,12 @@ function deriveManagedMedia(src, mediaRegistry, site) {
538
590
  }
539
591
 
540
592
  function buildResponsiveImageSrcset(media, site) {
541
- if (site.mediaDeliveryMode !== 'media_domain') {
593
+ if (site.media_delivery_mode !== 'media_domain') {
542
594
  return '';
543
595
  }
544
596
 
545
- const mediaBaseUrl = normalizeOptionalString(site.mediaBaseUrl);
546
- if (!mediaBaseUrl || !isUrlUnderMediaBase(media.src, mediaBaseUrl) || !isResponsiveRasterImage(media.src)) {
597
+ const media_base_url = normalizeOptionalString(site.media_base_url);
598
+ if (!media_base_url || !isUrlUnderMediaBase(media.src, media_base_url) || !isResponsiveRasterImage(media.src)) {
547
599
  return '';
548
600
  }
549
601
 
@@ -570,10 +622,10 @@ function buildResponsiveImageVariantUrl(src, width) {
570
622
  }
571
623
  }
572
624
 
573
- function isUrlUnderMediaBase(src, mediaBaseUrl) {
625
+ function isUrlUnderMediaBase(src, media_base_url) {
574
626
  try {
575
627
  const sourceUrl = new URL(src);
576
- const baseUrl = new URL(mediaBaseUrl);
628
+ const baseUrl = new URL(media_base_url);
577
629
  const basePath = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/`;
578
630
  return sourceUrl.origin === baseUrl.origin && sourceUrl.pathname.startsWith(basePath);
579
631
  } catch {
@@ -610,19 +662,19 @@ function normalizeCollections(collections) {
610
662
  );
611
663
  }
612
664
 
613
- function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
614
- if (!widgetAreas || typeof widgetAreas !== 'object') {
665
+ function normalizeWidgetAreas(widget_areas, media_base_url) {
666
+ if (!widget_areas || typeof widget_areas !== 'object') {
615
667
  return {};
616
668
  }
617
669
 
618
670
  return Object.fromEntries(
619
- Object.entries(widgetAreas).map(([widgetAreaId, widgetArea]) => [
671
+ Object.entries(widget_areas).map(([widgetAreaId, widgetArea]) => [
620
672
  widgetAreaId,
621
673
  {
622
674
  ...widgetArea,
623
675
  name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
624
676
  items: Array.isArray(widgetArea?.items)
625
- ? widgetArea.items.map((item) => normalizeWidgetItem(item, mediaBaseUrl))
677
+ ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_base_url))
626
678
  : [],
627
679
  },
628
680
  ]),
@@ -631,21 +683,15 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
631
683
 
632
684
  function normalizeSiteFooter(footer) {
633
685
  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
686
 
638
687
  return {
639
688
  ...source,
640
689
  copyright_text: normalizeOptionalString(source.copyright_text),
641
- attribution: {
642
- ...attribution,
643
- enabled: attribution.enabled !== false,
644
- },
690
+ attribution: source.attribution !== false,
645
691
  };
646
692
  }
647
693
 
648
- function normalizeWidgetItem(item, mediaBaseUrl) {
694
+ function normalizeWidgetItem(item, media_base_url) {
649
695
  const normalizedItem = {
650
696
  ...item,
651
697
  title: typeof item?.title === 'string' ? item.title.trim() : '',
@@ -656,7 +702,7 @@ function normalizeWidgetItem(item, mediaBaseUrl) {
656
702
  ...normalizedItem,
657
703
  settings: {
658
704
  ...item.settings,
659
- avatar: normalizeMediaField(item.settings.avatar, mediaBaseUrl),
705
+ avatar: normalizeMediaField(item.settings.avatar, media_base_url),
660
706
  },
661
707
  };
662
708
  }
@@ -733,21 +779,21 @@ function normalizeFrontPage(frontPage) {
733
779
  };
734
780
  }
735
781
 
736
- function normalizePostIndex(postIndex) {
737
- if (!postIndex || typeof postIndex !== 'object') {
782
+ function normalizePostIndex(post_index) {
783
+ if (!post_index || typeof post_index !== 'object') {
738
784
  return { ...DEFAULT_POST_INDEX };
739
785
  }
740
786
 
741
787
  return {
742
- enabled: postIndex.enabled !== false,
743
- path: normalizeNonEmptyString(postIndex.path, DEFAULT_POST_INDEX.path),
744
- paginate: postIndex.paginate !== false,
788
+ enabled: post_index.enabled !== false,
789
+ path: normalizeNonEmptyString(post_index.path, DEFAULT_POST_INDEX.path),
790
+ paginate: post_index.paginate !== false,
745
791
  };
746
792
  }
747
793
 
748
794
  function createRenderData(previewData, themeMetadata = {}) {
749
795
  const themeSupportsComments = themeMetadata?.features?.comments === true;
750
- const themeSupportsPostIndex = themeMetadata?.features?.postIndex !== false;
796
+ const themeSupportsPostIndex = themeMetadata?.features?.post_index !== false;
751
797
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
752
798
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
753
799
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -755,8 +801,9 @@ function createRenderData(previewData, themeMetadata = {}) {
755
801
  const tagPostsBySlug = new Map();
756
802
  const categoryCountBySlug = new Map();
757
803
  const tagCountBySlug = new Map();
804
+ const discoverableSourcePosts = previewData.content.posts.filter((post) => !isDelistedDocument(post));
758
805
 
759
- for (const post of previewData.content.posts) {
806
+ for (const post of discoverableSourcePosts) {
760
807
  for (const slug of post.category_slugs) {
761
808
  pushToSlugMap(categoryPostsBySlug, slug, post);
762
809
  categoryCountBySlug.set(slug, (categoryCountBySlug.get(slug) || 0) + 1);
@@ -769,25 +816,34 @@ function createRenderData(previewData, themeMetadata = {}) {
769
816
  }
770
817
 
771
818
  const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
772
- const posts = preparedPosts.map((post, index) => ({
819
+ const discoverablePreparedPosts = preparedPosts.filter((post) => !isDelistedDocument(post));
820
+ const adjacentPostsBySlug = new Map(
821
+ discoverablePreparedPosts.map((post, index) => [post.slug, {
822
+ prev: index > 0 ? buildAdjacentPostSummary(discoverablePreparedPosts[index - 1]) : null,
823
+ next: index < discoverablePreparedPosts.length - 1 ? buildAdjacentPostSummary(discoverablePreparedPosts[index + 1]) : null,
824
+ }]),
825
+ );
826
+ const posts = preparedPosts.map((post) => ({
773
827
  ...post,
774
- prev: index > 0 ? buildAdjacentPostSummary(preparedPosts[index - 1]) : null,
775
- next: index < preparedPosts.length - 1 ? buildAdjacentPostSummary(preparedPosts[index + 1]) : null,
828
+ prev: adjacentPostsBySlug.get(post.slug)?.prev || null,
829
+ next: adjacentPostsBySlug.get(post.slug)?.next || null,
776
830
  }));
777
831
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
778
832
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
779
833
  const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
780
834
  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 === '/') {
835
+ const collections = resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage);
836
+ attachCollectionCursors(posts, pages, collections);
837
+ const post_index = previewData.site.post_index;
838
+ const effectivePostIndexEnabled = post_index.enabled !== false && themeSupportsPostIndex;
839
+ const effectivePostIndexPaginate = effectivePostIndexEnabled && post_index.paginate !== false;
840
+ const post_indexBasePath = normalizeRoutePath(post_index.path || DEFAULT_POST_INDEX.path);
841
+
842
+ if (frontPage.type !== 'theme_index' && effectivePostIndexEnabled && post_indexBasePath === '/') {
787
843
  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
844
  }
789
845
 
790
- const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath);
846
+ const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath);
791
847
  const pageFrontPageSlug = frontPage.type === 'page' ? frontPage.page_slug : '';
792
848
  const preparedPages = pageFrontPageSlug
793
849
  ? pages.filter((page) => page.slug !== pageFrontPageSlug)
@@ -797,22 +853,22 @@ function createRenderData(previewData, themeMetadata = {}) {
797
853
  posts,
798
854
  pages: preparedPages,
799
855
  postBySlug,
800
- collections: resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage),
856
+ collections,
801
857
  taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
802
858
  frontPageRoute,
803
859
  indexRoutes: buildPostIndexRoutes({
804
860
  enabled: effectivePostIndexEnabled,
805
861
  paginate: effectivePostIndexPaginate,
806
- items: previewData.content.posts,
807
- postsPerPage: previewData.site.postsPerPage,
808
- basePath: postIndexBasePath,
862
+ items: discoverableSourcePosts,
863
+ posts_per_page: previewData.site.posts_per_page,
864
+ basePath: post_indexBasePath,
809
865
  outputStyle: previewData.site.permalinks.output_style,
810
866
  postBySlug,
811
867
  frontPage,
812
868
  }),
813
869
  archiveRoutes: buildPaginatedCollection({
814
- items: previewData.content.posts,
815
- postsPerPage: previewData.site.postsPerPage,
870
+ items: discoverableSourcePosts,
871
+ posts_per_page: previewData.site.posts_per_page,
816
872
  basePath: '/archive/',
817
873
  outputStyle: previewData.site.permalinks.output_style,
818
874
  }).map((entry) => ({
@@ -828,7 +884,7 @@ function createRenderData(previewData, themeMetadata = {}) {
828
884
  items: previewData.content.categories,
829
885
  postsBySlug: categoryPostsBySlug,
830
886
  postBySlug,
831
- postsPerPage: previewData.site.postsPerPage,
887
+ posts_per_page: previewData.site.posts_per_page,
832
888
  outputStyle: previewData.site.permalinks.output_style,
833
889
  buildBasePath: (category) => resolvePermalink(previewData.site, 'categories', category).path,
834
890
  renderExtras: (category) => ({
@@ -839,7 +895,7 @@ function createRenderData(previewData, themeMetadata = {}) {
839
895
  items: previewData.content.tags,
840
896
  postsBySlug: tagPostsBySlug,
841
897
  postBySlug,
842
- postsPerPage: previewData.site.postsPerPage,
898
+ posts_per_page: previewData.site.posts_per_page,
843
899
  outputStyle: previewData.site.permalinks.output_style,
844
900
  buildBasePath: (tag) => resolvePermalink(previewData.site, 'tags', tag).path,
845
901
  renderExtras: (tag) => ({
@@ -862,15 +918,19 @@ function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
862
918
  }
863
919
 
864
920
  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
- ]),
921
+ Object.entries(collections).map(([collectionId, collection]) => {
922
+ const items = resolveCollectionItems(collectionId, collection?.items, postBySlug, pageBySlug, frontPage);
923
+ return [
924
+ collectionId,
925
+ {
926
+ id: collectionId,
927
+ title: normalizeOptionalString(collection?.title),
928
+ description: normalizeOptionalString(collection?.description),
929
+ count: items.length,
930
+ items,
931
+ },
932
+ ];
933
+ }),
874
934
  );
875
935
  }
876
936
 
@@ -916,12 +976,70 @@ function buildCollectionPageSummary(page, frontPage) {
916
976
  featured_image: page.featured_image || '',
917
977
  ...(page.featured_media ? { featured_media: { ...page.featured_media } } : {}),
918
978
  meta: page.meta,
979
+ data: page.data,
980
+ };
981
+ }
982
+
983
+ function attachCollectionCursors(posts, pages, collections) {
984
+ const postTargets = new Map(posts.map((post) => [post.slug, post]));
985
+ const pageTargets = new Map(pages.map((page) => [page.slug, page]));
986
+
987
+ for (const [collectionId, collection] of Object.entries(collections || {})) {
988
+ const items = Array.isArray(collection.items) ? collection.items : [];
989
+
990
+ items.forEach((item, index) => {
991
+ const target = item.type === 'post'
992
+ ? postTargets.get(item.slug)
993
+ : item.type === 'page'
994
+ ? pageTargets.get(item.slug)
995
+ : null;
996
+
997
+ if (!target) {
998
+ return;
999
+ }
1000
+
1001
+ target.collection_cursors = target.collection_cursors || {};
1002
+ target.collection_cursors[collectionId] = buildCollectionCursor(collectionId, collection, items, index);
1003
+ });
1004
+ }
1005
+ }
1006
+
1007
+ function buildCollectionCursor(collectionId, collection, items, index) {
1008
+ const count = items.length;
1009
+
1010
+ return {
1011
+ collection_id: collectionId,
1012
+ collection_title: collection.title || '',
1013
+ index,
1014
+ position: index + 1,
1015
+ count,
1016
+ first: index === 0,
1017
+ last: index === count - 1,
1018
+ prev: index > 0 ? buildCollectionCursorItemSummary(items[index - 1]) : null,
1019
+ next: index < count - 1 ? buildCollectionCursorItemSummary(items[index + 1]) : null,
919
1020
  };
920
1021
  }
921
1022
 
922
- function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath) {
1023
+ function buildCollectionCursorItemSummary(item) {
1024
+ if (!item) {
1025
+ return null;
1026
+ }
1027
+
1028
+ return {
1029
+ type: item.type,
1030
+ title: item.title,
1031
+ slug: item.slug,
1032
+ url: item.url,
1033
+ excerpt: item.excerpt || '',
1034
+ featured_image: item.featured_image || '',
1035
+ meta: item.meta,
1036
+ data: item.data,
1037
+ };
1038
+ }
1039
+
1040
+ function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath) {
923
1041
  if (frontPage.type === 'theme_index') {
924
- if (effectivePostIndexEnabled && postIndexBasePath === '/') {
1042
+ if (effectivePostIndexEnabled && post_indexBasePath === '/') {
925
1043
  return null;
926
1044
  }
927
1045
 
@@ -967,7 +1085,7 @@ function buildPostIndexRoutes(options) {
967
1085
  }
968
1086
 
969
1087
  if (!options.paginate) {
970
- const items = options.items.slice(0, options.postsPerPage);
1088
+ const items = options.items.slice(0, options.posts_per_page);
971
1089
  return [{
972
1090
  path: options.basePath,
973
1091
  route_type: 'post_index',
@@ -982,7 +1100,7 @@ function buildPostIndexRoutes(options) {
982
1100
 
983
1101
  return buildPaginatedCollection({
984
1102
  items: options.items,
985
- postsPerPage: options.postsPerPage,
1103
+ posts_per_page: options.posts_per_page,
986
1104
  basePath: options.basePath,
987
1105
  outputStyle: options.outputStyle,
988
1106
  }).map((entry) => ({
@@ -1073,6 +1191,7 @@ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
1073
1191
  function resolveRecentPostsWidget(baseWidget, settings, renderData) {
1074
1192
  const limit = clampInteger(settings?.limit, 5, 1, 20);
1075
1193
  const items = renderData.posts
1194
+ .filter((post) => !isDelistedDocument(post))
1076
1195
  .slice(0, limit)
1077
1196
  .map((post) => ({
1078
1197
  title: post.title,
@@ -1284,7 +1403,9 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1284
1403
  featured_image: post.featured_image,
1285
1404
  ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1286
1405
  meta: post.meta,
1406
+ data: post.data,
1287
1407
  status: post.status,
1408
+ discoverability: post.discoverability,
1288
1409
  allow_comments: post.allow_comments,
1289
1410
  category_slugs: post.category_slugs,
1290
1411
  tag_slugs: post.tag_slugs,
@@ -1301,7 +1422,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1301
1422
  published_at: formatTimestamp(post.published_at_iso, site),
1302
1423
  updated_at: formatTimestamp(post.updated_at_iso, site),
1303
1424
  reading_time: calculateReadingTime(renderedDocument.html),
1304
- comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
1425
+ comments_enabled: themeSupportsComments && site.disallow_comments !== true && post.allow_comments === true,
1305
1426
  };
1306
1427
  }
1307
1428
 
@@ -1328,7 +1449,7 @@ function buildTaxonomyRoutes(options) {
1328
1449
 
1329
1450
  const paginated = buildPaginatedCollection({
1330
1451
  items: matchedPosts,
1331
- postsPerPage: options.postsPerPage,
1452
+ posts_per_page: options.posts_per_page,
1332
1453
  basePath: options.buildBasePath(item),
1333
1454
  outputStyle: options.outputStyle,
1334
1455
  });
@@ -1352,14 +1473,14 @@ function buildTaxonomyRoutes(options) {
1352
1473
  function buildPaginatedCollection(options) {
1353
1474
  const basePath = normalizePaginationBasePath(options.basePath);
1354
1475
  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;
1476
+ const posts_per_page = Number.isInteger(options.posts_per_page) && options.posts_per_page > 0 ? options.posts_per_page : DEFAULT_POSTS_PER_PAGE;
1356
1477
  const totalPosts = options.items.length;
1357
- const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
1478
+ const totalPages = Math.max(1, Math.ceil(totalPosts / posts_per_page));
1358
1479
  const pages = [];
1359
1480
 
1360
1481
  for (let page = 1; page <= totalPages; page += 1) {
1361
- const start = (page - 1) * postsPerPage;
1362
- const end = start + postsPerPage;
1482
+ const start = (page - 1) * posts_per_page;
1483
+ const end = start + posts_per_page;
1363
1484
 
1364
1485
  pages.push({
1365
1486
  path: buildPaginatedPath(basePath, page),
@@ -1535,6 +1656,8 @@ function buildStructuredPostSummary(post) {
1535
1656
  reading_time: post.reading_time,
1536
1657
  featured_image: post.featured_image,
1537
1658
  ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1659
+ meta: post.meta,
1660
+ data: post.data,
1538
1661
  author: {
1539
1662
  display_name: post.author?.display_name || '',
1540
1663
  avatar: post.author?.avatar || '',
@@ -1557,6 +1680,7 @@ function buildAdjacentPostSummary(post) {
1557
1680
  excerpt: post.excerpt,
1558
1681
  published_at: post.published_at,
1559
1682
  published_at_iso: post.published_at_iso,
1683
+ data: post.data,
1560
1684
  };
1561
1685
  }
1562
1686
 
@@ -1646,50 +1770,25 @@ function formatArchiveLabel(date, site) {
1646
1770
  function formatTimestamp(value, site) {
1647
1771
  const date = toDate(value);
1648
1772
  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;
1773
+ const dateStyle = DATETIME_STYLES.has(site.date_style) ? site.date_style : DEFAULT_DATE_STYLE;
1774
+ const timeStyle = DATETIME_STYLES.has(site.time_style) ? site.time_style : DEFAULT_TIME_STYLE;
1651
1775
  const siteTimezone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
1652
1776
 
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;
1777
+ if (dateStyle === 'none' && timeStyle === 'none') {
1778
+ return '';
1675
1779
  }
1676
1780
 
1677
- const timeParts = new Intl.DateTimeFormat(locale, {
1781
+ const options = {
1678
1782
  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}`;
1783
+ };
1784
+ if (dateStyle !== 'none') {
1785
+ options.dateStyle = dateStyle;
1786
+ }
1787
+ if (timeStyle !== 'none') {
1788
+ options.timeStyle = timeStyle;
1789
+ }
1691
1790
 
1692
- return `${formattedDate} ${formattedTime}`.trim();
1791
+ return new Intl.DateTimeFormat(locale, options).format(date);
1693
1792
  }
1694
1793
 
1695
1794
  function calculateReadingTime(html) {
@@ -1785,11 +1884,12 @@ async function normalizeAndValidateThemePackage(themePackage) {
1785
1884
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
1786
1885
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
1787
1886
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1887
+ ...(themePackage.metadata.links ? { links: themePackage.metadata.links } : {}),
1788
1888
  ...(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 } : {}),
1889
+ ...(themePackage.metadata.menu_slots ? { menu_slots: themePackage.metadata.menu_slots } : {}),
1890
+ ...(themePackage.metadata.widget_areas ? { widget_areas: themePackage.metadata.widget_areas } : {}),
1891
+ ...(themePackage.metadata.site_meta ? { site_meta: themePackage.metadata.site_meta } : {}),
1892
+ ...(themePackage.metadata.collection_slots ? { collection_slots: themePackage.metadata.collection_slots } : {}),
1793
1893
  }));
1794
1894
 
1795
1895
  for (const [templateName, templateContent] of themePackage.templates.entries()) {
@@ -1806,7 +1906,7 @@ async function normalizeAndValidateThemePackage(themePackage) {
1806
1906
 
1807
1907
  const validation = await validateThemeFiles(fileMap);
1808
1908
  if (!validation.ok) {
1809
- throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
1909
+ throw new Error(formatThemeValidationFailure(validation));
1810
1910
  }
1811
1911
 
1812
1912
  if (!validation.manifest) {
@@ -1821,6 +1921,61 @@ async function normalizeAndValidateThemePackage(themePackage) {
1821
1921
  };
1822
1922
  }
1823
1923
 
1924
+ function formatThemeValidationFailure(validation) {
1925
+ const blocks = [
1926
+ [
1927
+ 'Theme validation failed',
1928
+ `Errors: ${validation.errors.length}`,
1929
+ `Checked files: ${validation.checkedFiles}`,
1930
+ ].join('\n'),
1931
+ ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
1932
+ ];
1933
+ return blocks.join('\n\n');
1934
+ }
1935
+
1936
+ function formatThemeValidationIssue(issue) {
1937
+ if (!issue) {
1938
+ return 'Reason: Unknown error';
1939
+ }
1940
+
1941
+ const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
1942
+ const location = splitIssuePath(issue.path);
1943
+ if (location.file) {
1944
+ lines.push(`File: ${location.file}`);
1945
+ }
1946
+ if (location.path) {
1947
+ lines.push(`Path: ${location.path}`);
1948
+ }
1949
+ if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
1950
+ lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
1951
+ }
1952
+ if (issue.category) {
1953
+ lines.push(`Category: ${issue.category}`);
1954
+ }
1955
+ lines.push(`Reason: ${issue.message || 'Unknown error'}`);
1956
+ if (issue.snippet) {
1957
+ const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
1958
+ lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
1959
+ }
1960
+ if (issue.hint) {
1961
+ lines.push('', 'Hint:', issue.hint);
1962
+ }
1963
+
1964
+ return lines.join('\n');
1965
+ }
1966
+
1967
+ function splitIssuePath(issuePath) {
1968
+ const normalizedPath = String(issuePath || '');
1969
+ if (normalizedPath.startsWith('theme.json.')) {
1970
+ return {
1971
+ file: 'theme.json',
1972
+ path: normalizedPath.slice('theme.json.'.length),
1973
+ };
1974
+ }
1975
+
1976
+ return { file: normalizedPath, path: '' };
1977
+ }
1978
+
1824
1979
  function normalizeThemePackageMetadata(sourceMetadata, manifest) {
1825
1980
  return {
1826
1981
  ...manifest,
@@ -1902,6 +2057,7 @@ function buildPageMeta(site, options = {}) {
1902
2057
  const ogType = normalizeNonEmptyString(options.ogType, 'website');
1903
2058
  const publishedTime = normalizeOptionalString(options.publishedTime);
1904
2059
  const modifiedTime = normalizeOptionalString(options.modifiedTime);
2060
+ const robotsNoindex = options.robotsNoindex === true;
1905
2061
 
1906
2062
  const meta = {
1907
2063
  title: escapeHtml(resolvedTitle),
@@ -1915,6 +2071,7 @@ function buildPageMeta(site, options = {}) {
1915
2071
  og_image: ogImage ? escapeHtml(ogImage) : '',
1916
2072
  article_published_time: publishedTime ? escapeHtml(publishedTime) : '',
1917
2073
  article_modified_time: modifiedTime ? escapeHtml(modifiedTime) : '',
2074
+ robots_noindex: robotsNoindex,
1918
2075
  };
1919
2076
 
1920
2077
  return {
@@ -1935,6 +2092,9 @@ function buildMetaHeadTags(meta) {
1935
2092
  if (meta.description) {
1936
2093
  tags.push(`<meta name="description" content="${meta.description}">`);
1937
2094
  }
2095
+ if (meta.robots_noindex) {
2096
+ tags.push('<meta name="robots" content="noindex">');
2097
+ }
1938
2098
  if (meta.canonical_url) {
1939
2099
  tags.push(`<link rel="canonical" href="${meta.canonical_url}">`);
1940
2100
  }
@@ -1982,7 +2142,7 @@ function resolveMetaImageUrl(image) {
1982
2142
  return '';
1983
2143
  }
1984
2144
 
1985
- function normalizeMediaField(value, mediaBaseUrl) {
2145
+ function normalizeMediaField(value, media_base_url) {
1986
2146
  if (value === undefined) {
1987
2147
  return undefined;
1988
2148
  }
@@ -1996,7 +2156,7 @@ function normalizeMediaField(value, mediaBaseUrl) {
1996
2156
  return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
1997
2157
  }
1998
2158
 
1999
- const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
2159
+ const normalizedBaseUrl = normalizeOptionalString(media_base_url);
2000
2160
  if (!normalizedBaseUrl) {
2001
2161
  return normalizedValue;
2002
2162
  }
@@ -2234,6 +2394,7 @@ function assertPlannedOutputPathsSafe(state) {
2234
2394
  ];
2235
2395
 
2236
2396
  if (state.options.generateSpecialFiles) {
2397
+ plannedPaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH);
2237
2398
  plannedPaths.push('404.html');
2238
2399
  if (shouldGenerateRobotsTxt(state.options)) {
2239
2400
  plannedPaths.push('robots.txt');
@@ -2338,6 +2499,7 @@ function sha256(content) {
2338
2499
 
2339
2500
  function injectSiteCustomizations(html, state) {
2340
2501
  let next = injectFaviconLinks(html, state.favicon);
2502
+ next = injectGeneratorMeta(next, state.exposeGenerator);
2341
2503
  next = injectCustomCssAssetLink(next, state.customCssHref);
2342
2504
  next = injectCustomHtml(next, state.customHtml);
2343
2505
  return next;
@@ -2374,6 +2536,14 @@ function buildFaviconLinks(favicon) {
2374
2536
  return lines.join('\n');
2375
2537
  }
2376
2538
 
2539
+ function injectGeneratorMeta(html, exposeGenerator) {
2540
+ if (exposeGenerator === false) {
2541
+ return html;
2542
+ }
2543
+
2544
+ return html.replace('</head>', ' <meta name="generator" content="ZeroPress">\n</head>');
2545
+ }
2546
+
2377
2547
  function injectCustomCssAssetLink(html, href) {
2378
2548
  if (!normalizeOptionalString(href)) {
2379
2549
  return html;
@@ -2397,9 +2567,407 @@ function injectCustomHtml(html, customHtml) {
2397
2567
  return next;
2398
2568
  }
2399
2569
 
2400
- function buildSitemapXml(site, emitted, generatedAt) {
2570
+ function buildSearchIndexJson(state) {
2571
+ return `${JSON.stringify(buildSearchIndexItems(state), null, 2)}\n`;
2572
+ }
2573
+
2574
+ function buildSearchIndexItems(state) {
2575
+ const posts = state.renderData.posts
2576
+ .filter((post) => post.status === 'published' && !isDelistedDocument(post))
2577
+ .map((post) => ({
2578
+ id: `post:${post.slug}`,
2579
+ type: 'post',
2580
+ title: post.title,
2581
+ url: post.url,
2582
+ excerpt: normalizeSearchText(post.excerpt),
2583
+ headings: buildSearchHeadings(post.toc),
2584
+ categories: Array.isArray(post.categories) ? post.categories.map((category) => category.name).filter(Boolean) : [],
2585
+ tags: Array.isArray(post.tags) ? post.tags.map((tag) => tag.name).filter(Boolean) : [],
2586
+ published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
2587
+ updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
2588
+ content_text: htmlToSearchText(post.html),
2589
+ }));
2590
+
2591
+ const frontPagePage = state.renderData.frontPageRoute?.front_page_type === 'page'
2592
+ ? state.renderData.frontPageRoute.page
2593
+ : null;
2594
+ const frontPageItems = frontPagePage && frontPagePage.status === 'published' && !isDelistedDocument(frontPagePage)
2595
+ ? [buildSearchPageItem(frontPagePage, '/')]
2596
+ : [];
2597
+ const pageItems = state.renderData.pages
2598
+ .filter((page) => page.status === 'published' && !isDelistedDocument(page))
2599
+ .map((page) => buildSearchPageItem(page, page.url));
2600
+
2601
+ return [...posts, ...frontPageItems, ...pageItems];
2602
+ }
2603
+
2604
+ function buildSearchPageItem(page, url) {
2605
+ return {
2606
+ id: `page:${page.slug}`,
2607
+ type: 'page',
2608
+ title: page.title,
2609
+ url,
2610
+ excerpt: normalizeSearchText(page.excerpt),
2611
+ headings: buildSearchHeadings(page.toc),
2612
+ categories: [],
2613
+ tags: [],
2614
+ published_at_iso: '',
2615
+ updated_at_iso: '',
2616
+ content_text: htmlToSearchText(page.html),
2617
+ };
2618
+ }
2619
+
2620
+ function buildSearchHeadings(toc) {
2621
+ return Array.isArray(toc)
2622
+ ? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
2623
+ : [];
2624
+ }
2625
+
2626
+ function htmlToSearchText(html) {
2627
+ return normalizeSearchText(decodeHtmlEntities(
2628
+ String(html || '')
2629
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
2630
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
2631
+ .replace(/<!--[\s\S]*?-->/g, ' ')
2632
+ .replace(/<[^>]+>/g, ' '),
2633
+ ));
2634
+ }
2635
+
2636
+ function decodeHtmlEntities(value) {
2637
+ const namedEntities = {
2638
+ amp: '&',
2639
+ lt: '<',
2640
+ gt: '>',
2641
+ quot: '"',
2642
+ apos: "'",
2643
+ nbsp: ' ',
2644
+ };
2645
+
2646
+ return String(value || '').replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]+);/g, (match, entity) => {
2647
+ if (entity.startsWith('#x')) {
2648
+ const codePoint = Number.parseInt(entity.slice(2), 16);
2649
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
2650
+ }
2651
+ if (entity.startsWith('#')) {
2652
+ const codePoint = Number.parseInt(entity.slice(1), 10);
2653
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
2654
+ }
2655
+ return Object.prototype.hasOwnProperty.call(namedEntities, entity) ? namedEntities[entity] : match;
2656
+ });
2657
+ }
2658
+
2659
+ function normalizeSearchText(value) {
2660
+ return String(value || '').replace(/\s+/g, ' ').trim();
2661
+ }
2662
+
2663
+ function buildSearchAdapterJs() {
2664
+ const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
2665
+ return `const FIELD_WEIGHTS = ${fieldWeightsJson};
2666
+ const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
2667
+ const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
2668
+ const DEFAULT_LIMIT = 20;
2669
+ const BM25_K1 = 1.2;
2670
+ const BM25_B = 0.75;
2671
+
2672
+ let preparedIndexPromise;
2673
+
2674
+ export async function preload() {
2675
+ await loadPreparedIndex();
2676
+ }
2677
+
2678
+ export async function search(query, options = {}) {
2679
+ const prepared = await loadPreparedIndex();
2680
+ const terms = tokenize(query);
2681
+ const phrase = normalizeText(query);
2682
+ if (terms.length === 0 && !phrase) {
2683
+ return { results: [] };
2684
+ }
2685
+
2686
+ const hits = [];
2687
+ for (const document of prepared.documents) {
2688
+ const score = scoreDocument(document, terms, phrase, prepared);
2689
+ if (score > 0) {
2690
+ hits.push({ document, score });
2691
+ }
2692
+ }
2693
+
2694
+ const limit = normalizeLimit(options.limit);
2695
+ hits.sort((left, right) => right.score - left.score || left.document.raw.title.localeCompare(right.document.raw.title));
2696
+
2697
+ return {
2698
+ results: hits.slice(0, limit).map((hit) => ({
2699
+ id: hit.document.raw.id,
2700
+ score: Number(hit.score.toFixed(6)),
2701
+ data: async () => buildResultData(hit.document.raw, query),
2702
+ })),
2703
+ };
2704
+ }
2705
+
2706
+ async function loadPreparedIndex() {
2707
+ if (!preparedIndexPromise) {
2708
+ preparedIndexPromise = fetch(new URL('./search.json', import.meta.url))
2709
+ .then((response) => {
2710
+ if (!response.ok) {
2711
+ throw new Error('ZeroPress search index not found');
2712
+ }
2713
+ return response.json();
2714
+ })
2715
+ .then((items) => prepareIndex(Array.isArray(items) ? items : []));
2716
+ }
2717
+
2718
+ return preparedIndexPromise;
2719
+ }
2720
+
2721
+ function prepareIndex(items) {
2722
+ const documents = items.map((item) => prepareDocument(item));
2723
+ const documentFrequencies = new Map();
2724
+ const averageLengths = Object.fromEntries(FIELD_NAMES.map((fieldName) => [fieldName, 1]));
2725
+ const newestPostTime = documents.reduce((newest, document) => {
2726
+ if (document.raw.type !== 'post') {
2727
+ return newest;
2728
+ }
2729
+ return Math.max(newest, document.publishedTime || 0);
2730
+ }, 0);
2731
+
2732
+ for (const document of documents) {
2733
+ const seenTerms = new Set();
2734
+ for (const fieldName of FIELD_NAMES) {
2735
+ for (const term of document.fieldTokens[fieldName]) {
2736
+ seenTerms.add(term);
2737
+ }
2738
+ }
2739
+ for (const term of seenTerms) {
2740
+ documentFrequencies.set(term, (documentFrequencies.get(term) || 0) + 1);
2741
+ }
2742
+ }
2743
+
2744
+ for (const fieldName of FIELD_NAMES) {
2745
+ const total = documents.reduce((sum, document) => sum + document.fieldLengths[fieldName], 0);
2746
+ averageLengths[fieldName] = documents.length > 0 ? Math.max(1, total / documents.length) : 1;
2747
+ }
2748
+
2749
+ return {
2750
+ documents,
2751
+ documentFrequencies,
2752
+ averageLengths,
2753
+ documentCount: documents.length,
2754
+ newestPostTime,
2755
+ };
2756
+ }
2757
+
2758
+ function prepareDocument(item) {
2759
+ const raw = normalizeItem(item);
2760
+ const fields = {
2761
+ title: raw.title,
2762
+ headings: raw.headings.join(' '),
2763
+ tags: raw.tags.join(' '),
2764
+ categories: raw.categories.join(' '),
2765
+ excerpt: raw.excerpt,
2766
+ content_text: raw.content_text,
2767
+ };
2768
+ const fieldTexts = {};
2769
+ const fieldTokens = {};
2770
+ const fieldTermCounts = {};
2771
+ const fieldLengths = {};
2772
+
2773
+ for (const [fieldName, value] of Object.entries(fields)) {
2774
+ const normalizedText = normalizeText(value);
2775
+ const tokens = tokenize(normalizedText);
2776
+ fieldTexts[fieldName] = normalizedText;
2777
+ fieldTokens[fieldName] = tokens;
2778
+ fieldTermCounts[fieldName] = countTerms(tokens);
2779
+ fieldLengths[fieldName] = Math.max(1, tokens.length);
2780
+ }
2781
+
2782
+ return {
2783
+ raw,
2784
+ fieldTexts,
2785
+ fieldTokens,
2786
+ fieldTermCounts,
2787
+ fieldLengths,
2788
+ publishedTime: Date.parse(raw.published_at_iso) || 0,
2789
+ };
2790
+ }
2791
+
2792
+ function normalizeItem(item) {
2793
+ return {
2794
+ id: String(item && item.id || ''),
2795
+ type: item && item.type === 'page' ? 'page' : 'post',
2796
+ title: String(item && item.title || ''),
2797
+ url: String(item && item.url || ''),
2798
+ excerpt: String(item && item.excerpt || ''),
2799
+ headings: Array.isArray(item && item.headings) ? item.headings.map(String) : [],
2800
+ categories: Array.isArray(item && item.categories) ? item.categories.map(String) : [],
2801
+ tags: Array.isArray(item && item.tags) ? item.tags.map(String) : [],
2802
+ published_at_iso: String(item && item.published_at_iso || ''),
2803
+ updated_at_iso: String(item && item.updated_at_iso || ''),
2804
+ content_text: String(item && item.content_text || ''),
2805
+ };
2806
+ }
2807
+
2808
+ function scoreDocument(document, terms, phrase, prepared) {
2809
+ let score = 0;
2810
+ const uniqueTerms = Array.from(new Set(terms));
2811
+
2812
+ for (const term of uniqueTerms) {
2813
+ const documentFrequency = prepared.documentFrequencies.get(term) || 0;
2814
+ const idf = Math.log(1 + (prepared.documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5));
2815
+
2816
+ for (const fieldName of FIELD_NAMES) {
2817
+ const termFrequency = document.fieldTermCounts[fieldName].get(term) || 0;
2818
+ if (termFrequency === 0) {
2819
+ continue;
2820
+ }
2821
+
2822
+ const fieldLength = document.fieldLengths[fieldName];
2823
+ const averageLength = prepared.averageLengths[fieldName];
2824
+ const denominator = termFrequency + BM25_K1 * (1 - BM25_B + BM25_B * (fieldLength / averageLength));
2825
+ score += FIELD_WEIGHTS[fieldName] * idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
2826
+ }
2827
+ }
2828
+
2829
+ if (phrase && phrase.length > 1) {
2830
+ for (const fieldName of FIELD_NAMES) {
2831
+ if (document.fieldTexts[fieldName].includes(phrase)) {
2832
+ score += FIELD_WEIGHTS[fieldName] * 0.6;
2833
+ }
2834
+ }
2835
+ }
2836
+
2837
+ if (score <= 0) {
2838
+ return 0;
2839
+ }
2840
+
2841
+ return score * (1 + recencyBoost(document, prepared.newestPostTime));
2842
+ }
2843
+
2844
+ function recencyBoost(document, newestPostTime) {
2845
+ if (document.raw.type !== 'post' || !document.publishedTime || !newestPostTime) {
2846
+ return 0;
2847
+ }
2848
+ const ageMs = Math.max(0, newestPostTime - document.publishedTime);
2849
+ const halfLifeMs = 180 * 24 * 60 * 60 * 1000;
2850
+ return RECENCY_BOOST_MAX * Math.exp(-ageMs / halfLifeMs);
2851
+ }
2852
+
2853
+ function buildResultData(item, query) {
2854
+ const excerpt = buildExcerpt(item, query);
2855
+ return {
2856
+ url: item.url,
2857
+ excerpt,
2858
+ plain_excerpt: excerpt,
2859
+ meta: {
2860
+ title: item.title,
2861
+ type: item.type,
2862
+ published_at_iso: item.published_at_iso,
2863
+ updated_at_iso: item.updated_at_iso,
2864
+ categories: item.categories,
2865
+ tags: item.tags,
2866
+ },
2867
+ sub_results: [],
2868
+ };
2869
+ }
2870
+
2871
+ function buildExcerpt(item, query) {
2872
+ const explicitExcerpt = String(item.excerpt || '').trim();
2873
+ if (explicitExcerpt) {
2874
+ return explicitExcerpt;
2875
+ }
2876
+
2877
+ const text = String(item.content_text || '').replace(/\\s+/g, ' ').trim();
2878
+ if (!text) {
2879
+ return '';
2880
+ }
2881
+
2882
+ const normalizedText = normalizeText(text);
2883
+ const terms = tokenize(query);
2884
+ const firstMatch = terms.map((term) => normalizedText.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
2885
+ const start = Math.max(0, (firstMatch || 0) - 80);
2886
+ const end = Math.min(text.length, start + 180);
2887
+ const prefix = start > 0 ? '...' : '';
2888
+ const suffix = end < text.length ? '...' : '';
2889
+ return prefix + text.slice(start, end).trim() + suffix;
2890
+ }
2891
+
2892
+ function countTerms(tokens) {
2893
+ const counts = new Map();
2894
+ for (const token of tokens) {
2895
+ counts.set(token, (counts.get(token) || 0) + 1);
2896
+ }
2897
+ return counts;
2898
+ }
2899
+
2900
+ function tokenize(value) {
2901
+ const text = normalizeText(value);
2902
+ if (!text) {
2903
+ return [];
2904
+ }
2905
+
2906
+ const tokens = [];
2907
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2908
+ try {
2909
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
2910
+ for (const part of segmenter.segment(text)) {
2911
+ if (part.isWordLike && isUsefulToken(part.segment)) {
2912
+ tokens.push(part.segment);
2913
+ }
2914
+ }
2915
+ } catch {
2916
+ // Fall through to regex tokenization.
2917
+ }
2918
+ }
2919
+
2920
+ for (const match of text.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
2921
+ if (isUsefulToken(match[0])) {
2922
+ tokens.push(match[0]);
2923
+ }
2924
+ }
2925
+
2926
+ for (const match of text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu)) {
2927
+ tokens.push(...buildNgrams(match[0], 2));
2928
+ }
2929
+
2930
+ return tokens;
2931
+ }
2932
+
2933
+ function buildNgrams(value, size) {
2934
+ const normalized = Array.from(value);
2935
+ if (normalized.length <= size) {
2936
+ return isUsefulToken(value) ? [value] : [];
2937
+ }
2938
+
2939
+ const tokens = [];
2940
+ for (let index = 0; index <= normalized.length - size; index += 1) {
2941
+ tokens.push(normalized.slice(index, index + size).join(''));
2942
+ }
2943
+ tokens.push(value);
2944
+ return tokens;
2945
+ }
2946
+
2947
+ function isUsefulToken(value) {
2948
+ const token = String(value || '').trim();
2949
+ return token.length > 1 || /^\\d$/.test(token);
2950
+ }
2951
+
2952
+ function normalizeText(value) {
2953
+ return String(value || '')
2954
+ .normalize('NFKC')
2955
+ .toLowerCase()
2956
+ .replace(/[\\u2018\\u2019]/g, "'")
2957
+ .replace(/[_-]+/g, ' ')
2958
+ .replace(/\\s+/g, ' ')
2959
+ .trim();
2960
+ }
2961
+
2962
+ function normalizeLimit(value) {
2963
+ return Number.isInteger(value) && value > 0 ? Math.min(value, 100) : DEFAULT_LIMIT;
2964
+ }
2965
+ `;
2966
+ }
2967
+
2968
+ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
2401
2969
  const entries = [
2402
- ...(emitted.frontPage
2970
+ ...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
2403
2971
  ? [{
2404
2972
  url: emitted.frontPage.url,
2405
2973
  changefreq: 'daily',
@@ -2436,7 +3004,12 @@ function buildSitemapXml(site, emitted, generatedAt) {
2436
3004
  return ` <url>\n <loc>${loc}</loc>${lastmod}\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
2437
3005
  }).join('\n');
2438
3006
 
2439
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
3007
+ const normalizedStylesheetHref = normalizeOptionalString(stylesheetHref);
3008
+ const stylesheet = normalizedStylesheetHref
3009
+ ? `\n<?xml-stylesheet type="text/xsl" href="${escapeXml(normalizedStylesheetHref)}"?>`
3010
+ : '';
3011
+
3012
+ 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
3013
  }
2441
3014
 
2442
3015
  function buildFeedXml(site, emitted, generatedAt) {