@zeropress/build-core 0.1.15 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "dependencies": {
36
36
  "@zeropress/slug-policy": "^0.1.0",
37
37
  "@zeropress/preview-data-validator": "^0.4.4",
38
- "@zeropress/theme-validator": "^0.3.4",
38
+ "@zeropress/theme-validator": "^0.4.0",
39
39
  "highlight.js": "^11.11.1",
40
40
  "markdown-it": "^14.1.0",
41
41
  "markdown-it-anchor": "^9.2.0"
@@ -17,8 +17,6 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
17
17
  const DEFAULT_TIME_FORMAT = 'HH:mm';
18
18
  const DEFAULT_TIMEZONE = 'UTC';
19
19
  const DEFAULT_LOCALE = 'en-US';
20
- const DEFAULT_THEME_RUNTIME = '0.3';
21
- const THEME_RUNTIME_V0_4 = '0.4';
22
20
  const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
23
21
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
24
22
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
@@ -89,15 +87,13 @@ async function createBuildState(input, options) {
89
87
  }
90
88
 
91
89
  assertPreviewData(input.previewData);
92
- await assertValidThemePackage(input.themePackage);
90
+ const themePackage = await normalizeAndValidateThemePackage(input.themePackage);
93
91
 
94
92
  const engine = new ZeroPressEngine();
95
93
  const assetProcessor = new AssetProcessor();
96
94
  const summaries = [];
97
- const themePackage = input.themePackage;
98
95
  const previewData = normalizePreviewData(input.previewData);
99
96
  const renderData = createRenderData(previewData, themePackage.metadata);
100
- const themeRuntime = normalizeThemeRuntime(themePackage.metadata?.runtime);
101
97
 
102
98
  engine.initialize(themePackage);
103
99
 
@@ -114,8 +110,7 @@ async function createBuildState(input, options) {
114
110
  writer: input.writer,
115
111
  previewData,
116
112
  renderData,
117
- themeRuntime,
118
- widgets: buildWidgetsForTheme(previewData, renderData, themeRuntime),
113
+ widgets: resolveWidgetAreas(previewData, renderData),
119
114
  engine,
120
115
  assetProcessor,
121
116
  summaries,
@@ -375,11 +370,14 @@ function createRenderData(previewData, themeMetadata = {}) {
375
370
  }
376
371
  }
377
372
 
378
- const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
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
+ }));
379
379
  const pages = previewData.content.pages.map((page) => preparePage(page));
380
380
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
381
- const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
382
- const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
383
381
 
384
382
  return {
385
383
  posts,
@@ -393,10 +391,8 @@ function createRenderData(previewData, themeMetadata = {}) {
393
391
  path: entry.path,
394
392
  page: entry.page,
395
393
  totalPages: entry.totalPages,
396
- posts: renderPostList(entry.items, postBySlug),
397
- categories: categoryLinks,
398
- tags: tagLinks,
399
- pagination: renderPagination(entry.paginationData),
394
+ posts: buildStructuredPostCollection(entry.items, postBySlug),
395
+ pagination: buildStructuredPagination(entry.paginationData),
400
396
  })),
401
397
  archiveRoutes: buildPaginatedCollection({
402
398
  items: previewData.content.posts,
@@ -406,40 +402,34 @@ function createRenderData(previewData, themeMetadata = {}) {
406
402
  path: entry.path,
407
403
  page: entry.page,
408
404
  totalPages: entry.totalPages,
409
- posts: renderArchive(entry.items, postBySlug),
410
- pagination: renderPagination(entry.paginationData),
405
+ archive: {
406
+ groups: buildArchiveGroups(entry.items, postBySlug),
407
+ },
408
+ pagination: buildStructuredPagination(entry.paginationData),
411
409
  })),
412
410
  categoryRoutes: buildTaxonomyRoutes({
413
411
  items: previewData.content.categories,
414
412
  postsBySlug: categoryPostsBySlug,
413
+ postBySlug,
415
414
  postsPerPage: previewData.site.postsPerPage,
416
415
  buildBasePath: (category) => `/categories/${encodeSlugSegment(category.slug)}/`,
417
- renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
418
- renderExtras: () => ({ categories: categoryLinks }),
416
+ renderExtras: (category) => ({
417
+ taxonomy: buildTaxonomyRouteData('category', category, categoryCountBySlug),
418
+ }),
419
419
  }),
420
420
  tagRoutes: buildTaxonomyRoutes({
421
421
  items: previewData.content.tags,
422
422
  postsBySlug: tagPostsBySlug,
423
+ postBySlug,
423
424
  postsPerPage: previewData.site.postsPerPage,
424
425
  buildBasePath: (tag) => `/tags/${encodeSlugSegment(tag.slug)}/`,
425
- renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
426
- renderExtras: () => ({ tags: tagLinks }),
426
+ renderExtras: (tag) => ({
427
+ taxonomy: buildTaxonomyRouteData('tag', tag, tagCountBySlug),
428
+ }),
427
429
  }),
428
430
  };
429
431
  }
430
432
 
431
- function normalizeThemeRuntime(value) {
432
- return value === THEME_RUNTIME_V0_4 ? THEME_RUNTIME_V0_4 : DEFAULT_THEME_RUNTIME;
433
- }
434
-
435
- function buildWidgetsForTheme(previewData, renderData, themeRuntime) {
436
- if (themeRuntime === THEME_RUNTIME_V0_4) {
437
- return resolveWidgetAreas(previewData, renderData);
438
- }
439
-
440
- return renderWidgetAreas(previewData, renderData);
441
- }
442
-
443
433
  function resolveWidgetAreas(previewData, renderData) {
444
434
  if (!previewData.widgets || typeof previewData.widgets !== 'object') {
445
435
  return {};
@@ -667,253 +657,6 @@ function resolveProfileWidget(baseWidget, settings) {
667
657
  };
668
658
  }
669
659
 
670
- function renderWidgetAreas(previewData, renderData) {
671
- if (!previewData.widgets || typeof previewData.widgets !== 'object') {
672
- return {};
673
- }
674
-
675
- return Object.fromEntries(
676
- Object.entries(previewData.widgets).map(([widgetAreaId, widgetArea]) => [
677
- widgetAreaId,
678
- renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId),
679
- ]),
680
- );
681
- }
682
-
683
- function renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId) {
684
- if (!widgetArea || !Array.isArray(widgetArea.items) || widgetArea.items.length === 0) {
685
- return '';
686
- }
687
-
688
- return widgetArea.items
689
- .map((item, index) => renderWidgetItem(item, previewData, renderData, widgetAreaId, index))
690
- .filter(Boolean)
691
- .join('\n');
692
- }
693
-
694
- function renderWidgetItem(item, previewData, renderData, widgetAreaId, index) {
695
- if (!item || typeof item !== 'object') {
696
- return '';
697
- }
698
-
699
- const title = normalizeNonEmptyString(item.title, 'Widget');
700
-
701
- switch (item.type) {
702
- case 'recent-posts':
703
- return renderRecentPostsWidget(title, item.settings, renderData);
704
- case 'categories':
705
- return renderCategoriesWidget(title, item.settings, previewData);
706
- case 'tags':
707
- return renderTagsWidget(title, item.settings, previewData);
708
- case 'archives':
709
- return renderArchivesWidget(title, item.settings, previewData);
710
- case 'image':
711
- return renderImageWidget(title, item.settings);
712
- case 'text':
713
- return renderTextWidget(title, item.settings);
714
- case 'link-list':
715
- return renderLinkListWidget(title, item.settings);
716
- case 'search':
717
- return renderSearchWidget(title, item.settings, widgetAreaId, index);
718
- case 'profile':
719
- return renderProfileWidget(title, item.settings);
720
- default:
721
- return '';
722
- }
723
- }
724
-
725
- function renderRecentPostsWidget(title, settings, renderData) {
726
- const limit = clampInteger(settings?.limit, 5, 1, 20);
727
- const showDate = settings?.show_date !== false;
728
- const items = renderData.posts
729
- .slice(0, limit)
730
- .map((post) => {
731
- const dateMeta = showDate
732
- ? `<span class="widget-list__meta">${escapeHtml(post.published_at)}</span>`
733
- : '';
734
-
735
- return `<li class="widget-list__item"><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a>${dateMeta}</li>`;
736
- })
737
- .join('');
738
-
739
- if (!items) {
740
- return '';
741
- }
742
-
743
- return renderWidgetCard(title, 'recent-posts', `<ul class="widget-list">${items}</ul>`);
744
- }
745
-
746
- function renderCategoriesWidget(title, settings, previewData) {
747
- const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'category_slugs');
748
- const items = previewData.content.categories
749
- .map((category) => {
750
- const count = countBySlug.get(category.slug) || 0;
751
- if (count === 0) {
752
- return '';
753
- }
754
-
755
- const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${count})</span>` : '';
756
- return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)}${suffix}</a>`;
757
- })
758
- .filter(Boolean)
759
- .join('');
760
-
761
- if (!items) {
762
- return '';
763
- }
764
-
765
- return renderWidgetCard(title, 'categories', `<div class="taxonomy-list taxonomy-list--stack widget-taxonomy">${items}</div>`);
766
- }
767
-
768
- function renderTagsWidget(title, settings, previewData) {
769
- const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'tag_slugs');
770
- const limit = clampInteger(settings?.limit, 20, 1, 100);
771
- const items = previewData.content.tags
772
- .map((tag) => ({
773
- ...tag,
774
- count: countBySlug.get(tag.slug) || 0,
775
- }))
776
- .filter((tag) => tag.count > 0)
777
- .slice(0, limit)
778
- .map((tag) => {
779
- const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${tag.count})</span>` : '';
780
- return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)}${suffix}</a>`;
781
- })
782
- .join('');
783
-
784
- if (!items) {
785
- return '';
786
- }
787
-
788
- return renderWidgetCard(title, 'tags', `<div class="taxonomy-list widget-taxonomy">${items}</div>`);
789
- }
790
-
791
- function renderArchivesWidget(title, settings, previewData) {
792
- const limit = clampInteger(settings?.limit, 12, 1, 120);
793
- const archiveEntries = buildArchiveEntries(previewData.content.posts, previewData.site).slice(0, limit);
794
- const items = archiveEntries
795
- .map((entry) => `<li class="widget-list__item"><a href="/archive/">${escapeHtml(entry.label)}</a><span class="widget-list__meta">${entry.count} posts</span></li>`)
796
- .join('');
797
-
798
- if (!items) {
799
- return '';
800
- }
801
-
802
- return renderWidgetCard(title, 'archives', `<ul class="widget-list">${items}</ul>`);
803
- }
804
-
805
- function renderImageWidget(title, settings) {
806
- const src = normalizeOptionalString(settings?.src);
807
- if (!src) {
808
- return '';
809
- }
810
-
811
- const alt = escapeHtml(normalizeOptionalString(settings?.alt));
812
- const caption = normalizeOptionalString(settings?.caption);
813
- const href = normalizeOptionalString(settings?.href);
814
- const target = normalizeLinkTarget(settings?.target);
815
- const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
816
- const image = `<img class="widget-image__media" src="${escapeHtml(src)}" alt="${alt}">`;
817
- const linkedImage = href ? `<a href="${escapeHtml(href)}" target="${target}"${rel}>${image}</a>` : image;
818
- const captionHtml = caption ? `<figcaption class="widget-image__caption">${escapeHtml(caption)}</figcaption>` : '';
819
-
820
- return renderWidgetCard(title, 'image', `<figure class="widget-image">${linkedImage}${captionHtml}</figure>`);
821
- }
822
-
823
- function renderTextWidget(title, settings) {
824
- const content = typeof settings?.content === 'string' ? settings.content : '';
825
- const html = renderDocumentContent(content, normalizeDocumentType(settings?.document_type));
826
- if (!normalizeOptionalString(html)) {
827
- return '';
828
- }
829
-
830
- return renderWidgetCard(title, 'text', `<div class="widget-copy">${html}</div>`);
831
- }
832
-
833
- function renderLinkListWidget(title, settings) {
834
- const links = Array.isArray(settings?.links) ? settings.links : [];
835
- const items = links
836
- .map((link) => {
837
- const label = normalizeOptionalString(link?.label);
838
- const url = normalizeThemeLinkUrl(link?.url);
839
- if (!label || !url) {
840
- return '';
841
- }
842
-
843
- const target = normalizeLinkTarget(link?.target);
844
- const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
845
- return `<a href="${escapeHtml(url)}" target="${target}"${rel}>${escapeHtml(label)}</a>`;
846
- })
847
- .filter(Boolean)
848
- .join('');
849
-
850
- if (!items) {
851
- return '';
852
- }
853
-
854
- return renderWidgetCard(title, 'link-list', `<div class="taxonomy-list taxonomy-list--stack widget-link-list">${items}</div>`);
855
- }
856
-
857
- function renderSearchWidget(title, settings, widgetAreaId, index) {
858
- const placeholder = escapeHtml(normalizeNonEmptyString(settings?.placeholder, 'Search...'));
859
- const buttonLabel = escapeHtml(normalizeNonEmptyString(settings?.button_label, 'Search'));
860
- const searchId = `widget-search-${widgetAreaId}-${index + 1}`;
861
-
862
- return renderWidgetCard(title, 'search', [
863
- '<div class="widget-search" data-search-root>',
864
- ` <label class="sr-only" for="${escapeHtml(searchId)}">${escapeHtml(title)}</label>`,
865
- ' <form class="search-theme-wrapper" data-search-form>',
866
- ` <input id="${escapeHtml(searchId)}" class="search-input" data-theme-search type="search" placeholder="${placeholder}" autocomplete="off" enterkeyhint="search">`,
867
- ` <button class="widget-search-button" type="submit">${buttonLabel}</button>`,
868
- ' </form>',
869
- ' <div class="search-results" data-search-results hidden></div>',
870
- ' <p class="search-feedback search-feedback--sidebar" data-search-feedback aria-live="polite"></p>',
871
- '</div>',
872
- ].join('\n'));
873
- }
874
-
875
- function renderProfileWidget(title, settings) {
876
- const displayName = normalizeOptionalString(settings?.display_name);
877
- const affiliation = normalizeOptionalString(settings?.affiliation);
878
- const bioShort = normalizeOptionalString(settings?.bio_short);
879
- const avatar = normalizeOptionalString(settings?.avatar);
880
-
881
- if (!displayName && !bioShort && !avatar) {
882
- return '';
883
- }
884
-
885
- const avatarHtml = avatar
886
- ? `<img class="widget-profile__avatar" src="${escapeHtml(avatar)}" alt="${escapeHtml(displayName || title)}">`
887
- : '';
888
- const affiliationHtml = affiliation ? `<p class="widget-profile__affiliation">${escapeHtml(affiliation)}</p>` : '';
889
- const bioHtml = bioShort ? `<p class="sidebar-copy">${escapeHtml(bioShort)}</p>` : '';
890
-
891
- return renderWidgetCard(title, 'profile', [
892
- '<div class="widget-profile">',
893
- avatarHtml,
894
- ' <div class="widget-profile__body">',
895
- displayName ? ` <p class="widget-profile__name">${escapeHtml(displayName)}</p>` : '',
896
- affiliationHtml ? ` ${affiliationHtml}` : '',
897
- bioHtml ? ` ${bioHtml}` : '',
898
- ' </div>',
899
- '</div>',
900
- ].filter(Boolean).join('\n'));
901
- }
902
-
903
- function renderWidgetCard(title, modifier, body) {
904
- if (!normalizeOptionalString(body)) {
905
- return '';
906
- }
907
-
908
- return [
909
- `<section class="content-card sidebar-card widget-card widget-card--${escapeHtml(modifier)}">`,
910
- ' <p class="section-kicker">Widget</p>',
911
- ` <h2>${escapeHtml(title)}</h2>`,
912
- ` ${body}`,
913
- '</section>',
914
- ].join('\n');
915
- }
916
-
917
660
  function preparePage(page) {
918
661
  const documentType = normalizeDocumentType(page.document_type);
919
662
 
@@ -928,11 +671,28 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
928
671
  const documentType = normalizeDocumentType(post.document_type);
929
672
  const html = renderDocumentContent(post.content, documentType);
930
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
+ }));
931
690
 
932
691
  return {
933
692
  public_id: post.public_id,
934
693
  title: post.title,
935
694
  slug: post.slug,
695
+ url: `/posts/${encodeSlugSegment(post.slug)}/`,
936
696
  content: post.content,
937
697
  document_type: documentType,
938
698
  excerpt: post.excerpt,
@@ -944,14 +704,17 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
944
704
  allow_comments: post.allow_comments,
945
705
  category_slugs: post.category_slugs,
946
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,
947
714
  html,
948
- author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
949
- author_avatar: author?.avatar,
950
715
  published_at: formatTimestamp(post.published_at_iso, site),
951
716
  updated_at: formatTimestamp(post.updated_at_iso, site),
952
717
  reading_time: calculateReadingTime(html),
953
- categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
954
- tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
955
718
  comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
956
719
  };
957
720
  }
@@ -989,8 +752,8 @@ function buildTaxonomyRoutes(options) {
989
752
  slug: item.slug,
990
753
  page: entry.page,
991
754
  totalPages: entry.totalPages,
992
- posts: options.renderList(entry.items),
993
- pagination: renderPagination(entry.paginationData),
755
+ posts: buildStructuredPostCollection(entry.items, options.postBySlug),
756
+ pagination: buildStructuredPagination(entry.paginationData),
994
757
  ...options.renderExtras(item),
995
758
  });
996
759
  }
@@ -1022,6 +785,12 @@ function buildPaginatedCollection(options) {
1022
785
  return pages;
1023
786
  }
1024
787
 
788
+ function buildStructuredPostCollection(posts, postBySlug) {
789
+ return {
790
+ items: buildStructuredPostItems(posts, postBySlug),
791
+ };
792
+ }
793
+
1025
794
  function buildPaginatedPath(basePath, page) {
1026
795
  const normalizedBasePath = normalizePaginationBasePath(basePath);
1027
796
  if (page <= 1) {
@@ -1062,124 +831,99 @@ function buildPaginationData(currentPage, totalPages, totalPosts, basePath) {
1062
831
  };
1063
832
  }
1064
833
 
1065
- function renderPostList(posts, postBySlug) {
1066
- if (posts.length === 0) {
1067
- return '<p>No posts</p>';
1068
- }
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
+ }
1069
851
 
852
+ function buildStructuredPostItems(posts, postBySlug) {
1070
853
  return posts
1071
- .map((post) => {
1072
- const prepared = postBySlug.get(post.slug);
1073
- if (!prepared) {
1074
- return '';
1075
- }
1076
-
1077
- return [
1078
- '<article class="post-list-item">',
1079
- ` <h2><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a></h2>`,
1080
- ` <div class="post-meta"><time datetime="${escapeHtml(prepared.published_at_iso)}">${escapeHtml(prepared.published_at)}</time> • ${escapeHtml(prepared.reading_time)}</div>`,
1081
- ` <div class="post-excerpt">${escapeHtml(prepared.excerpt)}</div>`,
1082
- '</article>',
1083
- ].join('\n');
1084
- })
854
+ .map((post) => postBySlug.get(post.slug))
1085
855
  .filter(Boolean)
1086
- .join('\n');
856
+ .map((post) => buildStructuredPostSummary(post));
1087
857
  }
1088
858
 
1089
- function renderArchive(posts, postBySlug) {
1090
- if (posts.length === 0) {
1091
- return '<p>No posts</p>';
1092
- }
1093
-
1094
- const groups = new Map();
1095
-
1096
- for (const post of posts) {
1097
- const key = `${toDate(post.published_at_iso).getUTCFullYear()}-${String(toDate(post.published_at_iso).getUTCMonth() + 1).padStart(2, '0')}`;
1098
- const items = groups.get(key) || [];
1099
- items.push(post);
1100
- groups.set(key, items);
1101
- }
1102
-
1103
- return Array.from(groups.entries())
1104
- .map(([key, groupPosts]) => {
1105
- const items = groupPosts
1106
- .map((post) => {
1107
- const prepared = postBySlug.get(post.slug);
1108
- if (!prepared) {
1109
- return '';
1110
- }
1111
-
1112
- 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>`;
1113
- })
1114
- .filter(Boolean)
1115
- .join('\n');
1116
-
1117
- return `<section class="archive-group"><h2>${escapeHtml(key)}</h2><ul>${items}</ul></section>`;
1118
- })
1119
- .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
+ };
1120
876
  }
1121
877
 
1122
- function renderCategoryLinks(categories, countBySlug) {
1123
- return categories
1124
- .map((category) => {
1125
- const count = countBySlug.get(category.slug) || 0;
1126
- if (count === 0) {
1127
- return null;
1128
- }
878
+ function buildAdjacentPostSummary(post) {
879
+ if (!post) {
880
+ return null;
881
+ }
1129
882
 
1130
- return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)} (${count})</a>`;
1131
- })
1132
- .filter(Boolean)
1133
- .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
+ };
1134
891
  }
1135
892
 
1136
- function renderTagLinks(tags, countBySlug) {
1137
- return tags
1138
- .map((tag) => {
1139
- const count = countBySlug.get(tag.slug) || 0;
1140
- if (count === 0) {
1141
- return null;
1142
- }
893
+ function buildArchiveGroups(posts, postBySlug) {
894
+ const groups = new Map();
1143
895
 
1144
- return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)} (${count})</a>`;
1145
- })
1146
- .filter(Boolean)
1147
- .join('\n');
1148
- }
896
+ for (const post of posts) {
897
+ const prepared = postBySlug.get(post.slug);
898
+ if (!prepared?.published_at_iso) {
899
+ continue;
900
+ }
1149
901
 
1150
- function renderInlineTaxonomyLinks(slugs, taxonomyBySlug, kind) {
1151
- const links = slugs
1152
- .map((slug) => taxonomyBySlug.get(slug))
1153
- .filter(Boolean)
1154
- .map((item) => {
1155
- const base = kind === 'category' ? '/categories/' : '/tags/';
1156
- const className = kind === 'category' ? 'category-link' : 'tag-link';
1157
- return `<a href="${base}${escapeHtml(encodeSlugSegment(item.slug))}/" class="${className}">${escapeHtml(item.name)}</a>`;
1158
- });
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
+ };
1159
912
 
1160
- if (links.length === 0) {
1161
- return '';
913
+ current.items.push(buildStructuredPostSummary(prepared));
914
+ groups.set(label, current);
1162
915
  }
1163
916
 
1164
- return kind === 'category' ? links.join(', ') : links.join(' ');
917
+ return Array.from(groups.values());
1165
918
  }
1166
919
 
1167
- function renderPagination(paginationData) {
1168
- if (paginationData.totalPages <= 1) {
1169
- return '';
1170
- }
1171
-
1172
- const prevLink = paginationData.hasPrev && paginationData.prevUrl
1173
- ? `<a href="${paginationData.prevUrl}" class="prev">Previous</a>`
1174
- : '';
1175
- const nextLink = paginationData.hasNext && paginationData.nextUrl
1176
- ? `<a href="${paginationData.nextUrl}" class="next">Next</a>`
1177
- : '';
1178
- const pageLinks = paginationData.pages
1179
- .map((page) => `<a href="${page.url}" class="page-link ${page.current ? 'current' : ''}">${page.number}</a>`)
1180
- .join(' ');
1181
-
1182
- 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
+ };
1183
927
  }
1184
928
 
1185
929
  function buildTaxonomyCountMap(posts, fieldName) {
@@ -1351,7 +1095,7 @@ function recordRouteEmission(state, templateName, route, currentUrl) {
1351
1095
  });
1352
1096
  }
1353
1097
 
1354
- async function assertValidThemePackage(themePackage) {
1098
+ async function normalizeAndValidateThemePackage(themePackage) {
1355
1099
  if (!themePackage?.templates || !themePackage?.partials || !themePackage?.assets || !themePackage?.metadata) {
1356
1100
  throw new Error('Invalid themePackage: expected metadata, templates, partials, and assets');
1357
1101
  }
@@ -1389,6 +1133,25 @@ async function assertValidThemePackage(themePackage) {
1389
1133
  if (!validation.ok) {
1390
1134
  throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
1391
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
+ };
1392
1155
  }
1393
1156
 
1394
1157
  async function buildAssetOutputs(assets, assetProcessor, options) {
package/src/index.d.ts CHANGED
@@ -1,29 +1,10 @@
1
1
  import type { PreviewDataV05 } from '@zeropress/preview-data-validator';
2
+ import type { ThemeManifest } from '@zeropress/theme-validator';
2
3
 
3
4
  export interface ThemePackage {
4
- metadata: {
5
- name: string;
6
- version: string;
7
- author?: string | null;
8
- description?: string;
5
+ metadata: ThemeManifest & {
9
6
  thumbnail?: string;
10
7
  settings?: Record<string, unknown>;
11
- features?: {
12
- comments?: boolean;
13
- newsletter?: boolean;
14
- };
15
- menuSlots?: Record<string, {
16
- title: string;
17
- description?: string;
18
- }>;
19
- widgetAreas?: Record<string, {
20
- title: string;
21
- description?: string;
22
- }>;
23
- namespace?: string;
24
- slug?: string;
25
- license?: 'MIT' | 'Apache-2.0' | 'BSD-3-Clause' | 'GPL-3.0-only' | 'GPL-3.0-or-later';
26
- runtime?: '0.3' | '0.4';
27
8
  };
28
9
  templates: Map<string, string>;
29
10
  partials: Map<string, string>;
@@ -0,0 +1,23 @@
1
+ export class PartialResolver {
2
+ static PARTIAL_PATTERN = /\{\{partial:([a-zA-Z_][a-zA-Z0-9_-]*(?:\/[a-zA-Z_][a-zA-Z0-9_-]*)*)\}\}/g;
3
+
4
+ resolve(template, partials) {
5
+ return this.resolveTemplate(String(template || ''), partials, []);
6
+ }
7
+
8
+ resolveTemplate(template, partials, activeStack) {
9
+ return template.replace(PartialResolver.PARTIAL_PATTERN, (_, partialName) => {
10
+ if (!partials.has(partialName)) {
11
+ throw new Error(`Partial "${partialName}" not found`);
12
+ }
13
+
14
+ if (activeStack.includes(partialName)) {
15
+ const cycle = [...activeStack, partialName].join(' -> ');
16
+ throw new Error(`Circular partial reference detected: ${cycle}`);
17
+ }
18
+
19
+ const partialContent = partials.get(partialName);
20
+ return this.resolveTemplate(String(partialContent || ''), partials, [...activeStack, partialName]);
21
+ });
22
+ }
23
+ }
@@ -2,7 +2,6 @@ export class VariableResolver {
2
2
  static MENU_PATTERN = /\{\{menu:([a-z][a-z0-9_-]{0,63})\}\}/g;
3
3
  static VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+)\}\}/g;
4
4
  static STANDALONE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
5
- static RAW_STANDALONE = new Set(['posts', 'categories', 'tags', 'pagination']);
6
5
 
7
6
  resolve(template, data, options = {}) {
8
7
  let result = template.replace(VariableResolver.MENU_PATTERN, (_, menuId) => {
@@ -95,10 +94,6 @@ export class VariableResolver {
95
94
  }
96
95
  }
97
96
 
98
- if (VariableResolver.RAW_STANDALONE.has(variablePath)) {
99
- return true;
100
- }
101
-
102
97
  const lastSegment = variablePath.split('.').pop();
103
98
  if (lastSegment === 'html' || lastSegment?.endsWith('_html')) {
104
99
  return true;
@@ -1,10 +1,12 @@
1
1
  import { SlotResolver } from './slot-resolver.js';
2
2
  import { VariableResolver } from './variable-resolver.js';
3
3
  import { ControlFlowRenderer } from './control-flow-renderer.js';
4
+ import { PartialResolver } from './partial-resolver.js';
4
5
 
5
6
  export class ZeroPressEngine {
6
7
  constructor() {
7
8
  this.slotResolver = new SlotResolver();
9
+ this.partialResolver = new PartialResolver();
8
10
  this.variableResolver = new VariableResolver();
9
11
  this.controlFlowRenderer = new ControlFlowRenderer({
10
12
  resolvePath: (data, path) => this.variableResolver.resolvePath(data, path),
@@ -33,9 +35,9 @@ export class ZeroPressEngine {
33
35
  }
34
36
 
35
37
  const renderData = this.combineRenderData(data, context);
36
- const renderedContent = this.renderTemplate(template, renderData);
38
+ const renderedContent = this.renderTemplate(this.expandPartials(template), renderData);
37
39
  const layoutWithSlots = this.slotResolver.resolve(layout, this.themePackage.partials, renderedContent);
38
- return this.renderTemplate(layoutWithSlots, renderData);
40
+ return this.renderTemplate(this.expandPartials(layoutWithSlots), renderData);
39
41
  }
40
42
 
41
43
  combineRenderData(data, context) {
@@ -58,4 +60,12 @@ export class ZeroPressEngine {
58
60
 
59
61
  return this.variableResolver.resolve(template, data);
60
62
  }
63
+
64
+ expandPartials(template) {
65
+ if (this.themePackage?.metadata?.runtime !== '0.4') {
66
+ return template;
67
+ }
68
+
69
+ return this.partialResolver.resolve(template, this.themePackage.partials);
70
+ }
61
71
  }
@@ -16,6 +16,10 @@ export async function loadThemePackageFromDir(themeDir) {
16
16
 
17
17
  const rawThemeJson = String(fileMap.get('theme.json'));
18
18
  const themeJson = JSON.parse(rawThemeJson);
19
+ const manifest = validation.manifest;
20
+ if (!manifest) {
21
+ throw new Error('Theme validation failed: normalized manifest not available');
22
+ }
19
23
  const templates = new Map();
20
24
  const partials = new Map();
21
25
  const assets = new Map();
@@ -45,19 +49,9 @@ export async function loadThemePackageFromDir(themeDir) {
45
49
 
46
50
  return {
47
51
  metadata: {
48
- name: themeJson.name,
49
- version: themeJson.version,
50
- author: themeJson.author,
51
- description: themeJson.description,
52
+ ...manifest,
52
53
  thumbnail: themeJson.thumbnail,
53
54
  settings: themeJson.settings || {},
54
- features: themeJson.features,
55
- menuSlots: themeJson.menuSlots,
56
- widgetAreas: themeJson.widgetAreas,
57
- namespace: themeJson.namespace,
58
- slug: themeJson.slug,
59
- license: themeJson.license,
60
- runtime: themeJson.runtime,
61
55
  },
62
56
  templates,
63
57
  partials,