@zeropress/build-core 0.1.14 → 0.1.16

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,7 +9,6 @@ import { ZeroPressEngine } from '../render/zeropress-engine.js';
9
9
  const DEFAULT_OPTIONS = {
10
10
  assetHashing: true,
11
11
  generateSpecialFiles: true,
12
- injectHtmx: true,
13
12
  writeManifest: false,
14
13
  };
15
14
 
@@ -18,7 +17,10 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
18
17
  const DEFAULT_TIME_FORMAT = 'HH:mm';
19
18
  const DEFAULT_TIMEZONE = 'UTC';
20
19
  const DEFAULT_LOCALE = 'en-US';
20
+ const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
21
21
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
22
+ const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
23
+ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
22
24
  export async function buildSite(input) {
23
25
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
24
26
  const state = await createBuildState(input, options);
@@ -58,6 +60,14 @@ export async function buildSite(input) {
58
60
  await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
59
61
  }
60
62
 
63
+ await writeOutput(
64
+ state.writer,
65
+ state.summaries,
66
+ COMMENT_POLICY_OUTPUT_PATH,
67
+ state.commentPolicyContent,
68
+ 'application/json',
69
+ );
70
+
61
71
  if (options.generateSpecialFiles) {
62
72
  await maybeRenderNotFoundPage(state);
63
73
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
@@ -77,14 +87,13 @@ async function createBuildState(input, options) {
77
87
  }
78
88
 
79
89
  assertPreviewData(input.previewData);
80
- await assertValidThemePackage(input.themePackage);
90
+ const themePackage = await normalizeAndValidateThemePackage(input.themePackage);
81
91
 
82
92
  const engine = new ZeroPressEngine();
83
93
  const assetProcessor = new AssetProcessor();
84
94
  const summaries = [];
85
- const themePackage = input.themePackage;
86
95
  const previewData = normalizePreviewData(input.previewData);
87
- const renderData = createRenderData(previewData);
96
+ const renderData = createRenderData(previewData, themePackage.metadata);
88
97
 
89
98
  engine.initialize(themePackage);
90
99
 
@@ -101,13 +110,14 @@ async function createBuildState(input, options) {
101
110
  writer: input.writer,
102
111
  previewData,
103
112
  renderData,
104
- renderedWidgets: renderWidgetAreas(previewData, renderData),
113
+ widgets: resolveWidgetAreas(previewData, renderData),
105
114
  engine,
106
115
  assetProcessor,
107
116
  summaries,
108
117
  assetOutputs,
109
118
  assetMap,
110
119
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
120
+ commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
111
121
  options,
112
122
  generatedAt: new Date(),
113
123
  emitted: {
@@ -149,7 +159,7 @@ async function renderRoute(state, templateName, route) {
149
159
  templateName,
150
160
  {
151
161
  menus: state.previewData.menus,
152
- widgets: state.renderedWidgets,
162
+ widgets: state.widgets,
153
163
  ...route,
154
164
  meta: buildPageMeta(state.previewData.site, {
155
165
  currentUrl,
@@ -172,7 +182,7 @@ async function renderPost(state, post) {
172
182
  'post',
173
183
  {
174
184
  menus: state.previewData.menus,
175
- widgets: state.renderedWidgets,
185
+ widgets: state.widgets,
176
186
  post,
177
187
  meta: buildPageMeta(state.previewData.site, {
178
188
  currentUrl,
@@ -188,9 +198,6 @@ async function renderPost(state, post) {
188
198
  );
189
199
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
190
200
  html = injectCustomCssAssetLink(html, state.customCssHref);
191
- if (state.options.injectHtmx) {
192
- html = injectHtmxScript(html);
193
- }
194
201
  await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
195
202
  state.emitted.posts.push({
196
203
  url: currentUrl,
@@ -208,7 +215,7 @@ async function renderPage(state, page) {
208
215
  'page',
209
216
  {
210
217
  menus: state.previewData.menus,
211
- widgets: state.renderedWidgets,
218
+ widgets: state.widgets,
212
219
  page,
213
220
  meta: buildPageMeta(state.previewData.site, {
214
221
  currentUrl,
@@ -240,7 +247,7 @@ async function maybeRenderNotFoundPage(state) {
240
247
  '404',
241
248
  {
242
249
  menus: state.previewData.menus,
243
- widgets: state.renderedWidgets,
250
+ widgets: state.widgets,
244
251
  meta: buildPageMeta(state.previewData.site, {
245
252
  currentUrl: '/404.html',
246
253
  title: state.previewData.site.title,
@@ -320,7 +327,7 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
320
327
  function normalizeWidgetItem(item, mediaBaseUrl) {
321
328
  const normalizedItem = {
322
329
  ...item,
323
- title: normalizeNonEmptyString(item?.title, 'Widget'),
330
+ title: typeof item?.title === 'string' ? item.title.trim() : '',
324
331
  };
325
332
 
326
333
  if (item?.type === 'profile' && item?.settings && typeof item.settings === 'object') {
@@ -341,7 +348,8 @@ function normalizeCustomCss(customCss) {
341
348
  return content ? { content } : undefined;
342
349
  }
343
350
 
344
- function createRenderData(previewData) {
351
+ function createRenderData(previewData, themeMetadata = {}) {
352
+ const themeSupportsComments = themeMetadata?.features?.comments === true;
345
353
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
346
354
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
347
355
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -362,11 +370,14 @@ function createRenderData(previewData) {
362
370
  }
363
371
  }
364
372
 
365
- const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
373
+ const preparedPosts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
374
+ const posts = preparedPosts.map((post, index) => ({
375
+ ...post,
376
+ prev: index > 0 ? buildAdjacentPostSummary(preparedPosts[index - 1]) : null,
377
+ next: index < preparedPosts.length - 1 ? buildAdjacentPostSummary(preparedPosts[index + 1]) : null,
378
+ }));
366
379
  const pages = previewData.content.pages.map((page) => preparePage(page));
367
380
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
368
- const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
369
- const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
370
381
 
371
382
  return {
372
383
  posts,
@@ -380,10 +391,8 @@ function createRenderData(previewData) {
380
391
  path: entry.path,
381
392
  page: entry.page,
382
393
  totalPages: entry.totalPages,
383
- posts: renderPostList(entry.items, postBySlug),
384
- categories: categoryLinks,
385
- tags: tagLinks,
386
- pagination: renderPagination(entry.paginationData),
394
+ posts: buildStructuredPostCollection(entry.items, postBySlug),
395
+ pagination: buildStructuredPagination(entry.paginationData),
387
396
  })),
388
397
  archiveRoutes: buildPaginatedCollection({
389
398
  items: previewData.content.posts,
@@ -393,29 +402,35 @@ function createRenderData(previewData) {
393
402
  path: entry.path,
394
403
  page: entry.page,
395
404
  totalPages: entry.totalPages,
396
- posts: renderArchive(entry.items, postBySlug),
397
- pagination: renderPagination(entry.paginationData),
405
+ archive: {
406
+ groups: buildArchiveGroups(entry.items, postBySlug),
407
+ },
408
+ pagination: buildStructuredPagination(entry.paginationData),
398
409
  })),
399
410
  categoryRoutes: buildTaxonomyRoutes({
400
411
  items: previewData.content.categories,
401
412
  postsBySlug: categoryPostsBySlug,
413
+ postBySlug,
402
414
  postsPerPage: previewData.site.postsPerPage,
403
415
  buildBasePath: (category) => `/categories/${encodeSlugSegment(category.slug)}/`,
404
- renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
405
- renderExtras: () => ({ categories: categoryLinks }),
416
+ renderExtras: (category) => ({
417
+ taxonomy: buildTaxonomyRouteData('category', category, categoryCountBySlug),
418
+ }),
406
419
  }),
407
420
  tagRoutes: buildTaxonomyRoutes({
408
421
  items: previewData.content.tags,
409
422
  postsBySlug: tagPostsBySlug,
423
+ postBySlug,
410
424
  postsPerPage: previewData.site.postsPerPage,
411
425
  buildBasePath: (tag) => `/tags/${encodeSlugSegment(tag.slug)}/`,
412
- renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
413
- renderExtras: () => ({ tags: tagLinks }),
426
+ renderExtras: (tag) => ({
427
+ taxonomy: buildTaxonomyRouteData('tag', tag, tagCountBySlug),
428
+ }),
414
429
  }),
415
430
  };
416
431
  }
417
432
 
418
- function renderWidgetAreas(previewData, renderData) {
433
+ function resolveWidgetAreas(previewData, renderData) {
419
434
  if (!previewData.widgets || typeof previewData.widgets !== 'object') {
420
435
  return {};
421
436
  }
@@ -423,243 +438,223 @@ function renderWidgetAreas(previewData, renderData) {
423
438
  return Object.fromEntries(
424
439
  Object.entries(previewData.widgets).map(([widgetAreaId, widgetArea]) => [
425
440
  widgetAreaId,
426
- renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId),
441
+ resolveWidgetArea(widgetArea, previewData, renderData, widgetAreaId),
427
442
  ]),
428
443
  );
429
444
  }
430
445
 
431
- function renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId) {
432
- if (!widgetArea || !Array.isArray(widgetArea.items) || widgetArea.items.length === 0) {
433
- return '';
446
+ function resolveWidgetArea(widgetArea, previewData, renderData, widgetAreaId) {
447
+ if (!widgetArea || !Array.isArray(widgetArea.items)) {
448
+ return {
449
+ name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
450
+ items: [],
451
+ };
434
452
  }
435
453
 
436
- return widgetArea.items
437
- .map((item, index) => renderWidgetItem(item, previewData, renderData, widgetAreaId, index))
438
- .filter(Boolean)
439
- .join('\n');
454
+ return {
455
+ name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
456
+ items: widgetArea.items
457
+ .map((item, index) => resolveWidgetItem(item, previewData, renderData, widgetAreaId, index))
458
+ .filter(Boolean),
459
+ };
440
460
  }
441
461
 
442
- function renderWidgetItem(item, previewData, renderData, widgetAreaId, index) {
443
- if (!item || typeof item !== 'object') {
444
- return '';
462
+ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
463
+ if (!item || typeof item !== 'object' || typeof item.type !== 'string') {
464
+ return null;
445
465
  }
446
466
 
447
- const title = normalizeNonEmptyString(item.title, 'Widget');
467
+ const baseWidget = {
468
+ id: `${widgetAreaId}-${index + 1}`,
469
+ type: item.type,
470
+ title: typeof item.title === 'string' ? item.title : '',
471
+ empty: false,
472
+ };
448
473
 
449
474
  switch (item.type) {
450
475
  case 'recent-posts':
451
- return renderRecentPostsWidget(title, item.settings, renderData);
476
+ return resolveRecentPostsWidget(baseWidget, item.settings, renderData);
452
477
  case 'categories':
453
- return renderCategoriesWidget(title, item.settings, previewData);
478
+ return resolveCategoriesWidget(baseWidget, item.settings, previewData);
454
479
  case 'tags':
455
- return renderTagsWidget(title, item.settings, previewData);
480
+ return resolveTagsWidget(baseWidget, item.settings, previewData);
456
481
  case 'archives':
457
- return renderArchivesWidget(title, item.settings, previewData);
458
- case 'image':
459
- return renderImageWidget(title, item.settings);
482
+ return resolveArchivesWidget(baseWidget, item.settings, previewData);
460
483
  case 'text':
461
- return renderTextWidget(title, item.settings);
484
+ return resolveTextWidget(baseWidget, item.settings);
462
485
  case 'link-list':
463
- return renderLinkListWidget(title, item.settings);
486
+ return resolveLinkListWidget(baseWidget, item.settings);
464
487
  case 'search':
465
- return renderSearchWidget(title, item.settings, widgetAreaId, index);
488
+ return resolveSearchWidget(baseWidget, item.settings, widgetAreaId, index);
466
489
  case 'profile':
467
- return renderProfileWidget(title, item.settings);
490
+ return resolveProfileWidget(baseWidget, item.settings);
468
491
  default:
469
- return '';
492
+ return null;
470
493
  }
471
494
  }
472
495
 
473
- function renderRecentPostsWidget(title, settings, renderData) {
496
+ function resolveRecentPostsWidget(baseWidget, settings, renderData) {
474
497
  const limit = clampInteger(settings?.limit, 5, 1, 20);
475
- const showDate = settings?.show_date !== false;
476
498
  const items = renderData.posts
477
499
  .slice(0, limit)
478
- .map((post) => {
479
- const dateMeta = showDate
480
- ? `<span class="widget-list__meta">${escapeHtml(post.published_at)}</span>`
481
- : '';
482
-
483
- return `<li class="widget-list__item"><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a>${dateMeta}</li>`;
484
- })
485
- .join('');
500
+ .map((post) => ({
501
+ title: post.title,
502
+ url: `/posts/${encodeSlugSegment(post.slug)}/`,
503
+ published_at: post.published_at,
504
+ published_at_iso: post.published_at_iso,
505
+ }));
486
506
 
487
- if (!items) {
488
- return '';
507
+ if (items.length === 0) {
508
+ return null;
489
509
  }
490
510
 
491
- return renderWidgetCard(title, 'recent-posts', `<ul class="widget-list">${items}</ul>`);
511
+ return {
512
+ ...baseWidget,
513
+ show_date: settings?.show_date !== false,
514
+ items,
515
+ };
492
516
  }
493
517
 
494
- function renderCategoriesWidget(title, settings, previewData) {
518
+ function resolveCategoriesWidget(baseWidget, settings, previewData) {
495
519
  const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'category_slugs');
496
520
  const items = previewData.content.categories
497
- .map((category) => {
498
- const count = countBySlug.get(category.slug) || 0;
499
- if (count === 0) {
500
- return '';
501
- }
502
-
503
- const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${count})</span>` : '';
504
- return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)}${suffix}</a>`;
505
- })
506
- .filter(Boolean)
507
- .join('');
521
+ .map((category) => ({
522
+ name: category.name,
523
+ slug: category.slug,
524
+ url: `/categories/${encodeSlugSegment(category.slug)}/`,
525
+ count: countBySlug.get(category.slug) || 0,
526
+ depth: 0,
527
+ }))
528
+ .filter((category) => category.count > 0);
508
529
 
509
- if (!items) {
510
- return '';
530
+ if (items.length === 0) {
531
+ return null;
511
532
  }
512
533
 
513
- return renderWidgetCard(title, 'categories', `<div class="taxonomy-list taxonomy-list--stack widget-taxonomy">${items}</div>`);
534
+ return {
535
+ ...baseWidget,
536
+ show_count: settings?.show_count === true,
537
+ hierarchical: settings?.hierarchical === true,
538
+ items,
539
+ };
514
540
  }
515
541
 
516
- function renderTagsWidget(title, settings, previewData) {
542
+ function resolveTagsWidget(baseWidget, settings, previewData) {
517
543
  const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'tag_slugs');
518
544
  const limit = clampInteger(settings?.limit, 20, 1, 100);
519
545
  const items = previewData.content.tags
520
546
  .map((tag) => ({
521
- ...tag,
547
+ name: tag.name,
548
+ slug: tag.slug,
549
+ url: `/tags/${encodeSlugSegment(tag.slug)}/`,
522
550
  count: countBySlug.get(tag.slug) || 0,
523
551
  }))
524
552
  .filter((tag) => tag.count > 0)
525
- .slice(0, limit)
526
- .map((tag) => {
527
- const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${tag.count})</span>` : '';
528
- return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)}${suffix}</a>`;
529
- })
530
- .join('');
553
+ .slice(0, limit);
531
554
 
532
- if (!items) {
533
- return '';
555
+ if (items.length === 0) {
556
+ return null;
534
557
  }
535
558
 
536
- return renderWidgetCard(title, 'tags', `<div class="taxonomy-list widget-taxonomy">${items}</div>`);
559
+ return {
560
+ ...baseWidget,
561
+ show_count: settings?.show_count === true,
562
+ items,
563
+ };
537
564
  }
538
565
 
539
- function renderArchivesWidget(title, settings, previewData) {
566
+ function resolveArchivesWidget(baseWidget, settings, previewData) {
540
567
  const limit = clampInteger(settings?.limit, 12, 1, 120);
541
- const archiveEntries = buildArchiveEntries(previewData.content.posts, previewData.site).slice(0, limit);
542
- const items = archiveEntries
543
- .map((entry) => `<li class="widget-list__item"><a href="/archive/">${escapeHtml(entry.label)}</a><span class="widget-list__meta">${entry.count} posts</span></li>`)
544
- .join('');
545
-
546
- if (!items) {
547
- return '';
548
- }
549
-
550
- return renderWidgetCard(title, 'archives', `<ul class="widget-list">${items}</ul>`);
551
- }
568
+ const items = buildArchiveEntries(previewData.content.posts, previewData.site)
569
+ .slice(0, limit)
570
+ .map((entry) => ({
571
+ label: entry.label,
572
+ url: '/archive/',
573
+ count: entry.count,
574
+ year: entry.year,
575
+ month: entry.month,
576
+ meta: `${entry.count} posts`,
577
+ }));
552
578
 
553
- function renderImageWidget(title, settings) {
554
- const src = normalizeOptionalString(settings?.src);
555
- if (!src) {
556
- return '';
579
+ if (items.length === 0) {
580
+ return null;
557
581
  }
558
582
 
559
- const alt = escapeHtml(normalizeOptionalString(settings?.alt));
560
- const caption = normalizeOptionalString(settings?.caption);
561
- const href = normalizeOptionalString(settings?.href);
562
- const target = normalizeLinkTarget(settings?.target);
563
- const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
564
- const image = `<img class="widget-image__media" src="${escapeHtml(src)}" alt="${alt}">`;
565
- const linkedImage = href ? `<a href="${escapeHtml(href)}" target="${target}"${rel}>${image}</a>` : image;
566
- const captionHtml = caption ? `<figcaption class="widget-image__caption">${escapeHtml(caption)}</figcaption>` : '';
567
-
568
- return renderWidgetCard(title, 'image', `<figure class="widget-image">${linkedImage}${captionHtml}</figure>`);
583
+ return {
584
+ ...baseWidget,
585
+ items,
586
+ };
569
587
  }
570
588
 
571
- function renderTextWidget(title, settings) {
589
+ function resolveTextWidget(baseWidget, settings) {
572
590
  const content = typeof settings?.content === 'string' ? settings.content : '';
573
591
  const html = renderDocumentContent(content, normalizeDocumentType(settings?.document_type));
592
+
574
593
  if (!normalizeOptionalString(html)) {
575
- return '';
594
+ return null;
576
595
  }
577
596
 
578
- return renderWidgetCard(title, 'text', `<div class="widget-copy">${html}</div>`);
597
+ return {
598
+ ...baseWidget,
599
+ html,
600
+ };
579
601
  }
580
602
 
581
- function renderLinkListWidget(title, settings) {
582
- const links = Array.isArray(settings?.links) ? settings.links : [];
583
- const items = links
603
+ function resolveLinkListWidget(baseWidget, settings) {
604
+ const items = (Array.isArray(settings?.links) ? settings.links : [])
584
605
  .map((link) => {
585
606
  const label = normalizeOptionalString(link?.label);
586
- const url = normalizeOptionalString(link?.url);
607
+ const url = normalizeThemeLinkUrl(link?.url);
587
608
  if (!label || !url) {
588
- return '';
609
+ return null;
589
610
  }
590
611
 
591
612
  const target = normalizeLinkTarget(link?.target);
592
- const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
593
- return `<a href="${escapeHtml(url)}" target="${target}"${rel}>${escapeHtml(label)}</a>`;
613
+ return {
614
+ label,
615
+ url,
616
+ target,
617
+ rel: target === '_blank' ? 'noreferrer noopener' : '',
618
+ };
594
619
  })
595
- .filter(Boolean)
596
- .join('');
620
+ .filter(Boolean);
597
621
 
598
- if (!items) {
599
- return '';
622
+ if (items.length === 0) {
623
+ return null;
600
624
  }
601
625
 
602
- return renderWidgetCard(title, 'link-list', `<div class="taxonomy-list taxonomy-list--stack widget-link-list">${items}</div>`);
626
+ return {
627
+ ...baseWidget,
628
+ items,
629
+ };
603
630
  }
604
631
 
605
- function renderSearchWidget(title, settings, widgetAreaId, index) {
606
- const placeholder = escapeHtml(normalizeNonEmptyString(settings?.placeholder, 'Search...'));
607
- const buttonLabel = escapeHtml(normalizeNonEmptyString(settings?.button_label, 'Search'));
608
- const searchId = `widget-search-${widgetAreaId}-${index + 1}`;
609
-
610
- return renderWidgetCard(title, 'search', [
611
- '<div class="widget-search" data-search-root>',
612
- ` <label class="sr-only" for="${escapeHtml(searchId)}">${escapeHtml(title)}</label>`,
613
- ' <form class="search-theme-wrapper" data-search-form>',
614
- ` <input id="${escapeHtml(searchId)}" class="search-input" data-theme-search type="search" placeholder="${placeholder}" autocomplete="off" enterkeyhint="search">`,
615
- ` <button class="widget-search-button" type="submit">${buttonLabel}</button>`,
616
- ' </form>',
617
- ' <div class="search-results" data-search-results hidden></div>',
618
- ' <p class="search-feedback search-feedback--sidebar" data-search-feedback aria-live="polite"></p>',
619
- '</div>',
620
- ].join('\n'));
632
+ function resolveSearchWidget(baseWidget, settings, widgetAreaId, index) {
633
+ return {
634
+ ...baseWidget,
635
+ placeholder: normalizeNonEmptyString(settings?.placeholder, 'Search...'),
636
+ button_label: normalizeNonEmptyString(settings?.button_label, 'Search'),
637
+ dom_id: `widget-search-${widgetAreaId}-${index + 1}`,
638
+ };
621
639
  }
622
640
 
623
- function renderProfileWidget(title, settings) {
641
+ function resolveProfileWidget(baseWidget, settings) {
624
642
  const displayName = normalizeOptionalString(settings?.display_name);
625
643
  const affiliation = normalizeOptionalString(settings?.affiliation);
626
- const bioShort = normalizeOptionalString(settings?.bio_short);
627
- const avatar = normalizeOptionalString(settings?.avatar);
628
-
629
- if (!displayName && !bioShort && !avatar) {
630
- return '';
631
- }
632
-
633
- const avatarHtml = avatar
634
- ? `<img class="widget-profile__avatar" src="${escapeHtml(avatar)}" alt="${escapeHtml(displayName || title)}">`
635
- : '';
636
- const affiliationHtml = affiliation ? `<p class="widget-profile__affiliation">${escapeHtml(affiliation)}</p>` : '';
637
- const bioHtml = bioShort ? `<p class="sidebar-copy">${escapeHtml(bioShort)}</p>` : '';
638
-
639
- return renderWidgetCard(title, 'profile', [
640
- '<div class="widget-profile">',
641
- avatarHtml,
642
- ' <div class="widget-profile__body">',
643
- displayName ? ` <p class="widget-profile__name">${escapeHtml(displayName)}</p>` : '',
644
- affiliationHtml ? ` ${affiliationHtml}` : '',
645
- bioHtml ? ` ${bioHtml}` : '',
646
- ' </div>',
647
- '</div>',
648
- ].filter(Boolean).join('\n'));
649
- }
644
+ const bioText = normalizeOptionalString(settings?.bio_short);
645
+ const avatarUrl = normalizeMediaField(settings?.avatar);
650
646
 
651
- function renderWidgetCard(title, modifier, body) {
652
- if (!normalizeOptionalString(body)) {
653
- return '';
647
+ if (!displayName && !affiliation && !bioText && !avatarUrl) {
648
+ return null;
654
649
  }
655
650
 
656
- return [
657
- `<section class="content-card sidebar-card widget-card widget-card--${escapeHtml(modifier)}">`,
658
- ' <p class="section-kicker">Widget</p>',
659
- ` <h2>${escapeHtml(title)}</h2>`,
660
- ` ${body}`,
661
- '</section>',
662
- ].join('\n');
651
+ return {
652
+ ...baseWidget,
653
+ display_name: displayName,
654
+ affiliation,
655
+ avatar_url: avatarUrl,
656
+ bio_text: bioText,
657
+ };
663
658
  }
664
659
 
665
660
  function preparePage(page) {
@@ -672,66 +667,68 @@ function preparePage(page) {
672
667
  };
673
668
  }
674
669
 
675
- function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
670
+ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
676
671
  const documentType = normalizeDocumentType(post.document_type);
677
672
  const html = renderDocumentContent(post.content, documentType);
678
673
  const author = authorsById.get(post.author_id);
674
+ const categories = post.category_slugs
675
+ .map((slug) => categoriesBySlug.get(slug))
676
+ .filter(Boolean)
677
+ .map((category) => ({
678
+ name: category.name,
679
+ slug: category.slug,
680
+ url: `/categories/${encodeSlugSegment(category.slug)}/`,
681
+ }));
682
+ const tags = post.tag_slugs
683
+ .map((slug) => tagsBySlug.get(slug))
684
+ .filter(Boolean)
685
+ .map((tag) => ({
686
+ name: tag.name,
687
+ slug: tag.slug,
688
+ url: `/tags/${encodeSlugSegment(tag.slug)}/`,
689
+ }));
679
690
 
680
691
  return {
681
- ...post,
692
+ public_id: post.public_id,
693
+ title: post.title,
694
+ slug: post.slug,
695
+ url: `/posts/${encodeSlugSegment(post.slug)}/`,
696
+ content: post.content,
682
697
  document_type: documentType,
698
+ excerpt: post.excerpt,
699
+ published_at_iso: post.published_at_iso,
700
+ updated_at_iso: post.updated_at_iso,
701
+ author_id: post.author_id,
702
+ featured_image: post.featured_image,
703
+ status: post.status,
704
+ allow_comments: post.allow_comments,
705
+ category_slugs: post.category_slugs,
706
+ tag_slugs: post.tag_slugs,
707
+ author: {
708
+ id: post.author_id,
709
+ display_name: normalizeNonEmptyString(author?.display_name, post.author_id),
710
+ avatar: author?.avatar || '',
711
+ },
712
+ categories,
713
+ tags,
683
714
  html,
684
- author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
685
- author_avatar: author?.avatar,
686
715
  published_at: formatTimestamp(post.published_at_iso, site),
687
716
  updated_at: formatTimestamp(post.updated_at_iso, site),
688
717
  reading_time: calculateReadingTime(html),
689
- categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
690
- tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
691
- comments_html: renderCommentsContainer(site, post),
718
+ comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
692
719
  };
693
720
  }
694
721
 
695
- function renderCommentsContainer(site, post) {
696
- if (site.disallowComments === true || post.allow_comments !== true) {
697
- return '';
698
- }
722
+ function buildCommentPolicyManifest(posts) {
723
+ const commentablePosts = posts
724
+ .filter((post) => post.status === 'published' && post.comments_enabled === true)
725
+ .map((post) => post.public_id)
726
+ .filter((value) => Number.isInteger(value) && value > 0);
699
727
 
700
- const safePostId = encodeURIComponent(post.id);
701
- return `<section id="comments" class="zp-comments">
702
- <h2>댓글</h2>
703
- <div id="comment-list"
704
- hx-get="/api/comments/${safePostId}"
705
- hx-trigger="load"
706
- hx-swap="innerHTML">
707
- <p class="zp-comments-loading">댓글을 불러오는 중...</p>
708
- </div>
709
- <form id="comment-form"
710
- hx-post="/api/comments/${safePostId}"
711
- hx-target="#comment-form-result"
712
- hx-swap="innerHTML">
713
- <div id="comment-form-result"></div>
714
- <div>
715
- <label for="author_name">이름</label>
716
- <input type="text" id="author_name" name="author_name" required>
717
- </div>
718
- <div>
719
- <label for="author_email">이메일</label>
720
- <input type="email" id="author_email" name="author_email" required>
721
- </div>
722
- <div>
723
- <label for="content">댓글</label>
724
- <textarea id="content" name="content" required minlength="1" maxlength="5000"></textarea>
725
- </div>
726
- <div style="position:absolute;left:-9999px;" aria-hidden="true">
727
- <input type="text" name="website" tabindex="-1" autocomplete="off">
728
- </div>
729
- <button type="submit">댓글 작성</button>
730
- </form>
731
- <noscript>
732
- <p>댓글 기능을 사용하려면 JavaScript를 활성화해 주세요.</p>
733
- </noscript>
734
- </section>`;
728
+ return JSON.stringify({
729
+ version: 1,
730
+ commentable_posts: commentablePosts,
731
+ }, null, 2);
735
732
  }
736
733
 
737
734
  function buildTaxonomyRoutes(options) {
@@ -755,8 +752,8 @@ function buildTaxonomyRoutes(options) {
755
752
  slug: item.slug,
756
753
  page: entry.page,
757
754
  totalPages: entry.totalPages,
758
- posts: options.renderList(entry.items),
759
- pagination: renderPagination(entry.paginationData),
755
+ posts: buildStructuredPostCollection(entry.items, options.postBySlug),
756
+ pagination: buildStructuredPagination(entry.paginationData),
760
757
  ...options.renderExtras(item),
761
758
  });
762
759
  }
@@ -788,6 +785,12 @@ function buildPaginatedCollection(options) {
788
785
  return pages;
789
786
  }
790
787
 
788
+ function buildStructuredPostCollection(posts, postBySlug) {
789
+ return {
790
+ items: buildStructuredPostItems(posts, postBySlug),
791
+ };
792
+ }
793
+
791
794
  function buildPaginatedPath(basePath, page) {
792
795
  const normalizedBasePath = normalizePaginationBasePath(basePath);
793
796
  if (page <= 1) {
@@ -828,124 +831,99 @@ function buildPaginationData(currentPage, totalPages, totalPosts, basePath) {
828
831
  };
829
832
  }
830
833
 
831
- function renderPostList(posts, postBySlug) {
832
- if (posts.length === 0) {
833
- return '<p>No posts</p>';
834
- }
834
+ function buildStructuredPagination(paginationData) {
835
+ return {
836
+ current_page: paginationData.currentPage,
837
+ total_pages: paginationData.totalPages,
838
+ total_items: paginationData.totalPosts,
839
+ has_prev: paginationData.hasPrev,
840
+ has_next: paginationData.hasNext,
841
+ has_multiple_pages: paginationData.totalPages > 1,
842
+ prev_url: paginationData.prevUrl || '',
843
+ next_url: paginationData.nextUrl || '',
844
+ pages: paginationData.pages.map((page) => ({
845
+ number: page.number,
846
+ url: page.url,
847
+ current: page.current,
848
+ })),
849
+ };
850
+ }
835
851
 
852
+ function buildStructuredPostItems(posts, postBySlug) {
836
853
  return posts
837
- .map((post) => {
838
- const prepared = postBySlug.get(post.slug);
839
- if (!prepared) {
840
- return '';
841
- }
842
-
843
- return [
844
- '<article class="post-list-item">',
845
- ` <h2><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a></h2>`,
846
- ` <div class="post-meta"><time datetime="${escapeHtml(prepared.published_at_iso)}">${escapeHtml(prepared.published_at)}</time> • ${escapeHtml(prepared.reading_time)}</div>`,
847
- ` <div class="post-excerpt">${escapeHtml(prepared.excerpt)}</div>`,
848
- '</article>',
849
- ].join('\n');
850
- })
854
+ .map((post) => postBySlug.get(post.slug))
851
855
  .filter(Boolean)
852
- .join('\n');
856
+ .map((post) => buildStructuredPostSummary(post));
853
857
  }
854
858
 
855
- function renderArchive(posts, postBySlug) {
856
- if (posts.length === 0) {
857
- return '<p>No posts</p>';
858
- }
859
-
860
- const groups = new Map();
861
-
862
- for (const post of posts) {
863
- const key = `${toDate(post.published_at_iso).getUTCFullYear()}-${String(toDate(post.published_at_iso).getUTCMonth() + 1).padStart(2, '0')}`;
864
- const items = groups.get(key) || [];
865
- items.push(post);
866
- groups.set(key, items);
867
- }
868
-
869
- return Array.from(groups.entries())
870
- .map(([key, groupPosts]) => {
871
- const items = groupPosts
872
- .map((post) => {
873
- const prepared = postBySlug.get(post.slug);
874
- if (!prepared) {
875
- return '';
876
- }
877
-
878
- return `<li><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a> <time datetime="${escapeHtml(prepared.published_at_iso)}">${escapeHtml(prepared.published_at)}</time></li>`;
879
- })
880
- .filter(Boolean)
881
- .join('\n');
882
-
883
- return `<section class="archive-group"><h2>${escapeHtml(key)}</h2><ul>${items}</ul></section>`;
884
- })
885
- .join('\n');
859
+ function buildStructuredPostSummary(post) {
860
+ return {
861
+ title: post.title,
862
+ slug: post.slug,
863
+ url: post.url,
864
+ excerpt: post.excerpt,
865
+ published_at: post.published_at,
866
+ published_at_iso: post.published_at_iso,
867
+ reading_time: post.reading_time,
868
+ featured_image: post.featured_image,
869
+ author: {
870
+ display_name: post.author?.display_name || '',
871
+ avatar: post.author?.avatar || '',
872
+ },
873
+ categories: Array.isArray(post.categories) ? post.categories.map((category) => ({ ...category })) : [],
874
+ tags: Array.isArray(post.tags) ? post.tags.map((tag) => ({ ...tag })) : [],
875
+ };
886
876
  }
887
877
 
888
- function renderCategoryLinks(categories, countBySlug) {
889
- return categories
890
- .map((category) => {
891
- const count = countBySlug.get(category.slug) || 0;
892
- if (count === 0) {
893
- return null;
894
- }
878
+ function buildAdjacentPostSummary(post) {
879
+ if (!post) {
880
+ return null;
881
+ }
895
882
 
896
- return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)} (${count})</a>`;
897
- })
898
- .filter(Boolean)
899
- .join('\n');
883
+ return {
884
+ title: post.title,
885
+ slug: post.slug,
886
+ url: post.url,
887
+ excerpt: post.excerpt,
888
+ published_at: post.published_at,
889
+ published_at_iso: post.published_at_iso,
890
+ };
900
891
  }
901
892
 
902
- function renderTagLinks(tags, countBySlug) {
903
- return tags
904
- .map((tag) => {
905
- const count = countBySlug.get(tag.slug) || 0;
906
- if (count === 0) {
907
- return null;
908
- }
893
+ function buildArchiveGroups(posts, postBySlug) {
894
+ const groups = new Map();
909
895
 
910
- return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)} (${count})</a>`;
911
- })
912
- .filter(Boolean)
913
- .join('\n');
914
- }
896
+ for (const post of posts) {
897
+ const prepared = postBySlug.get(post.slug);
898
+ if (!prepared?.published_at_iso) {
899
+ continue;
900
+ }
915
901
 
916
- function renderInlineTaxonomyLinks(slugs, taxonomyBySlug, kind) {
917
- const links = slugs
918
- .map((slug) => taxonomyBySlug.get(slug))
919
- .filter(Boolean)
920
- .map((item) => {
921
- const base = kind === 'category' ? '/categories/' : '/tags/';
922
- const className = kind === 'category' ? 'category-link' : 'tag-link';
923
- return `<a href="${base}${escapeHtml(encodeSlugSegment(item.slug))}/" class="${className}">${escapeHtml(item.name)}</a>`;
924
- });
902
+ const date = toDate(prepared.published_at_iso);
903
+ const year = date.getUTCFullYear();
904
+ const month = date.getUTCMonth() + 1;
905
+ const label = `${year}-${String(month).padStart(2, '0')}`;
906
+ const current = groups.get(label) || {
907
+ label,
908
+ year,
909
+ month,
910
+ items: [],
911
+ };
925
912
 
926
- if (links.length === 0) {
927
- return '';
913
+ current.items.push(buildStructuredPostSummary(prepared));
914
+ groups.set(label, current);
928
915
  }
929
916
 
930
- return kind === 'category' ? links.join(', ') : links.join(' ');
917
+ return Array.from(groups.values());
931
918
  }
932
919
 
933
- function renderPagination(paginationData) {
934
- if (paginationData.totalPages <= 1) {
935
- return '';
936
- }
937
-
938
- const prevLink = paginationData.hasPrev && paginationData.prevUrl
939
- ? `<a href="${paginationData.prevUrl}" class="prev">Previous</a>`
940
- : '';
941
- const nextLink = paginationData.hasNext && paginationData.nextUrl
942
- ? `<a href="${paginationData.nextUrl}" class="next">Next</a>`
943
- : '';
944
- const pageLinks = paginationData.pages
945
- .map((page) => `<a href="${page.url}" class="page-link ${page.current ? 'current' : ''}">${page.number}</a>`)
946
- .join(' ');
947
-
948
- return `<nav class="pagination">\n ${prevLink}\n <span class="pages">${pageLinks}</span>\n ${nextLink}\n</nav>`;
920
+ function buildTaxonomyRouteData(kind, item, countBySlug) {
921
+ return {
922
+ kind,
923
+ slug: item.slug,
924
+ name: item.name,
925
+ count: countBySlug.get(item.slug) || 0,
926
+ };
949
927
  }
950
928
 
951
929
  function buildTaxonomyCountMap(posts, fieldName) {
@@ -982,6 +960,8 @@ function buildArchiveEntries(posts, site) {
982
960
  .map((entry) => ({
983
961
  label: formatArchiveLabel(entry.date, site),
984
962
  count: entry.count,
963
+ year: entry.date.getUTCFullYear(),
964
+ month: entry.date.getUTCMonth() + 1,
985
965
  }));
986
966
  }
987
967
 
@@ -1115,7 +1095,7 @@ function recordRouteEmission(state, templateName, route, currentUrl) {
1115
1095
  });
1116
1096
  }
1117
1097
 
1118
- async function assertValidThemePackage(themePackage) {
1098
+ async function normalizeAndValidateThemePackage(themePackage) {
1119
1099
  if (!themePackage?.templates || !themePackage?.partials || !themePackage?.assets || !themePackage?.metadata) {
1120
1100
  throw new Error('Invalid themePackage: expected metadata, templates, partials, and assets');
1121
1101
  }
@@ -1131,6 +1111,7 @@ async function assertValidThemePackage(themePackage) {
1131
1111
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
1132
1112
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
1133
1113
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1114
+ ...(themePackage.metadata.features ? { features: themePackage.metadata.features } : {}),
1134
1115
  ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1135
1116
  ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
1136
1117
  settings: themePackage.metadata.settings || {},
@@ -1152,6 +1133,25 @@ async function assertValidThemePackage(themePackage) {
1152
1133
  if (!validation.ok) {
1153
1134
  throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
1154
1135
  }
1136
+
1137
+ if (!validation.manifest) {
1138
+ throw new Error('Theme validation failed: normalized manifest not available');
1139
+ }
1140
+
1141
+ return {
1142
+ metadata: normalizeThemePackageMetadata(themePackage.metadata, validation.manifest),
1143
+ templates: themePackage.templates,
1144
+ partials: themePackage.partials,
1145
+ assets: themePackage.assets,
1146
+ };
1147
+ }
1148
+
1149
+ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
1150
+ return {
1151
+ ...manifest,
1152
+ ...(sourceMetadata?.thumbnail ? { thumbnail: sourceMetadata.thumbnail } : {}),
1153
+ settings: sourceMetadata?.settings || {},
1154
+ };
1155
1155
  }
1156
1156
 
1157
1157
  async function buildAssetOutputs(assets, assetProcessor, options) {
@@ -1292,7 +1292,7 @@ function resolveMetaImageUrl(image) {
1292
1292
  }
1293
1293
 
1294
1294
  if (isAbsoluteUrl(normalizedImage)) {
1295
- return normalizedImage;
1295
+ return normalizeAbsoluteUrl(normalizedImage, SAFE_MEDIA_PROTOCOLS);
1296
1296
  }
1297
1297
 
1298
1298
  return '';
@@ -1309,7 +1309,7 @@ function normalizeMediaField(value, mediaBaseUrl) {
1309
1309
  }
1310
1310
 
1311
1311
  if (isAbsoluteUrl(normalizedValue)) {
1312
- return normalizedValue;
1312
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
1313
1313
  }
1314
1314
 
1315
1315
  const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
@@ -1333,6 +1333,31 @@ function isAbsoluteUrl(value) {
1333
1333
  }
1334
1334
  }
1335
1335
 
1336
+ function normalizeAbsoluteUrl(value, allowedProtocols) {
1337
+ try {
1338
+ const url = new URL(value);
1339
+ if (!allowedProtocols.has(url.protocol)) {
1340
+ return '';
1341
+ }
1342
+ return decodeURI(url.toString());
1343
+ } catch {
1344
+ return '';
1345
+ }
1346
+ }
1347
+
1348
+ function normalizeThemeLinkUrl(value) {
1349
+ const normalizedValue = normalizeOptionalString(value);
1350
+ if (!normalizedValue || normalizedValue.startsWith('//')) {
1351
+ return '';
1352
+ }
1353
+
1354
+ if (isAbsoluteUrl(normalizedValue)) {
1355
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_LINK_PROTOCOLS);
1356
+ }
1357
+
1358
+ return normalizedValue;
1359
+ }
1360
+
1336
1361
  async function writeOutput(writer, summaries, path, content, contentType) {
1337
1362
  const rawPath = String(path || '');
1338
1363
  const normalizedPath = normalizeOutputPath(rawPath);
@@ -1391,6 +1416,7 @@ function assertPlannedOutputPathsSafe(state) {
1391
1416
  ...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
1392
1417
  ...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
1393
1418
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1419
+ COMMENT_POLICY_OUTPUT_PATH,
1394
1420
  ];
1395
1421
 
1396
1422
  if (state.options.generateSpecialFiles) {
@@ -1465,10 +1491,6 @@ function sha256(content) {
1465
1491
  return hash.digest('hex');
1466
1492
  }
1467
1493
 
1468
- function injectHtmxScript(html) {
1469
- return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
1470
- }
1471
-
1472
1494
  function injectCustomCssAssetLink(html, href) {
1473
1495
  if (!normalizeOptionalString(href)) {
1474
1496
  return html;