@zeropress/build-core 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ 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
 
@@ -24,16 +25,31 @@ const DEFAULT_PERMALINKS = Object.freeze({
24
25
  categories: '/categories/:slug/',
25
26
  tags: '/tags/:slug/',
26
27
  });
28
+ const DEFAULT_FRONT_PAGE = Object.freeze({
29
+ type: 'theme_index',
30
+ });
31
+ const DEFAULT_POST_INDEX = Object.freeze({
32
+ enabled: true,
33
+ path: '/',
34
+ paginate: true,
35
+ });
27
36
  const PERMALINK_OUTPUT_STYLES = new Set(['directory', 'html-extension']);
28
37
  const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
29
38
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
30
39
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
31
40
  const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
41
+ const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
42
+ const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
43
+ const RESPONSIVE_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif']);
32
44
  export async function buildSite(input) {
33
45
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
34
46
  const state = await createBuildState(input, options);
35
47
  assertPlannedOutputPathsSafe(state);
36
48
 
49
+ if (state.renderData.frontPageRoute) {
50
+ await renderFrontPage(state, state.renderData.frontPageRoute);
51
+ }
52
+
37
53
  for (const route of state.renderData.indexRoutes) {
38
54
  await renderRoute(state, 'index', route);
39
55
  }
@@ -82,7 +98,9 @@ export async function buildSite(input) {
82
98
  await writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt), 'application/xml');
83
99
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
84
100
  }
85
- await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
101
+ if (shouldGenerateRobotsTxt(options)) {
102
+ await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
103
+ }
86
104
  }
87
105
 
88
106
  return finalizeBuildResult(state.writer, state.summaries, options);
@@ -99,7 +117,7 @@ async function createBuildState(input, options) {
99
117
  const engine = new ZeroPressEngine();
100
118
  const assetProcessor = new AssetProcessor();
101
119
  const summaries = [];
102
- const previewData = normalizePreviewData(input.previewData);
120
+ const previewData = normalizePreviewData(input.previewData, options);
103
121
  const renderData = createRenderData(previewData, themePackage.metadata);
104
122
 
105
123
  engine.initialize(themePackage);
@@ -125,10 +143,12 @@ async function createBuildState(input, options) {
125
143
  assetMap,
126
144
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
127
145
  customHtml: previewData.custom_html,
146
+ favicon: previewData.site.favicon,
128
147
  commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
129
148
  options,
130
149
  generatedAt: new Date(),
131
150
  emitted: {
151
+ frontPage: null,
132
152
  indexRoutes: [],
133
153
  archiveRoutes: [],
134
154
  categoryRoutes: [],
@@ -175,13 +195,19 @@ async function finalizeBuildResult(writer, summaries, options) {
175
195
 
176
196
  async function renderRoute(state, templateName, route) {
177
197
  const currentUrl = routePathToPublicUrl(route.path, state.previewData.site.permalinks.output_style);
198
+ const routeContext = buildRouteContext(route.route_type || templateName, currentUrl, {
199
+ isFrontPage: route.is_front_page === true,
200
+ isPostIndex: route.is_post_index === true,
201
+ });
178
202
  let html = await state.engine.render(
179
203
  templateName,
180
204
  {
181
205
  menus: state.previewData.menus,
182
206
  widgets: state.widgets,
207
+ collections: state.renderData.collections,
183
208
  taxonomies: state.renderData.taxonomies,
184
209
  ...route,
210
+ route: routeContext,
185
211
  meta: buildPageMeta(state.previewData.site, {
186
212
  currentUrl,
187
213
  title: state.previewData.site.title,
@@ -197,6 +223,89 @@ async function renderRoute(state, templateName, route) {
197
223
  recordRouteEmission(state, templateName, route, currentUrl);
198
224
  }
199
225
 
226
+ async function renderFrontPage(state, route) {
227
+ const currentUrl = '/';
228
+ const routeContext = buildRouteContext('front_page', currentUrl, {
229
+ isFrontPage: true,
230
+ isPostIndex: false,
231
+ });
232
+
233
+ if (route.front_page_type === 'standalone_html') {
234
+ await writeOutput(state.writer, state.summaries, 'index.html', route.html, 'text/html');
235
+ state.emitted.frontPage = {
236
+ url: currentUrl,
237
+ title: state.previewData.site.title,
238
+ description: state.previewData.site.description,
239
+ includeInFeed: false,
240
+ };
241
+ return;
242
+ }
243
+
244
+ if (route.front_page_type === 'page') {
245
+ const page = {
246
+ ...route.page,
247
+ url: currentUrl,
248
+ };
249
+ let html = await state.engine.render(
250
+ 'page',
251
+ {
252
+ menus: state.previewData.menus,
253
+ widgets: state.widgets,
254
+ collections: state.renderData.collections,
255
+ taxonomies: state.renderData.taxonomies,
256
+ page,
257
+ route: routeContext,
258
+ meta: buildPageMeta(state.previewData.site, {
259
+ currentUrl,
260
+ title: buildDocumentTitle(page.title, state.previewData.site.title),
261
+ description: page.excerpt,
262
+ ogType: 'website',
263
+ image: page.featured_image,
264
+ }),
265
+ },
266
+ createRenderContext(state.previewData.site, currentUrl),
267
+ );
268
+ html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
269
+ html = injectSiteCustomizations(html, state);
270
+ await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
271
+ state.emitted.frontPage = {
272
+ url: currentUrl,
273
+ title: page.title,
274
+ description: page.excerpt,
275
+ includeInFeed: false,
276
+ };
277
+ return;
278
+ }
279
+
280
+ let html = await state.engine.render(
281
+ 'index',
282
+ {
283
+ menus: state.previewData.menus,
284
+ widgets: state.widgets,
285
+ collections: state.renderData.collections,
286
+ taxonomies: state.renderData.taxonomies,
287
+ ...route,
288
+ route: routeContext,
289
+ meta: buildPageMeta(state.previewData.site, {
290
+ currentUrl,
291
+ title: state.previewData.site.title,
292
+ description: state.previewData.site.description,
293
+ ogType: 'website',
294
+ }),
295
+ },
296
+ createRenderContext(state.previewData.site, currentUrl),
297
+ );
298
+ html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
299
+ html = injectSiteCustomizations(html, state);
300
+ await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
301
+ state.emitted.frontPage = {
302
+ url: currentUrl,
303
+ title: state.previewData.site.title,
304
+ description: state.previewData.site.description,
305
+ includeInFeed: false,
306
+ };
307
+ }
308
+
200
309
  async function renderPost(state, post) {
201
310
  const currentUrl = post.url;
202
311
  let html = await state.engine.render(
@@ -204,8 +313,10 @@ async function renderPost(state, post) {
204
313
  {
205
314
  menus: state.previewData.menus,
206
315
  widgets: state.widgets,
316
+ collections: state.renderData.collections,
207
317
  taxonomies: state.renderData.taxonomies,
208
318
  post,
319
+ route: buildRouteContext('post', currentUrl),
209
320
  meta: buildPageMeta(state.previewData.site, {
210
321
  currentUrl,
211
322
  title: buildDocumentTitle(post.title, state.previewData.site.title),
@@ -233,15 +344,20 @@ async function renderPost(state, post) {
233
344
 
234
345
  async function renderPage(state, page) {
235
346
  const currentUrl = page.url;
347
+ const outputPath = pageToOutputPath(page, state.previewData.site.permalinks.output_style);
348
+ const canonicalUrl = normalizeOptionalString(page.canonical_url) || currentUrl;
236
349
  let html = await state.engine.render(
237
350
  'page',
238
351
  {
239
352
  menus: state.previewData.menus,
240
353
  widgets: state.widgets,
354
+ collections: state.renderData.collections,
241
355
  taxonomies: state.renderData.taxonomies,
242
356
  page,
357
+ route: buildRouteContext('page', currentUrl),
243
358
  meta: buildPageMeta(state.previewData.site, {
244
359
  currentUrl,
360
+ canonicalUrl,
245
361
  title: buildDocumentTitle(page.title, state.previewData.site.title),
246
362
  description: page.excerpt,
247
363
  ogType: 'website',
@@ -252,12 +368,13 @@ async function renderPage(state, page) {
252
368
  );
253
369
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
254
370
  html = injectSiteCustomizations(html, state);
255
- await writeOutput(state.writer, state.summaries, routePathToOutputPath(page.url, state.previewData.site.permalinks.output_style), html, 'text/html');
371
+ await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
256
372
  state.emitted.pages.push({
257
373
  url: currentUrl,
258
374
  title: page.title,
259
375
  description: page.excerpt,
260
376
  status: page.status,
377
+ includeInSitemap: page.omit_from_sitemap !== true,
261
378
  });
262
379
  }
263
380
 
@@ -271,7 +388,9 @@ async function maybeRenderNotFoundPage(state) {
271
388
  {
272
389
  menus: state.previewData.menus,
273
390
  widgets: state.widgets,
391
+ collections: state.renderData.collections,
274
392
  taxonomies: state.renderData.taxonomies,
393
+ route: buildRouteContext('not_found', '/404.html'),
275
394
  meta: buildPageMeta(state.previewData.site, {
276
395
  currentUrl: '/404.html',
277
396
  title: state.previewData.site.title,
@@ -286,10 +405,14 @@ async function maybeRenderNotFoundPage(state) {
286
405
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
287
406
  }
288
407
 
289
- function normalizePreviewData(previewData) {
408
+ function normalizePreviewData(previewData, options = {}) {
290
409
  const normalizedSite = {
291
410
  ...previewData.site,
292
411
  mediaBaseUrl: normalizeOptionalString(previewData.site.mediaBaseUrl),
412
+ mediaDeliveryMode: MEDIA_DELIVERY_MODES.has(previewData.site.mediaDeliveryMode)
413
+ ? previewData.site.mediaDeliveryMode
414
+ : 'none',
415
+ favicon: normalizeSiteFavicon(previewData.site.favicon || options.favicon),
293
416
  postsPerPage: Number.isInteger(previewData.site.postsPerPage) && previewData.site.postsPerPage > 0
294
417
  ? previewData.site.postsPerPage
295
418
  : DEFAULT_POSTS_PER_PAGE,
@@ -298,39 +421,195 @@ function normalizePreviewData(previewData) {
298
421
  timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
299
422
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
300
423
  disallowComments: previewData.site.disallowComments === true,
424
+ indexing: previewData.site.indexing !== false,
301
425
  permalinks: normalizePermalinks(previewData.site.permalinks),
426
+ front_page: normalizeFrontPage(previewData.site.front_page),
427
+ post_index: normalizePostIndex(previewData.site.post_index),
428
+ footer: normalizeSiteFooter(previewData.site.footer),
302
429
  };
430
+ const media = normalizeContentMedia(previewData.content.media, normalizedSite);
431
+ const mediaRegistry = buildMediaRegistry(media);
303
432
 
304
433
  return {
305
434
  ...previewData,
306
435
  site: normalizedSite,
436
+ menus: normalizeRecordMap(previewData.menus),
437
+ collections: normalizeCollections(previewData.collections),
307
438
  widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
308
439
  custom_css: normalizeCustomCss(previewData.custom_css),
309
440
  custom_html: normalizeCustomHtml(previewData.custom_html),
310
441
  content: {
311
442
  ...previewData.content,
312
- authors: previewData.content.authors.map((author) => ({
313
- ...author,
314
- avatar: normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl),
315
- })),
443
+ authors: previewData.content.authors.map((author) => {
444
+ const avatar = normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl);
445
+ const avatarMedia = deriveManagedMedia(avatar, mediaRegistry, normalizedSite);
446
+ return {
447
+ ...author,
448
+ avatar,
449
+ ...(avatarMedia ? { avatar_media: avatarMedia } : {}),
450
+ };
451
+ }),
316
452
  posts: previewData.content.posts
317
- .map((post) => ({
318
- ...post,
319
- published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
320
- updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
321
- featured_image: normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl),
322
- }))
453
+ .map((post) => {
454
+ const featuredImage = normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl);
455
+ const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
456
+ return {
457
+ ...post,
458
+ published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
459
+ updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
460
+ featured_image: featuredImage,
461
+ ...(featuredMedia ? { featured_media: featuredMedia } : {}),
462
+ };
463
+ })
323
464
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
324
- pages: previewData.content.pages.map((page) => ({
325
- ...page,
326
- featured_image: normalizeMediaField(page.featured_image, normalizedSite.mediaBaseUrl),
327
- })),
465
+ pages: previewData.content.pages.map((page) => {
466
+ const featuredImage = normalizeMediaField(page.featured_image, normalizedSite.mediaBaseUrl);
467
+ const featuredMedia = deriveManagedMedia(featuredImage, mediaRegistry, normalizedSite);
468
+ return {
469
+ ...page,
470
+ featured_image: featuredImage,
471
+ ...(featuredMedia ? { featured_media: featuredMedia } : {}),
472
+ };
473
+ }),
328
474
  categories: [...previewData.content.categories],
329
475
  tags: [...previewData.content.tags],
476
+ media,
330
477
  },
331
478
  };
332
479
  }
333
480
 
481
+ function normalizeRecordMap(value) {
482
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
483
+ return {};
484
+ }
485
+
486
+ return { ...value };
487
+ }
488
+
489
+ function normalizeContentMedia(mediaItems, site) {
490
+ if (!Array.isArray(mediaItems)) {
491
+ return [];
492
+ }
493
+
494
+ return mediaItems
495
+ .map((item) => {
496
+ if (!item || typeof item !== 'object') {
497
+ return null;
498
+ }
499
+ const src = normalizeMediaField(item.src, site.mediaBaseUrl);
500
+ const width = Number.isInteger(item.width) && item.width > 0 ? item.width : 0;
501
+ const height = Number.isInteger(item.height) && item.height > 0 ? item.height : 0;
502
+ if (!src || !width || !height) {
503
+ return null;
504
+ }
505
+ return {
506
+ src,
507
+ width,
508
+ height,
509
+ alt: typeof item.alt === 'string' ? item.alt : '',
510
+ };
511
+ })
512
+ .filter(Boolean);
513
+ }
514
+
515
+ function buildMediaRegistry(mediaItems) {
516
+ const registry = new Map();
517
+ for (const item of mediaItems) {
518
+ if (!registry.has(item.src)) {
519
+ registry.set(item.src, item);
520
+ }
521
+ }
522
+ return registry;
523
+ }
524
+
525
+ function deriveManagedMedia(src, mediaRegistry, site) {
526
+ const normalizedSrc = normalizeOptionalString(src);
527
+ if (!normalizedSrc) {
528
+ return null;
529
+ }
530
+ const media = mediaRegistry.get(normalizedSrc);
531
+ if (!media) {
532
+ return null;
533
+ }
534
+ return {
535
+ ...media,
536
+ srcset: buildResponsiveImageSrcset(media, site),
537
+ };
538
+ }
539
+
540
+ function buildResponsiveImageSrcset(media, site) {
541
+ if (site.mediaDeliveryMode !== 'media_domain') {
542
+ return '';
543
+ }
544
+
545
+ const mediaBaseUrl = normalizeOptionalString(site.mediaBaseUrl);
546
+ if (!mediaBaseUrl || !isUrlUnderMediaBase(media.src, mediaBaseUrl) || !isResponsiveRasterImage(media.src)) {
547
+ return '';
548
+ }
549
+
550
+ const widths = RESPONSIVE_IMAGE_WIDTHS.filter((width) => width <= media.width);
551
+ if (!widths.includes(media.width)) {
552
+ widths.push(media.width);
553
+ }
554
+
555
+ return widths
556
+ .filter((width, index, values) => width > 0 && values.indexOf(width) === index)
557
+ .map((width) => `${buildResponsiveImageVariantUrl(media.src, width)} ${width}w`)
558
+ .join(', ');
559
+ }
560
+
561
+ function buildResponsiveImageVariantUrl(src, width) {
562
+ try {
563
+ const url = new URL(src);
564
+ url.searchParams.set('w', String(width));
565
+ url.searchParams.set('fit', 'scale-down');
566
+ url.searchParams.set('format', 'auto');
567
+ return decodeURI(url.toString());
568
+ } catch {
569
+ return src;
570
+ }
571
+ }
572
+
573
+ function isUrlUnderMediaBase(src, mediaBaseUrl) {
574
+ try {
575
+ const sourceUrl = new URL(src);
576
+ const baseUrl = new URL(mediaBaseUrl);
577
+ const basePath = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/`;
578
+ return sourceUrl.origin === baseUrl.origin && sourceUrl.pathname.startsWith(basePath);
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+
584
+ function isResponsiveRasterImage(src) {
585
+ try {
586
+ const url = new URL(src);
587
+ const lastSegment = url.pathname.split('/').pop() || '';
588
+ const extension = lastSegment.includes('.') ? lastSegment.split('.').pop().toLowerCase() : '';
589
+ return RESPONSIVE_IMAGE_EXTENSIONS.has(extension);
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+
595
+ function normalizeCollections(collections) {
596
+ if (!collections || typeof collections !== 'object' || Array.isArray(collections)) {
597
+ return {};
598
+ }
599
+
600
+ return Object.fromEntries(
601
+ Object.entries(collections).map(([collectionId, collection]) => [
602
+ collectionId,
603
+ {
604
+ ...collection,
605
+ title: normalizeOptionalString(collection?.title),
606
+ description: normalizeOptionalString(collection?.description),
607
+ items: Array.isArray(collection?.items) ? collection.items.map((item) => ({ ...item })) : [],
608
+ },
609
+ ]),
610
+ );
611
+ }
612
+
334
613
  function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
335
614
  if (!widgetAreas || typeof widgetAreas !== 'object') {
336
615
  return {};
@@ -350,6 +629,22 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
350
629
  );
351
630
  }
352
631
 
632
+ function normalizeSiteFooter(footer) {
633
+ 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
+
638
+ return {
639
+ ...source,
640
+ copyright_text: normalizeOptionalString(source.copyright_text),
641
+ attribution: {
642
+ ...attribution,
643
+ enabled: attribution.enabled !== false,
644
+ },
645
+ };
646
+ }
647
+
353
648
  function normalizeWidgetItem(item, mediaBaseUrl) {
354
649
  const normalizedItem = {
355
650
  ...item,
@@ -391,6 +686,22 @@ function normalizeCustomHtml(customHtml) {
391
686
  };
392
687
  }
393
688
 
689
+ function normalizeSiteFavicon(favicon) {
690
+ if (!favicon || typeof favicon !== 'object') {
691
+ return undefined;
692
+ }
693
+
694
+ const normalized = {};
695
+ for (const key of ['icon', 'svg', 'png', 'apple_touch_icon']) {
696
+ const value = normalizeOptionalString(favicon[key]);
697
+ if (value) {
698
+ normalized[key] = value;
699
+ }
700
+ }
701
+
702
+ return Object.keys(normalized).length ? normalized : undefined;
703
+ }
704
+
394
705
  function normalizePermalinks(permalinks) {
395
706
  const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
396
707
  const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
@@ -406,8 +717,37 @@ function normalizePermalinks(permalinks) {
406
717
  };
407
718
  }
408
719
 
720
+ function normalizeFrontPage(frontPage) {
721
+ if (!frontPage || typeof frontPage !== 'object') {
722
+ return { ...DEFAULT_FRONT_PAGE };
723
+ }
724
+
725
+ const type = ['theme_index', 'page', 'standalone_html'].includes(frontPage.type)
726
+ ? frontPage.type
727
+ : DEFAULT_FRONT_PAGE.type;
728
+
729
+ return {
730
+ type,
731
+ ...(type === 'page' ? { page_slug: normalizeOptionalString(frontPage.page_slug) } : {}),
732
+ ...(type === 'standalone_html' ? { html: normalizeOptionalRawString(frontPage.html) } : {}),
733
+ };
734
+ }
735
+
736
+ function normalizePostIndex(postIndex) {
737
+ if (!postIndex || typeof postIndex !== 'object') {
738
+ return { ...DEFAULT_POST_INDEX };
739
+ }
740
+
741
+ return {
742
+ enabled: postIndex.enabled !== false,
743
+ path: normalizeNonEmptyString(postIndex.path, DEFAULT_POST_INDEX.path),
744
+ paginate: postIndex.paginate !== false,
745
+ };
746
+ }
747
+
409
748
  function createRenderData(previewData, themeMetadata = {}) {
410
749
  const themeSupportsComments = themeMetadata?.features?.comments === true;
750
+ const themeSupportsPostIndex = themeMetadata?.features?.postIndex !== false;
411
751
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
412
752
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
413
753
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -436,24 +776,40 @@ function createRenderData(previewData, themeMetadata = {}) {
436
776
  }));
437
777
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
438
778
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
779
+ const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
780
+ 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 === '/') {
787
+ 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
+ }
789
+
790
+ const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath);
791
+ const pageFrontPageSlug = frontPage.type === 'page' ? frontPage.page_slug : '';
792
+ const preparedPages = pageFrontPageSlug
793
+ ? pages.filter((page) => page.slug !== pageFrontPageSlug)
794
+ : pages;
439
795
 
440
796
  return {
441
797
  posts,
442
- pages,
798
+ pages: preparedPages,
443
799
  postBySlug,
800
+ collections: resolveCollections(previewData.collections, postBySlug, pageBySlug, frontPage),
444
801
  taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
445
- indexRoutes: buildPaginatedCollection({
802
+ frontPageRoute,
803
+ indexRoutes: buildPostIndexRoutes({
804
+ enabled: effectivePostIndexEnabled,
805
+ paginate: effectivePostIndexPaginate,
446
806
  items: previewData.content.posts,
447
807
  postsPerPage: previewData.site.postsPerPage,
448
- basePath: '/',
808
+ basePath: postIndexBasePath,
449
809
  outputStyle: previewData.site.permalinks.output_style,
450
- }).map((entry) => ({
451
- path: entry.path,
452
- page: entry.page,
453
- totalPages: entry.totalPages,
454
- posts: buildStructuredPostCollection(entry.items, postBySlug),
455
- pagination: buildStructuredPagination(entry.paginationData),
456
- })),
810
+ postBySlug,
811
+ frontPage,
812
+ }),
457
813
  archiveRoutes: buildPaginatedCollection({
458
814
  items: previewData.content.posts,
459
815
  postsPerPage: previewData.site.postsPerPage,
@@ -500,6 +856,147 @@ function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug)
500
856
  };
501
857
  }
502
858
 
859
+ function resolveCollections(collections, postBySlug, pageBySlug, frontPage) {
860
+ if (!collections || typeof collections !== 'object') {
861
+ return {};
862
+ }
863
+
864
+ 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
+ ]),
874
+ );
875
+ }
876
+
877
+ function resolveCollectionItems(collectionId, items, postBySlug, pageBySlug, frontPage) {
878
+ if (!Array.isArray(items)) {
879
+ return [];
880
+ }
881
+
882
+ return items.map((item, index) => resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage));
883
+ }
884
+
885
+ function resolveCollectionItem(collectionId, item, index, postBySlug, pageBySlug, frontPage) {
886
+ if (item?.type === 'post') {
887
+ const post = postBySlug.get(item.slug);
888
+ if (!post) {
889
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing post slug "${item.slug}".`);
890
+ }
891
+ return {
892
+ type: 'post',
893
+ meta: post.meta,
894
+ ...buildStructuredPostSummary(post),
895
+ };
896
+ }
897
+
898
+ if (item?.type === 'page') {
899
+ const page = pageBySlug.get(item.slug);
900
+ if (!page) {
901
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} references missing page slug "${item.slug}".`);
902
+ }
903
+ return buildCollectionPageSummary(page, frontPage);
904
+ }
905
+
906
+ throw new Error(`Invalid collection "${collectionId}": item ${index + 1} has unsupported type "${item?.type}".`);
907
+ }
908
+
909
+ function buildCollectionPageSummary(page, frontPage) {
910
+ return {
911
+ type: 'page',
912
+ title: page.title,
913
+ slug: page.slug,
914
+ url: frontPage?.type === 'page' && frontPage.page_slug === page.slug ? '/' : page.url,
915
+ excerpt: page.excerpt || '',
916
+ featured_image: page.featured_image || '',
917
+ ...(page.featured_media ? { featured_media: { ...page.featured_media } } : {}),
918
+ meta: page.meta,
919
+ };
920
+ }
921
+
922
+ function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath) {
923
+ if (frontPage.type === 'theme_index') {
924
+ if (effectivePostIndexEnabled && postIndexBasePath === '/') {
925
+ return null;
926
+ }
927
+
928
+ return {
929
+ path: '/',
930
+ front_page_type: 'theme_index',
931
+ posts: buildStructuredPostCollection([], new Map()),
932
+ pagination: buildStructuredPagination(buildDisabledPaginationData(0)),
933
+ };
934
+ }
935
+
936
+ if (frontPage.type === 'page') {
937
+ const page = pages.find((entry) => entry.slug === frontPage.page_slug);
938
+ if (!page) {
939
+ throw new Error(`Invalid front page configuration: site.front_page.page_slug "${frontPage.page_slug}" does not match a page.`);
940
+ }
941
+
942
+ return {
943
+ path: '/',
944
+ front_page_type: 'page',
945
+ page,
946
+ };
947
+ }
948
+
949
+ if (frontPage.type === 'standalone_html') {
950
+ if (!normalizeOptionalRawString(frontPage.html)) {
951
+ throw new Error('Invalid front page configuration: site.front_page.html is required for standalone_html.');
952
+ }
953
+
954
+ return {
955
+ path: '/',
956
+ front_page_type: 'standalone_html',
957
+ html: frontPage.html,
958
+ };
959
+ }
960
+
961
+ return null;
962
+ }
963
+
964
+ function buildPostIndexRoutes(options) {
965
+ if (!options.enabled) {
966
+ return [];
967
+ }
968
+
969
+ if (!options.paginate) {
970
+ const items = options.items.slice(0, options.postsPerPage);
971
+ return [{
972
+ path: options.basePath,
973
+ route_type: 'post_index',
974
+ is_front_page: options.basePath === '/' && options.frontPage.type === 'theme_index',
975
+ is_post_index: true,
976
+ page: 1,
977
+ totalPages: 1,
978
+ posts: buildStructuredPostCollection(items, options.postBySlug),
979
+ pagination: buildStructuredPagination(buildDisabledPaginationData(options.items.length)),
980
+ }];
981
+ }
982
+
983
+ return buildPaginatedCollection({
984
+ items: options.items,
985
+ postsPerPage: options.postsPerPage,
986
+ basePath: options.basePath,
987
+ outputStyle: options.outputStyle,
988
+ }).map((entry) => ({
989
+ path: entry.path,
990
+ route_type: 'post_index',
991
+ is_front_page: entry.path === '/' && options.frontPage.type === 'theme_index',
992
+ is_post_index: true,
993
+ page: entry.page,
994
+ totalPages: entry.totalPages,
995
+ posts: buildStructuredPostCollection(entry.items, options.postBySlug),
996
+ pagination: buildStructuredPagination(entry.paginationData),
997
+ }));
998
+ }
999
+
503
1000
  function buildGlobalTaxonomyItems(site, permalinkKind, items, countBySlug) {
504
1001
  return items.map((item) => ({
505
1002
  name: item.name,
@@ -785,6 +1282,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
785
1282
  updated_at_iso: post.updated_at_iso,
786
1283
  author_id: post.author_id,
787
1284
  featured_image: post.featured_image,
1285
+ ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
788
1286
  meta: post.meta,
789
1287
  status: post.status,
790
1288
  allow_comments: post.allow_comments,
@@ -794,6 +1292,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
794
1292
  id: post.author_id,
795
1293
  display_name: normalizeNonEmptyString(author?.display_name, post.author_id),
796
1294
  avatar: author?.avatar || '',
1295
+ ...(author?.avatar_media ? { avatar_media: { ...author.avatar_media } } : {}),
797
1296
  },
798
1297
  categories,
799
1298
  tags,
@@ -904,6 +1403,7 @@ function buildPaginationData(currentPage, totalPages, totalPosts, basePath, outp
904
1403
  const buildPageUrl = (page) => routePathToPublicUrl(buildPaginatedPath(basePath, page), outputStyle);
905
1404
 
906
1405
  return {
1406
+ enabled: true,
907
1407
  currentPage,
908
1408
  totalPages,
909
1409
  totalPosts,
@@ -922,8 +1422,23 @@ function buildPaginationData(currentPage, totalPages, totalPosts, basePath, outp
922
1422
  };
923
1423
  }
924
1424
 
1425
+ function buildDisabledPaginationData(totalPosts) {
1426
+ return {
1427
+ enabled: false,
1428
+ currentPage: 1,
1429
+ totalPages: 1,
1430
+ totalPosts,
1431
+ hasNext: false,
1432
+ hasPrev: false,
1433
+ nextUrl: undefined,
1434
+ prevUrl: undefined,
1435
+ pages: [],
1436
+ };
1437
+ }
1438
+
925
1439
  function buildStructuredPagination(paginationData) {
926
1440
  return {
1441
+ enabled: paginationData.enabled !== false,
927
1442
  current_page: paginationData.currentPage,
928
1443
  total_pages: paginationData.totalPages,
929
1444
  total_items: paginationData.totalPosts,
@@ -1019,9 +1534,11 @@ function buildStructuredPostSummary(post) {
1019
1534
  published_at_iso: post.published_at_iso,
1020
1535
  reading_time: post.reading_time,
1021
1536
  featured_image: post.featured_image,
1537
+ ...(post.featured_media ? { featured_media: { ...post.featured_media } } : {}),
1022
1538
  author: {
1023
1539
  display_name: post.author?.display_name || '',
1024
1540
  avatar: post.author?.avatar || '',
1541
+ ...(post.author?.avatar_media ? { avatar_media: { ...post.author.avatar_media } } : {}),
1025
1542
  },
1026
1543
  categories: Array.isArray(post.categories) ? post.categories.map((category) => ({ ...category })) : [],
1027
1544
  tags: Array.isArray(post.tags) ? post.tags.map((tag) => ({ ...tag })) : [],
@@ -1271,7 +1788,8 @@ async function normalizeAndValidateThemePackage(themePackage) {
1271
1788
  ...(themePackage.metadata.features ? { features: themePackage.metadata.features } : {}),
1272
1789
  ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1273
1790
  ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
1274
- settings: themePackage.metadata.settings || {},
1791
+ ...(themePackage.metadata.siteMeta ? { siteMeta: themePackage.metadata.siteMeta } : {}),
1792
+ ...(themePackage.metadata.collectionSlots ? { collectionSlots: themePackage.metadata.collectionSlots } : {}),
1275
1793
  }));
1276
1794
 
1277
1795
  for (const [templateName, templateContent] of themePackage.templates.entries()) {
@@ -1307,7 +1825,6 @@ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
1307
1825
  return {
1308
1826
  ...manifest,
1309
1827
  ...(sourceMetadata?.thumbnail ? { thumbnail: sourceMetadata.thumbnail } : {}),
1310
- settings: sourceMetadata?.settings || {},
1311
1828
  };
1312
1829
  }
1313
1830
 
@@ -1367,10 +1884,20 @@ function createRenderContext(site, currentUrl) {
1367
1884
  };
1368
1885
  }
1369
1886
 
1887
+ function buildRouteContext(type, url, options = {}) {
1888
+ return {
1889
+ type,
1890
+ is_front_page: options.isFrontPage === true,
1891
+ is_post_index: options.isPostIndex === true,
1892
+ path: url,
1893
+ url,
1894
+ };
1895
+ }
1896
+
1370
1897
  function buildPageMeta(site, options = {}) {
1371
1898
  const resolvedTitle = normalizeNonEmptyString(options.title, site.title);
1372
1899
  const resolvedDescription = normalizeOptionalString(options.description);
1373
- const canonicalUrl = resolveMetaCanonicalUrl(site, options.currentUrl);
1900
+ const canonicalUrl = resolveMetaCanonicalUrl(site, options.canonicalUrl || options.currentUrl);
1374
1901
  const ogImage = resolveMetaImageUrl(options.image);
1375
1902
  const ogType = normalizeNonEmptyString(options.ogType, 'website');
1376
1903
  const publishedTime = normalizeOptionalString(options.publishedTime);
@@ -1471,7 +1998,7 @@ function normalizeMediaField(value, mediaBaseUrl) {
1471
1998
 
1472
1999
  const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
1473
2000
  if (!normalizedBaseUrl) {
1474
- return '';
2001
+ return normalizedValue;
1475
2002
  }
1476
2003
 
1477
2004
  try {
@@ -1550,6 +2077,22 @@ function routePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output
1550
2077
  return normalizedPath;
1551
2078
  }
1552
2079
 
2080
+ function pagePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
2081
+ const normalizedPath = normalizeRoutePath(routePath);
2082
+ if (outputStyle !== 'html-extension') {
2083
+ return routePathToPublicUrl(normalizedPath, outputStyle);
2084
+ }
2085
+
2086
+ const withoutTrailingSlash = normalizedPath.replace(/\/$/, '');
2087
+ if (withoutTrailingSlash === '/index') {
2088
+ return '/';
2089
+ }
2090
+ if (withoutTrailingSlash.endsWith('/index')) {
2091
+ return `${withoutTrailingSlash.slice(0, -'/index'.length)}/`;
2092
+ }
2093
+ return withoutTrailingSlash;
2094
+ }
2095
+
1553
2096
  function normalizeRoutePath(routePath) {
1554
2097
  if (!routePath || routePath === '/') {
1555
2098
  return '/';
@@ -1560,7 +2103,7 @@ function normalizeRoutePath(routePath) {
1560
2103
 
1561
2104
  function resolvePagePermalink(site, page) {
1562
2105
  if (normalizeOptionalString(page.path)) {
1563
- return buildRouteInfo(page.path, site.permalinks.output_style);
2106
+ return buildRouteInfo(page.path, site.permalinks.output_style, { pagePath: true });
1564
2107
  }
1565
2108
  return resolvePermalink(site, 'pages', page);
1566
2109
  }
@@ -1570,15 +2113,21 @@ function resolvePermalink(site, kind, item) {
1570
2113
  return buildRouteInfo(applyPermalinkPattern(pattern, kind, item, site), site.permalinks.output_style);
1571
2114
  }
1572
2115
 
1573
- function buildRouteInfo(routePath, outputStyle) {
2116
+ function buildRouteInfo(routePath, outputStyle, options = {}) {
1574
2117
  const path = normalizeRoutePath(routePath);
1575
2118
  return {
1576
2119
  path,
1577
- url: routePathToPublicUrl(path, outputStyle),
2120
+ url: options.pagePath ? pagePathToPublicUrl(path, outputStyle) : routePathToPublicUrl(path, outputStyle),
1578
2121
  outputPath: routePathToOutputPath(path, outputStyle),
1579
2122
  };
1580
2123
  }
1581
2124
 
2125
+ function pageToOutputPath(page, outputStyle) {
2126
+ return normalizeOptionalString(page.path)
2127
+ ? routePathToOutputPath(page.path, outputStyle)
2128
+ : routePathToOutputPath(page.url, outputStyle);
2129
+ }
2130
+
1582
2131
  function applyPermalinkPattern(pattern, kind, item, site) {
1583
2132
  const tokenValues = buildPermalinkTokenValues(kind, item, site);
1584
2133
  const body = String(pattern || '').replace(/^\/+|\/+$/g, '');
@@ -1635,7 +2184,7 @@ function assertPlannedOutputPathsSafe(state) {
1635
2184
  }
1636
2185
 
1637
2186
  for (const page of state.renderData.pages) {
1638
- assertSafeSlugDerivedOutputPath(page.slug, routePathToOutputPath(page.url, outputStyle));
2187
+ assertSafeSlugDerivedOutputPath(page.slug, pageToOutputPath(page, outputStyle));
1639
2188
  }
1640
2189
 
1641
2190
  for (const category of state.previewData.content.categories) {
@@ -1647,6 +2196,10 @@ function assertPlannedOutputPathsSafe(state) {
1647
2196
  }
1648
2197
 
1649
2198
  const routeEntries = [
2199
+ ...(state.renderData.frontPageRoute ? [{
2200
+ url: '/',
2201
+ outputPath: 'index.html',
2202
+ }] : []),
1650
2203
  ...state.renderData.indexRoutes.map((route) => ({
1651
2204
  url: routePathToPublicUrl(route.path, outputStyle),
1652
2205
  outputPath: routePathToOutputPath(route.path, outputStyle),
@@ -1669,7 +2222,7 @@ function assertPlannedOutputPathsSafe(state) {
1669
2222
  })),
1670
2223
  ...state.renderData.pages.map((page) => ({
1671
2224
  url: page.url,
1672
- outputPath: routePathToOutputPath(page.url, outputStyle),
2225
+ outputPath: pageToOutputPath(page, outputStyle),
1673
2226
  })),
1674
2227
  ];
1675
2228
  assertUniqueRoutes(routeEntries);
@@ -1681,7 +2234,10 @@ function assertPlannedOutputPathsSafe(state) {
1681
2234
  ];
1682
2235
 
1683
2236
  if (state.options.generateSpecialFiles) {
1684
- plannedPaths.push('404.html', 'robots.txt');
2237
+ plannedPaths.push('404.html');
2238
+ if (shouldGenerateRobotsTxt(state.options)) {
2239
+ plannedPaths.push('robots.txt');
2240
+ }
1685
2241
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
1686
2242
  plannedPaths.push('sitemap.xml', 'feed.xml');
1687
2243
  }
@@ -1781,11 +2337,43 @@ function sha256(content) {
1781
2337
  }
1782
2338
 
1783
2339
  function injectSiteCustomizations(html, state) {
1784
- let next = injectCustomCssAssetLink(html, state.customCssHref);
2340
+ let next = injectFaviconLinks(html, state.favicon);
2341
+ next = injectCustomCssAssetLink(next, state.customCssHref);
1785
2342
  next = injectCustomHtml(next, state.customHtml);
1786
2343
  return next;
1787
2344
  }
1788
2345
 
2346
+ function injectFaviconLinks(html, favicon) {
2347
+ const links = buildFaviconLinks(favicon);
2348
+ if (!links) {
2349
+ return html;
2350
+ }
2351
+
2352
+ return html.replace('</head>', `${links}\n</head>`);
2353
+ }
2354
+
2355
+ function buildFaviconLinks(favicon) {
2356
+ if (!favicon || typeof favicon !== 'object') {
2357
+ return '';
2358
+ }
2359
+
2360
+ const lines = [];
2361
+ if (normalizeOptionalString(favicon.icon)) {
2362
+ lines.push(` <link rel="icon" href="${escapeHtml(favicon.icon)}" sizes="any">`);
2363
+ }
2364
+ if (normalizeOptionalString(favicon.svg)) {
2365
+ lines.push(` <link rel="icon" href="${escapeHtml(favicon.svg)}" type="image/svg+xml">`);
2366
+ }
2367
+ if (normalizeOptionalString(favicon.png)) {
2368
+ lines.push(` <link rel="icon" href="${escapeHtml(favicon.png)}" type="image/png">`);
2369
+ }
2370
+ if (normalizeOptionalString(favicon.apple_touch_icon)) {
2371
+ lines.push(` <link rel="apple-touch-icon" href="${escapeHtml(favicon.apple_touch_icon)}">`);
2372
+ }
2373
+
2374
+ return lines.join('\n');
2375
+ }
2376
+
1789
2377
  function injectCustomCssAssetLink(html, href) {
1790
2378
  if (!normalizeOptionalString(href)) {
1791
2379
  return html;
@@ -1811,12 +2399,19 @@ function injectCustomHtml(html, customHtml) {
1811
2399
 
1812
2400
  function buildSitemapXml(site, emitted, generatedAt) {
1813
2401
  const entries = [
2402
+ ...(emitted.frontPage
2403
+ ? [{
2404
+ url: emitted.frontPage.url,
2405
+ changefreq: 'daily',
2406
+ priority: 1.0,
2407
+ }]
2408
+ : []),
1814
2409
  ...emitted.indexRoutes
1815
- .filter((route) => route.url === '/')
2410
+ .filter((route) => route.page === 1)
1816
2411
  .map((route) => ({
1817
2412
  url: route.url,
1818
2413
  changefreq: 'daily',
1819
- priority: 1.0,
2414
+ priority: route.url === '/' ? 1.0 : 0.7,
1820
2415
  })),
1821
2416
  ...emitted.posts.map((post) => ({
1822
2417
  url: post.url,
@@ -1824,11 +2419,13 @@ function buildSitemapXml(site, emitted, generatedAt) {
1824
2419
  changefreq: 'weekly',
1825
2420
  priority: 0.8,
1826
2421
  })),
1827
- ...emitted.pages.map((page) => ({
1828
- url: page.url,
1829
- changefreq: 'monthly',
1830
- priority: 0.7,
1831
- })),
2422
+ ...emitted.pages
2423
+ .filter((page) => page.includeInSitemap !== false)
2424
+ .map((page) => ({
2425
+ url: page.url,
2426
+ changefreq: 'monthly',
2427
+ priority: 0.7,
2428
+ })),
1832
2429
  ];
1833
2430
 
1834
2431
  const body = entries.map((entry) => {
@@ -1858,13 +2455,23 @@ function buildFeedXml(site, emitted, generatedAt) {
1858
2455
  }
1859
2456
 
1860
2457
  function buildRobotsTxt(site) {
1861
- const lines = ['User-agent: *', 'Allow: /'];
2458
+ const lines = ['User-agent: *'];
2459
+ if (site.indexing === false) {
2460
+ lines.push('Disallow: /');
2461
+ return `${lines.join('\n')}\n`;
2462
+ }
2463
+
2464
+ lines.push('Allow: /');
1862
2465
  if (site.url) {
1863
2466
  lines.push('', `Sitemap: ${resolveSiteUrl(site.url, '/sitemap.xml')}`);
1864
2467
  }
1865
2468
  return `${lines.join('\n')}\n`;
1866
2469
  }
1867
2470
 
2471
+ function shouldGenerateRobotsTxt(options) {
2472
+ return options.generateSpecialFiles && options.generateRobotsTxt !== false;
2473
+ }
2474
+
1868
2475
  function getContentType(assetPath) {
1869
2476
  const ext = assetPath.split('.').pop()?.toLowerCase();
1870
2477
  const contentTypes = {