@zeropress/build-core 0.5.1 → 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.
@@ -9,14 +9,18 @@ import { ZeroPressEngine } from '../render/zeropress-engine.js';
9
9
  const DEFAULT_OPTIONS = {
10
10
  assetHashing: true,
11
11
  generateSpecialFiles: true,
12
+ generateRobotsTxt: true,
12
13
  writeManifest: false,
13
14
  };
14
15
 
15
16
  const DEFAULT_POSTS_PER_PAGE = 10;
16
- const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
17
- const DEFAULT_TIME_FORMAT = 'HH:mm';
17
+ const DEFAULT_DATETIME_DISPLAY = 'static';
18
+ const DEFAULT_DATE_STYLE = 'medium';
19
+ const DEFAULT_TIME_STYLE = 'none';
18
20
  const DEFAULT_TIMEZONE = 'UTC';
19
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']);
20
24
  const DEFAULT_PERMALINKS = Object.freeze({
21
25
  output_style: 'directory',
22
26
  posts: '/posts/:slug/',
@@ -34,9 +38,24 @@ const DEFAULT_POST_INDEX = Object.freeze({
34
38
  });
35
39
  const PERMALINK_OUTPUT_STYLES = new Set(['directory', 'html-extension']);
36
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';
37
43
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
38
44
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
39
45
  const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
46
+ const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
47
+ const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
48
+ const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
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;
40
59
  export async function buildSite(input) {
41
60
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
42
61
  const state = await createBuildState(input, options);
@@ -89,12 +108,22 @@ export async function buildSite(input) {
89
108
  );
90
109
 
91
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');
92
113
  await maybeRenderNotFoundPage(state);
93
114
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
94
- 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
+ );
95
122
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
96
123
  }
97
- await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
124
+ if (shouldGenerateRobotsTxt(options)) {
125
+ await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
126
+ }
98
127
  }
99
128
 
100
129
  return finalizeBuildResult(state.writer, state.summaries, options);
@@ -111,7 +140,7 @@ async function createBuildState(input, options) {
111
140
  const engine = new ZeroPressEngine();
112
141
  const assetProcessor = new AssetProcessor();
113
142
  const summaries = [];
114
- const previewData = normalizePreviewData(input.previewData);
143
+ const previewData = normalizePreviewData(input.previewData, options);
115
144
  const renderData = createRenderData(previewData, themePackage.metadata);
116
145
 
117
146
  engine.initialize(themePackage);
@@ -137,6 +166,8 @@ async function createBuildState(input, options) {
137
166
  assetMap,
138
167
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
139
168
  customHtml: previewData.custom_html,
169
+ favicon: previewData.site.favicon,
170
+ exposeGenerator: previewData.site.expose_generator !== false,
140
171
  commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
141
172
  options,
142
173
  generatedAt: new Date(),
@@ -197,6 +228,7 @@ async function renderRoute(state, templateName, route) {
197
228
  {
198
229
  menus: state.previewData.menus,
199
230
  widgets: state.widgets,
231
+ collections: state.renderData.collections,
200
232
  taxonomies: state.renderData.taxonomies,
201
233
  ...route,
202
234
  route: routeContext,
@@ -243,6 +275,7 @@ async function renderFrontPage(state, route) {
243
275
  {
244
276
  menus: state.previewData.menus,
245
277
  widgets: state.widgets,
278
+ collections: state.renderData.collections,
246
279
  taxonomies: state.renderData.taxonomies,
247
280
  page,
248
281
  route: routeContext,
@@ -252,6 +285,7 @@ async function renderFrontPage(state, route) {
252
285
  description: page.excerpt,
253
286
  ogType: 'website',
254
287
  image: page.featured_image,
288
+ robotsNoindex: shouldNoindexDocument(page),
255
289
  }),
256
290
  },
257
291
  createRenderContext(state.previewData.site, currentUrl),
@@ -264,6 +298,7 @@ async function renderFrontPage(state, route) {
264
298
  title: page.title,
265
299
  description: page.excerpt,
266
300
  includeInFeed: false,
301
+ includeInSitemap: !isDelistedDocument(page),
267
302
  };
268
303
  return;
269
304
  }
@@ -273,6 +308,7 @@ async function renderFrontPage(state, route) {
273
308
  {
274
309
  menus: state.previewData.menus,
275
310
  widgets: state.widgets,
311
+ collections: state.renderData.collections,
276
312
  taxonomies: state.renderData.taxonomies,
277
313
  ...route,
278
314
  route: routeContext,
@@ -303,6 +339,7 @@ async function renderPost(state, post) {
303
339
  {
304
340
  menus: state.previewData.menus,
305
341
  widgets: state.widgets,
342
+ collections: state.renderData.collections,
306
343
  taxonomies: state.renderData.taxonomies,
307
344
  post,
308
345
  route: buildRouteContext('post', currentUrl),
@@ -314,6 +351,7 @@ async function renderPost(state, post) {
314
351
  image: post.featured_image,
315
352
  publishedTime: post.published_at_iso,
316
353
  modifiedTime: post.updated_at_iso,
354
+ robotsNoindex: shouldNoindexDocument(post),
317
355
  }),
318
356
  },
319
357
  createRenderContext(state.previewData.site, currentUrl),
@@ -321,14 +359,16 @@ async function renderPost(state, post) {
321
359
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
322
360
  html = injectSiteCustomizations(html, state);
323
361
  await writeOutput(state.writer, state.summaries, routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style), html, 'text/html');
324
- state.emitted.posts.push({
325
- url: currentUrl,
326
- title: post.title,
327
- description: post.excerpt,
328
- publishedAt: post.published_at_iso,
329
- updatedAt: post.updated_at_iso,
330
- status: post.status,
331
- });
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
+ }
332
372
  }
333
373
 
334
374
  async function renderPage(state, page) {
@@ -340,6 +380,7 @@ async function renderPage(state, page) {
340
380
  {
341
381
  menus: state.previewData.menus,
342
382
  widgets: state.widgets,
383
+ collections: state.renderData.collections,
343
384
  taxonomies: state.renderData.taxonomies,
344
385
  page,
345
386
  route: buildRouteContext('page', currentUrl),
@@ -350,6 +391,7 @@ async function renderPage(state, page) {
350
391
  description: page.excerpt,
351
392
  ogType: 'website',
352
393
  image: page.featured_image,
394
+ robotsNoindex: shouldNoindexDocument(page),
353
395
  }),
354
396
  },
355
397
  createRenderContext(state.previewData.site, currentUrl),
@@ -362,7 +404,7 @@ async function renderPage(state, page) {
362
404
  title: page.title,
363
405
  description: page.excerpt,
364
406
  status: page.status,
365
- includeInSitemap: page.omit_from_sitemap !== true,
407
+ includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
366
408
  });
367
409
  }
368
410
 
@@ -376,6 +418,7 @@ async function maybeRenderNotFoundPage(state) {
376
418
  {
377
419
  menus: state.previewData.menus,
378
420
  widgets: state.widgets,
421
+ collections: state.renderData.collections,
379
422
  taxonomies: state.renderData.taxonomies,
380
423
  route: buildRouteContext('not_found', '/404.html'),
381
424
  meta: buildPageMeta(state.previewData.site, {
@@ -392,67 +435,246 @@ async function maybeRenderNotFoundPage(state) {
392
435
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
393
436
  }
394
437
 
395
- function normalizePreviewData(previewData) {
438
+ function normalizePreviewData(previewData, options = {}) {
396
439
  const normalizedSite = {
397
440
  ...previewData.site,
398
- mediaBaseUrl: normalizeOptionalString(previewData.site.mediaBaseUrl),
399
- postsPerPage: Number.isInteger(previewData.site.postsPerPage) && previewData.site.postsPerPage > 0
400
- ? previewData.site.postsPerPage
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
444
+ : 'none',
445
+ favicon: normalizeSiteFavicon(previewData.site.favicon || options.favicon),
446
+ posts_per_page: Number.isInteger(previewData.site.posts_per_page) && previewData.site.posts_per_page > 0
447
+ ? previewData.site.posts_per_page
401
448
  : DEFAULT_POSTS_PER_PAGE,
402
- dateFormat: normalizeNonEmptyString(previewData.site.dateFormat, DEFAULT_DATE_FORMAT),
403
- 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,
404
458
  timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
405
459
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
406
- disallowComments: previewData.site.disallowComments === true,
460
+ disallow_comments: previewData.site.disallow_comments === true,
461
+ expose_generator: previewData.site.expose_generator !== false,
462
+ indexing: previewData.site.indexing !== false,
407
463
  permalinks: normalizePermalinks(previewData.site.permalinks),
408
464
  front_page: normalizeFrontPage(previewData.site.front_page),
409
465
  post_index: normalizePostIndex(previewData.site.post_index),
410
466
  footer: normalizeSiteFooter(previewData.site.footer),
411
467
  };
468
+ const media = normalizeContentMedia(previewData.content.media, normalizedSite);
469
+ const mediaRegistry = buildMediaRegistry(media);
412
470
 
413
471
  return {
414
472
  ...previewData,
415
473
  site: normalizedSite,
416
- widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
474
+ menus: normalizeRecordMap(previewData.menus),
475
+ collections: normalizeCollections(previewData.collections),
476
+ widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.media_base_url),
417
477
  custom_css: normalizeCustomCss(previewData.custom_css),
418
478
  custom_html: normalizeCustomHtml(previewData.custom_html),
419
479
  content: {
420
480
  ...previewData.content,
421
- authors: previewData.content.authors.map((author) => ({
422
- ...author,
423
- avatar: normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl),
424
- })),
481
+ authors: previewData.content.authors.map((author) => {
482
+ const avatar = normalizeMediaField(author.avatar, normalizedSite.media_base_url);
483
+ const avatarMedia = deriveManagedMedia(avatar, mediaRegistry, normalizedSite);
484
+ return {
485
+ ...author,
486
+ avatar,
487
+ ...(avatarMedia ? { avatar_media: avatarMedia } : {}),
488
+ };
489
+ }),
425
490
  posts: previewData.content.posts
426
- .map((post) => ({
427
- ...post,
428
- published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
429
- updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
430
- featured_image: normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl),
431
- }))
491
+ .map((post) => {
492
+ const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.media_base_url);
493
+ const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
494
+ return {
495
+ ...post,
496
+ published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
497
+ updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
498
+ discoverability: normalizeDiscoverability(post.discoverability),
499
+ featured_image: featuredImage,
500
+ ...(featuredMedia ? { featured_media: featuredMedia } : {}),
501
+ };
502
+ })
432
503
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
433
- pages: previewData.content.pages.map((page) => ({
434
- ...page,
435
- featured_image: normalizeMediaField(page.featured_image, normalizedSite.mediaBaseUrl),
436
- })),
504
+ pages: previewData.content.pages.map((page) => {
505
+ const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.media_base_url);
506
+ const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
507
+ return {
508
+ ...page,
509
+ discoverability: normalizeDiscoverability(page.discoverability),
510
+ featured_image: featuredImage,
511
+ ...(featuredMedia ? { featured_media: featuredMedia } : {}),
512
+ };
513
+ }),
437
514
  categories: [...previewData.content.categories],
438
515
  tags: [...previewData.content.tags],
516
+ media,
439
517
  },
440
518
  };
441
519
  }
442
520
 
443
- function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
444
- if (!widgetAreas || typeof widgetAreas !== 'object') {
521
+ function normalizeRecordMap(value) {
522
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
523
+ return {};
524
+ }
525
+
526
+ return { ...value };
527
+ }
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
+
541
+ function normalizeContentMedia(mediaItems, site) {
542
+ if (!Array.isArray(mediaItems)) {
543
+ return [];
544
+ }
545
+
546
+ return mediaItems
547
+ .map((item) => {
548
+ if (!item || typeof item !== 'object') {
549
+ return null;
550
+ }
551
+ const src = normalizeMediaField(item.src, site.media_base_url);
552
+ const width = Number.isInteger(item.width) && item.width > 0 ? item.width : 0;
553
+ const height = Number.isInteger(item.height) && item.height > 0 ? item.height : 0;
554
+ if (!src || !width || !height) {
555
+ return null;
556
+ }
557
+ return {
558
+ src,
559
+ width,
560
+ height,
561
+ alt: typeof item.alt === 'string' ? item.alt : '',
562
+ };
563
+ })
564
+ .filter(Boolean);
565
+ }
566
+
567
+ function buildMediaRegistry(mediaItems) {
568
+ const registry = new Map();
569
+ for (const item of mediaItems) {
570
+ if (!registry.has(item.src)) {
571
+ registry.set(item.src, item);
572
+ }
573
+ }
574
+ return registry;
575
+ }
576
+
577
+ function deriveManagedMedia(src, mediaRegistry, site) {
578
+ const normalizedSrc = normalizeOptionalString(src);
579
+ if (!normalizedSrc) {
580
+ return null;
581
+ }
582
+ const media = mediaRegistry.get(normalizedSrc);
583
+ if (!media) {
584
+ return null;
585
+ }
586
+ return {
587
+ ...media,
588
+ srcset: buildResponsiveImageSrcset(media, site),
589
+ };
590
+ }
591
+
592
+ function buildResponsiveImageSrcset(media, site) {
593
+ if (site.media_delivery_mode !== 'media_domain') {
594
+ return '';
595
+ }
596
+
597
+ const media_base_url = normalizeOptionalString(site.media_base_url);
598
+ if (!media_base_url || !isUrlUnderMediaBase(media.src, media_base_url) || !isResponsiveRasterImage(media.src)) {
599
+ return '';
600
+ }
601
+
602
+ const widths = RESPONSIVE_IMAGE_WIDTHS.filter((width) => width <= media.width);
603
+ if (!widths.includes(media.width)) {
604
+ widths.push(media.width);
605
+ }
606
+
607
+ return widths
608
+ .filter((width, index, values) => width > 0 && values.indexOf(width) === index)
609
+ .map((width) => `${buildResponsiveImageVariantUrl(media.src, width)} ${width}w`)
610
+ .join(', ');
611
+ }
612
+
613
+ function buildResponsiveImageVariantUrl(src, width) {
614
+ try {
615
+ const url = new URL(src);
616
+ url.searchParams.set('w', String(width));
617
+ url.searchParams.set('fit', 'scale-down');
618
+ url.searchParams.set('format', 'auto');
619
+ return decodeURI(url.toString());
620
+ } catch {
621
+ return src;
622
+ }
623
+ }
624
+
625
+ function isUrlUnderMediaBase(src, media_base_url) {
626
+ try {
627
+ const sourceUrl = new URL(src);
628
+ const baseUrl = new URL(media_base_url);
629
+ const basePath = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/`;
630
+ return sourceUrl.origin === baseUrl.origin && sourceUrl.pathname.startsWith(basePath);
631
+ } catch {
632
+ return false;
633
+ }
634
+ }
635
+
636
+ function isResponsiveRasterImage(src) {
637
+ try {
638
+ const url = new URL(src);
639
+ const lastSegment = url.pathname.split('/').pop() || '';
640
+ const extension = lastSegment.includes('.') ? lastSegment.split('.').pop().toLowerCase() : '';
641
+ return RESPONSIVE_IMAGE_EXTENSIONS.has(extension);
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+
647
+ function normalizeCollections(collections) {
648
+ if (!collections || typeof collections !== 'object' || Array.isArray(collections)) {
649
+ return {};
650
+ }
651
+
652
+ return Object.fromEntries(
653
+ Object.entries(collections).map(([collectionId, collection]) => [
654
+ collectionId,
655
+ {
656
+ ...collection,
657
+ title: normalizeOptionalString(collection?.title),
658
+ description: normalizeOptionalString(collection?.description),
659
+ items: Array.isArray(collection?.items) ? collection.items.map((item) => ({ ...item })) : [],
660
+ },
661
+ ]),
662
+ );
663
+ }
664
+
665
+ function normalizeWidgetAreas(widget_areas, media_base_url) {
666
+ if (!widget_areas || typeof widget_areas !== 'object') {
445
667
  return {};
446
668
  }
447
669
 
448
670
  return Object.fromEntries(
449
- Object.entries(widgetAreas).map(([widgetAreaId, widgetArea]) => [
671
+ Object.entries(widget_areas).map(([widgetAreaId, widgetArea]) => [
450
672
  widgetAreaId,
451
673
  {
452
674
  ...widgetArea,
453
675
  name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
454
676
  items: Array.isArray(widgetArea?.items)
455
- ? widgetArea.items.map((item) => normalizeWidgetItem(item, mediaBaseUrl))
677
+ ? widgetArea.items.map((item) => normalizeWidgetItem(item, media_base_url))
456
678
  : [],
457
679
  },
458
680
  ]),
@@ -461,21 +683,15 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
461
683
 
462
684
  function normalizeSiteFooter(footer) {
463
685
  const source = footer && typeof footer === 'object' && !Array.isArray(footer) ? footer : {};
464
- const attribution = source.attribution && typeof source.attribution === 'object' && !Array.isArray(source.attribution)
465
- ? source.attribution
466
- : {};
467
686
 
468
687
  return {
469
688
  ...source,
470
689
  copyright_text: normalizeOptionalString(source.copyright_text),
471
- attribution: {
472
- ...attribution,
473
- enabled: attribution.enabled !== false,
474
- },
690
+ attribution: source.attribution !== false,
475
691
  };
476
692
  }
477
693
 
478
- function normalizeWidgetItem(item, mediaBaseUrl) {
694
+ function normalizeWidgetItem(item, media_base_url) {
479
695
  const normalizedItem = {
480
696
  ...item,
481
697
  title: typeof item?.title === 'string' ? item.title.trim() : '',
@@ -486,7 +702,7 @@ function normalizeWidgetItem(item, mediaBaseUrl) {
486
702
  ...normalizedItem,
487
703
  settings: {
488
704
  ...item.settings,
489
- avatar: normalizeMediaField(item.settings.avatar, mediaBaseUrl),
705
+ avatar: normalizeMediaField(item.settings.avatar, media_base_url),
490
706
  },
491
707
  };
492
708
  }
@@ -516,6 +732,22 @@ function normalizeCustomHtml(customHtml) {
516
732
  };
517
733
  }
518
734
 
735
+ function normalizeSiteFavicon(favicon) {
736
+ if (!favicon || typeof favicon !== 'object') {
737
+ return undefined;
738
+ }
739
+
740
+ const normalized = {};
741
+ for (const key of ['icon', 'svg', 'png', 'apple_touch_icon']) {
742
+ const value = normalizeOptionalString(favicon[key]);
743
+ if (value) {
744
+ normalized[key] = value;
745
+ }
746
+ }
747
+
748
+ return Object.keys(normalized).length ? normalized : undefined;
749
+ }
750
+
519
751
  function normalizePermalinks(permalinks) {
520
752
  const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
521
753
  const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
@@ -547,21 +779,21 @@ function normalizeFrontPage(frontPage) {
547
779
  };
548
780
  }
549
781
 
550
- function normalizePostIndex(postIndex) {
551
- if (!postIndex || typeof postIndex !== 'object') {
782
+ function normalizePostIndex(post_index) {
783
+ if (!post_index || typeof post_index !== 'object') {
552
784
  return { ...DEFAULT_POST_INDEX };
553
785
  }
554
786
 
555
787
  return {
556
- enabled: postIndex.enabled !== false,
557
- path: normalizeNonEmptyString(postIndex.path, DEFAULT_POST_INDEX.path),
558
- 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,
559
791
  };
560
792
  }
561
793
 
562
794
  function createRenderData(previewData, themeMetadata = {}) {
563
795
  const themeSupportsComments = themeMetadata?.features?.comments === true;
564
- const themeSupportsPostIndex = themeMetadata?.features?.postIndex !== false;
796
+ const themeSupportsPostIndex = themeMetadata?.features?.post_index !== false;
565
797
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
566
798
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
567
799
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -569,8 +801,9 @@ function createRenderData(previewData, themeMetadata = {}) {
569
801
  const tagPostsBySlug = new Map();
570
802
  const categoryCountBySlug = new Map();
571
803
  const tagCountBySlug = new Map();
804
+ const discoverableSourcePosts = previewData.content.posts.filter((post) => !isDelistedDocument(post));
572
805
 
573
- for (const post of previewData.content.posts) {
806
+ for (const post of discoverableSourcePosts) {
574
807
  for (const slug of post.category_slugs) {
575
808
  pushToSlugMap(categoryPostsBySlug, slug, post);
576
809
  categoryCountBySlug.set(slug, (categoryCountBySlug.get(slug) || 0) + 1);
@@ -583,24 +816,34 @@ function createRenderData(previewData, themeMetadata = {}) {
583
816
  }
584
817
 
585
818
  const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
586
- 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) => ({
587
827
  ...post,
588
- prev: index > 0 ? buildAdjacentPostSummary(preparedPosts[index - 1]) : null,
589
- 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,
590
830
  }));
591
831
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
592
832
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
833
+ const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
593
834
  const frontPage = previewData.site.front_page;
594
- const postIndex = previewData.site.post_index;
595
- const effectivePostIndexEnabled = postIndex.enabled !== false && themeSupportsPostIndex;
596
- const effectivePostIndexPaginate = effectivePostIndexEnabled && postIndex.paginate !== false;
597
- const postIndexBasePath = normalizeRoutePath(postIndex.path || DEFAULT_POST_INDEX.path);
598
-
599
- 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 === '/') {
600
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.');
601
844
  }
602
845
 
603
- const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath);
846
+ const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, post_indexBasePath);
604
847
  const pageFrontPageSlug = frontPage.type === 'page' ? frontPage.page_slug : '';
605
848
  const preparedPages = pageFrontPageSlug
606
849
  ? pages.filter((page) => page.slug !== pageFrontPageSlug)
@@ -610,21 +853,22 @@ function createRenderData(previewData, themeMetadata = {}) {
610
853
  posts,
611
854
  pages: preparedPages,
612
855
  postBySlug,
856
+ collections,
613
857
  taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
614
858
  frontPageRoute,
615
859
  indexRoutes: buildPostIndexRoutes({
616
860
  enabled: effectivePostIndexEnabled,
617
861
  paginate: effectivePostIndexPaginate,
618
- items: previewData.content.posts,
619
- postsPerPage: previewData.site.postsPerPage,
620
- basePath: postIndexBasePath,
862
+ items: discoverableSourcePosts,
863
+ posts_per_page: previewData.site.posts_per_page,
864
+ basePath: post_indexBasePath,
621
865
  outputStyle: previewData.site.permalinks.output_style,
622
866
  postBySlug,
623
867
  frontPage,
624
868
  }),
625
869
  archiveRoutes: buildPaginatedCollection({
626
- items: previewData.content.posts,
627
- postsPerPage: previewData.site.postsPerPage,
870
+ items: discoverableSourcePosts,
871
+ posts_per_page: previewData.site.posts_per_page,
628
872
  basePath: '/archive/',
629
873
  outputStyle: previewData.site.permalinks.output_style,
630
874
  }).map((entry) => ({
@@ -640,7 +884,7 @@ function createRenderData(previewData, themeMetadata = {}) {
640
884
  items: previewData.content.categories,
641
885
  postsBySlug: categoryPostsBySlug,
642
886
  postBySlug,
643
- postsPerPage: previewData.site.postsPerPage,
887
+ posts_per_page: previewData.site.posts_per_page,
644
888
  outputStyle: previewData.site.permalinks.output_style,
645
889
  buildBasePath: (category) => resolvePermalink(previewData.site, 'categories', category).path,
646
890
  renderExtras: (category) => ({
@@ -651,7 +895,7 @@ function createRenderData(previewData, themeMetadata = {}) {
651
895
  items: previewData.content.tags,
652
896
  postsBySlug: tagPostsBySlug,
653
897
  postBySlug,
654
- postsPerPage: previewData.site.postsPerPage,
898
+ posts_per_page: previewData.site.posts_per_page,
655
899
  outputStyle: previewData.site.permalinks.output_style,
656
900
  buildBasePath: (tag) => resolvePermalink(previewData.site, 'tags', tag).path,
657
901
  renderExtras: (tag) => ({
@@ -668,9 +912,134 @@ function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug)
668
912
  };
669
913
  }
670
914
 
671
- function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath) {
915
+ function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
916
+ if (!collections || typeof collections !== 'object') {
917
+ return {};
918
+ }
919
+
920
+ return Object.fromEntries(
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
+ }),
934
+ );
935
+ }
936
+
937
+ function resolveCollectionItems(collectionId, items, postBySlug, pageBySlug, frontPage) {
938
+ if (!Array.isArray(items)) {
939
+ return [];
940
+ }
941
+
942
+ return items.map((item, index) => resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage));
943
+ }
944
+
945
+ function resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage) {
946
+ if (item?.type === 'post') {
947
+ const post = postBySlug.get(item.slug);
948
+ if (!post) {
949
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing post slug "${item.slug}".`);
950
+ }
951
+ return {
952
+ type: 'post',
953
+ meta: post.meta,
954
+ ...buildStructuredPostSummary(post),
955
+ };
956
+ }
957
+
958
+ if (item?.type === 'page') {
959
+ const page = pageBySlug.get(item.slug);
960
+ if (!page) {
961
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing page slug "${item.slug}".`);
962
+ }
963
+ return buildCollectionPageSummary(page, frontPage);
964
+ }
965
+
966
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} has unsupported type "${item?.type}".`);
967
+ }
968
+
969
+ function buildCollectionPageSummary(page, frontPage) {
970
+ return {
971
+ type: 'page',
972
+ title: page.title,
973
+ slug: page.slug,
974
+ url: frontPage?.type === 'page' && frontPage.page_slug === page.slug ? '/' : page.url,
975
+ excerpt: page.excerpt || '',
976
+ featured_image: page.featured_image || '',
977
+ ...(page.featured_media ? { featured_media: { ...page.featured_media } } : {}),
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,
1020
+ };
1021
+ }
1022
+
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) {
672
1041
  if (frontPage.type === 'theme_index') {
673
- if (effectivePostIndexEnabled && postIndexBasePath === '/') {
1042
+ if (effectivePostIndexEnabled && post_indexBasePath === '/') {
674
1043
  return null;
675
1044
  }
676
1045
 
@@ -716,7 +1085,7 @@ function buildPostIndexRoutes(options) {
716
1085
  }
717
1086
 
718
1087
  if (!options.paginate) {
719
- const items = options.items.slice(0, options.postsPerPage);
1088
+ const items = options.items.slice(0, options.posts_per_page);
720
1089
  return [{
721
1090
  path: options.basePath,
722
1091
  route_type: 'post_index',
@@ -731,7 +1100,7 @@ function buildPostIndexRoutes(options) {
731
1100
 
732
1101
  return buildPaginatedCollection({
733
1102
  items: options.items,
734
- postsPerPage: options.postsPerPage,
1103
+ posts_per_page: options.posts_per_page,
735
1104
  basePath: options.basePath,
736
1105
  outputStyle: options.outputStyle,
737
1106
  }).map((entry) => ({
@@ -822,6 +1191,7 @@ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
822
1191
  function resolveRecentPostsWidget(baseWidget, settings, renderData) {
823
1192
  const limit = clampInteger(settings?.limit, 5, 1, 20);
824
1193
  const items = renderData.posts
1194
+ .filter((post) => !isDelistedDocument(post))
825
1195
  .slice(0, limit)
826
1196
  .map((post) => ({
827
1197
  title: post.title,
@@ -1031,8 +1401,11 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1031
1401
  updated_at_iso: post.updated_at_iso,
1032
1402
  author_id: post.author_id,
1033
1403
  featured_image: post.featured_image,
1404
+ ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1034
1405
  meta: post.meta,
1406
+ data: post.data,
1035
1407
  status: post.status,
1408
+ discoverability: post.discoverability,
1036
1409
  allow_comments: post.allow_comments,
1037
1410
  category_slugs: post.category_slugs,
1038
1411
  tag_slugs: post.tag_slugs,
@@ -1040,6 +1413,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1040
1413
  id: post.author_id,
1041
1414
  display_name: normalizeNonEmptyString(author?.display_name, post.author_id),
1042
1415
  avatar: author?.avatar || '',
1416
+ ...(author?.avatar_media ? { avatar_media: { ...author.avatar_media } } : {}),
1043
1417
  },
1044
1418
  categories,
1045
1419
  tags,
@@ -1048,7 +1422,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
1048
1422
  published_at: formatTimestamp(post.published_at_iso, site),
1049
1423
  updated_at: formatTimestamp(post.updated_at_iso, site),
1050
1424
  reading_time: calculateReadingTime(renderedDocument.html),
1051
- comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
1425
+ comments_enabled: themeSupportsComments && site.disallow_comments !== true && post.allow_comments === true,
1052
1426
  };
1053
1427
  }
1054
1428
 
@@ -1075,7 +1449,7 @@ function buildTaxonomyRoutes(options) {
1075
1449
 
1076
1450
  const paginated = buildPaginatedCollection({
1077
1451
  items: matchedPosts,
1078
- postsPerPage: options.postsPerPage,
1452
+ posts_per_page: options.posts_per_page,
1079
1453
  basePath: options.buildBasePath(item),
1080
1454
  outputStyle: options.outputStyle,
1081
1455
  });
@@ -1099,14 +1473,14 @@ function buildTaxonomyRoutes(options) {
1099
1473
  function buildPaginatedCollection(options) {
1100
1474
  const basePath = normalizePaginationBasePath(options.basePath);
1101
1475
  const outputStyle = PERMALINK_OUTPUT_STYLES.has(options.outputStyle) ? options.outputStyle : DEFAULT_PERMALINKS.output_style;
1102
- 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;
1103
1477
  const totalPosts = options.items.length;
1104
- const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
1478
+ const totalPages = Math.max(1, Math.ceil(totalPosts / posts_per_page));
1105
1479
  const pages = [];
1106
1480
 
1107
1481
  for (let page = 1; page <= totalPages; page += 1) {
1108
- const start = (page - 1) * postsPerPage;
1109
- const end = start + postsPerPage;
1482
+ const start = (page - 1) * posts_per_page;
1483
+ const end = start + posts_per_page;
1110
1484
 
1111
1485
  pages.push({
1112
1486
  path: buildPaginatedPath(basePath, page),
@@ -1281,9 +1655,13 @@ function buildStructuredPostSummary(post) {
1281
1655
  published_at_iso: post.published_at_iso,
1282
1656
  reading_time: post.reading_time,
1283
1657
  featured_image: post.featured_image,
1658
+ ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1659
+ meta: post.meta,
1660
+ data: post.data,
1284
1661
  author: {
1285
1662
  display_name: post.author?.display_name || '',
1286
1663
  avatar: post.author?.avatar || '',
1664
+ ...(post.author?.avatar_media ? { avatar_media: { ...post.author.avatar_media } } : {}),
1287
1665
  },
1288
1666
  categories: Array.isArray(post.categories) ? post.categories.map((category) => ({ ...category })) : [],
1289
1667
  tags: Array.isArray(post.tags) ? post.tags.map((tag) => ({ ...tag })) : [],
@@ -1302,6 +1680,7 @@ function buildAdjacentPostSummary(post) {
1302
1680
  excerpt: post.excerpt,
1303
1681
  published_at: post.published_at,
1304
1682
  published_at_iso: post.published_at_iso,
1683
+ data: post.data,
1305
1684
  };
1306
1685
  }
1307
1686
 
@@ -1391,50 +1770,25 @@ function formatArchiveLabel(date, site) {
1391
1770
  function formatTimestamp(value, site) {
1392
1771
  const date = toDate(value);
1393
1772
  const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
1394
- const dateFormat = normalizeNonEmptyString(site.dateFormat, DEFAULT_DATE_FORMAT);
1395
- 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;
1396
1775
  const siteTimezone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
1397
1776
 
1398
- const dateParts = new Intl.DateTimeFormat(locale, {
1399
- timeZone: siteTimezone,
1400
- year: 'numeric',
1401
- month: dateFormat === 'MMMM D, YYYY' ? 'long' : '2-digit',
1402
- day: '2-digit',
1403
- }).formatToParts(date);
1404
-
1405
- const year = dateParts.find((part) => part.type === 'year')?.value || '';
1406
- const month = dateParts.find((part) => part.type === 'month')?.value || '';
1407
- const day = dateParts.find((part) => part.type === 'day')?.value || '';
1408
-
1409
- let formattedDate = `${year}-${month}-${day}`;
1410
- if (dateFormat === 'DD/MM/YYYY') {
1411
- formattedDate = `${day}/${month}/${year}`;
1412
- } else if (dateFormat === 'MM/DD/YYYY') {
1413
- formattedDate = `${month}/${day}/${year}`;
1414
- } else if (dateFormat === 'MMMM D, YYYY') {
1415
- formattedDate = `${month} ${String(Number(day))}, ${year}`;
1416
- }
1417
-
1418
- if (!timeFormat) {
1419
- return formattedDate;
1777
+ if (dateStyle === 'none' && timeStyle === 'none') {
1778
+ return '';
1420
1779
  }
1421
1780
 
1422
- const timeParts = new Intl.DateTimeFormat(locale, {
1781
+ const options = {
1423
1782
  timeZone: siteTimezone,
1424
- hour: '2-digit',
1425
- minute: '2-digit',
1426
- hour12: timeFormat === 'hh:mm A',
1427
- }).formatToParts(date);
1428
-
1429
- const hour = timeParts.find((part) => part.type === 'hour')?.value || '';
1430
- const minute = timeParts.find((part) => part.type === 'minute')?.value || '';
1431
- const dayPeriod = (timeParts.find((part) => part.type === 'dayPeriod')?.value || '').toUpperCase();
1432
-
1433
- const formattedTime = timeFormat === 'hh:mm A'
1434
- ? `${hour}:${minute}${dayPeriod ? ` ${dayPeriod}` : ''}`
1435
- : `${hour}:${minute}`;
1783
+ };
1784
+ if (dateStyle !== 'none') {
1785
+ options.dateStyle = dateStyle;
1786
+ }
1787
+ if (timeStyle !== 'none') {
1788
+ options.timeStyle = timeStyle;
1789
+ }
1436
1790
 
1437
- return `${formattedDate} ${formattedTime}`.trim();
1791
+ return new Intl.DateTimeFormat(locale, options).format(date);
1438
1792
  }
1439
1793
 
1440
1794
  function calculateReadingTime(html) {
@@ -1530,10 +1884,12 @@ async function normalizeAndValidateThemePackage(themePackage) {
1530
1884
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
1531
1885
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
1532
1886
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1887
+ ...(themePackage.metadata.links ? { links: themePackage.metadata.links } : {}),
1533
1888
  ...(themePackage.metadata.features ? { features: themePackage.metadata.features } : {}),
1534
- ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1535
- ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
1536
- settings: themePackage.metadata.settings || {},
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 } : {}),
1537
1893
  }));
1538
1894
 
1539
1895
  for (const [templateName, templateContent] of themePackage.templates.entries()) {
@@ -1550,7 +1906,7 @@ async function normalizeAndValidateThemePackage(themePackage) {
1550
1906
 
1551
1907
  const validation = await validateThemeFiles(fileMap);
1552
1908
  if (!validation.ok) {
1553
- throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
1909
+ throw new Error(formatThemeValidationFailure(validation));
1554
1910
  }
1555
1911
 
1556
1912
  if (!validation.manifest) {
@@ -1565,11 +1921,65 @@ async function normalizeAndValidateThemePackage(themePackage) {
1565
1921
  };
1566
1922
  }
1567
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
+
1568
1979
  function normalizeThemePackageMetadata(sourceMetadata, manifest) {
1569
1980
  return {
1570
1981
  ...manifest,
1571
1982
  ...(sourceMetadata?.thumbnail ? { thumbnail: sourceMetadata.thumbnail } : {}),
1572
- settings: sourceMetadata?.settings || {},
1573
1983
  };
1574
1984
  }
1575
1985
 
@@ -1647,6 +2057,7 @@ function buildPageMeta(site, options = {}) {
1647
2057
  const ogType = normalizeNonEmptyString(options.ogType, 'website');
1648
2058
  const publishedTime = normalizeOptionalString(options.publishedTime);
1649
2059
  const modifiedTime = normalizeOptionalString(options.modifiedTime);
2060
+ const robotsNoindex = options.robotsNoindex === true;
1650
2061
 
1651
2062
  const meta = {
1652
2063
  title: escapeHtml(resolvedTitle),
@@ -1660,6 +2071,7 @@ function buildPageMeta(site, options = {}) {
1660
2071
  og_image: ogImage ? escapeHtml(ogImage) : '',
1661
2072
  article_published_time: publishedTime ? escapeHtml(publishedTime) : '',
1662
2073
  article_modified_time: modifiedTime ? escapeHtml(modifiedTime) : '',
2074
+ robots_noindex: robotsNoindex,
1663
2075
  };
1664
2076
 
1665
2077
  return {
@@ -1680,6 +2092,9 @@ function buildMetaHeadTags(meta) {
1680
2092
  if (meta.description) {
1681
2093
  tags.push(`<meta name="description" content="${meta.description}">`);
1682
2094
  }
2095
+ if (meta.robots_noindex) {
2096
+ tags.push('<meta name="robots" content="noindex">');
2097
+ }
1683
2098
  if (meta.canonical_url) {
1684
2099
  tags.push(`<link rel="canonical" href="${meta.canonical_url}">`);
1685
2100
  }
@@ -1727,7 +2142,7 @@ function resolveMetaImageUrl(image) {
1727
2142
  return '';
1728
2143
  }
1729
2144
 
1730
- function normalizeMediaField(value, mediaBaseUrl) {
2145
+ function normalizeMediaField(value, media_base_url) {
1731
2146
  if (value === undefined) {
1732
2147
  return undefined;
1733
2148
  }
@@ -1741,9 +2156,9 @@ function normalizeMediaField(value, mediaBaseUrl) {
1741
2156
  return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
1742
2157
  }
1743
2158
 
1744
- const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
2159
+ const normalizedBaseUrl = normalizeOptionalString(media_base_url);
1745
2160
  if (!normalizedBaseUrl) {
1746
- return '';
2161
+ return normalizedValue;
1747
2162
  }
1748
2163
 
1749
2164
  try {
@@ -1979,7 +2394,11 @@ function assertPlannedOutputPathsSafe(state) {
1979
2394
  ];
1980
2395
 
1981
2396
  if (state.options.generateSpecialFiles) {
1982
- plannedPaths.push('404.html', 'robots.txt');
2397
+ plannedPaths.push(SEARCH_INDEX_OUTPUT_PATH, SEARCH_ADAPTER_OUTPUT_PATH);
2398
+ plannedPaths.push('404.html');
2399
+ if (shouldGenerateRobotsTxt(state.options)) {
2400
+ plannedPaths.push('robots.txt');
2401
+ }
1983
2402
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
1984
2403
  plannedPaths.push('sitemap.xml', 'feed.xml');
1985
2404
  }
@@ -2079,11 +2498,52 @@ function sha256(content) {
2079
2498
  }
2080
2499
 
2081
2500
  function injectSiteCustomizations(html, state) {
2082
- let next = injectCustomCssAssetLink(html, state.customCssHref);
2501
+ let next = injectFaviconLinks(html, state.favicon);
2502
+ next = injectGeneratorMeta(next, state.exposeGenerator);
2503
+ next = injectCustomCssAssetLink(next, state.customCssHref);
2083
2504
  next = injectCustomHtml(next, state.customHtml);
2084
2505
  return next;
2085
2506
  }
2086
2507
 
2508
+ function injectFaviconLinks(html, favicon) {
2509
+ const links = buildFaviconLinks(favicon);
2510
+ if (!links) {
2511
+ return html;
2512
+ }
2513
+
2514
+ return html.replace('</head>', `${links}\n</head>`);
2515
+ }
2516
+
2517
+ function buildFaviconLinks(favicon) {
2518
+ if (!favicon || typeof favicon !== 'object') {
2519
+ return '';
2520
+ }
2521
+
2522
+ const lines = [];
2523
+ if (normalizeOptionalString(favicon.icon)) {
2524
+ lines.push(` <link rel="icon" href="${escapeHtml(favicon.icon)}" sizes="any">`);
2525
+ }
2526
+ if (normalizeOptionalString(favicon.svg)) {
2527
+ lines.push(` <link rel="icon" href="${escapeHtml(favicon.svg)}" type="image/svg+xml">`);
2528
+ }
2529
+ if (normalizeOptionalString(favicon.png)) {
2530
+ lines.push(` <link rel="icon" href="${escapeHtml(favicon.png)}" type="image/png">`);
2531
+ }
2532
+ if (normalizeOptionalString(favicon.apple_touch_icon)) {
2533
+ lines.push(` <link rel="apple-touch-icon" href="${escapeHtml(favicon.apple_touch_icon)}">`);
2534
+ }
2535
+
2536
+ return lines.join('\n');
2537
+ }
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
+
2087
2547
  function injectCustomCssAssetLink(html, href) {
2088
2548
  if (!normalizeOptionalString(href)) {
2089
2549
  return html;
@@ -2107,9 +2567,407 @@ function injectCustomHtml(html, customHtml) {
2107
2567
  return next;
2108
2568
  }
2109
2569
 
2110
- 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 = '') {
2111
2969
  const entries = [
2112
- ...(emitted.frontPage
2970
+ ...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
2113
2971
  ? [{
2114
2972
  url: emitted.frontPage.url,
2115
2973
  changefreq: 'daily',
@@ -2146,7 +3004,12 @@ function buildSitemapXml(site, emitted, generatedAt) {
2146
3004
  return ` <url>\n <loc>${loc}</loc>${lastmod}\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
2147
3005
  }).join('\n');
2148
3006
 
2149
- 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>`;
2150
3013
  }
2151
3014
 
2152
3015
  function buildFeedXml(site, emitted, generatedAt) {
@@ -2165,13 +3028,23 @@ function buildFeedXml(site, emitted, generatedAt) {
2165
3028
  }
2166
3029
 
2167
3030
  function buildRobotsTxt(site) {
2168
- const lines = ['User-agent: *', 'Allow: /'];
3031
+ const lines = ['User-agent: *'];
3032
+ if (site.indexing === false) {
3033
+ lines.push('Disallow: /');
3034
+ return `${lines.join('\n')}\n`;
3035
+ }
3036
+
3037
+ lines.push('Allow: /');
2169
3038
  if (site.url) {
2170
3039
  lines.push('', `Sitemap: ${resolveSiteUrl(site.url, '/sitemap.xml')}`);
2171
3040
  }
2172
3041
  return `${lines.join('\n')}\n`;
2173
3042
  }
2174
3043
 
3044
+ function shouldGenerateRobotsTxt(options) {
3045
+ return options.generateSpecialFiles && options.generateRobotsTxt !== false;
3046
+ }
3047
+
2175
3048
  function getContentType(assetPath) {
2176
3049
  const ext = assetPath.split('.').pop()?.toLowerCase();
2177
3050
  const contentTypes = {