@zeropress/build-core 0.1.15 → 0.2.0

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.2.0",
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.5.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,161 @@ 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>';
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
+ window: buildPaginationWindow(paginationData),
850
+ };
851
+ }
852
+
853
+ function buildPaginationWindow(paginationData) {
854
+ const totalPages = paginationData.totalPages;
855
+ if (!Number.isInteger(totalPages) || totalPages <= 0) {
856
+ return [];
1068
857
  }
1069
858
 
1070
- return posts
1071
- .map((post) => {
1072
- const prepared = postBySlug.get(post.slug);
1073
- if (!prepared) {
1074
- return '';
1075
- }
859
+ const currentPage = paginationData.currentPage;
860
+ const pageMap = new Map(
861
+ paginationData.pages.map((page) => [page.number, {
862
+ kind: 'page',
863
+ number: page.number,
864
+ url: page.url,
865
+ current: page.current,
866
+ }]),
867
+ );
1076
868
 
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
- })
1085
- .filter(Boolean)
1086
- .join('\n');
1087
- }
869
+ if (totalPages <= 7) {
870
+ return paginationData.pages.map((page) => ({
871
+ kind: 'page',
872
+ number: page.number,
873
+ url: page.url,
874
+ current: page.current,
875
+ }));
876
+ }
1088
877
 
1089
- function renderArchive(posts, postBySlug) {
1090
- if (posts.length === 0) {
1091
- return '<p>No posts</p>';
878
+ const pageNumbers = new Set([1, totalPages, currentPage - 1, currentPage, currentPage + 1]);
879
+
880
+ if (currentPage <= 4) {
881
+ for (let number = 1; number <= 5; number += 1) {
882
+ pageNumbers.add(number);
883
+ }
1092
884
  }
1093
885
 
1094
- const groups = new Map();
886
+ if (currentPage >= totalPages - 3) {
887
+ for (let number = totalPages - 4; number <= totalPages; number += 1) {
888
+ pageNumbers.add(number);
889
+ }
890
+ }
1095
891
 
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');
1120
- }
892
+ const orderedNumbers = Array.from(pageNumbers)
893
+ .filter((number) => Number.isInteger(number) && number >= 1 && number <= totalPages)
894
+ .sort((left, right) => left - right);
1121
895
 
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
- }
896
+ const windowItems = [];
897
+ let previousNumber = null;
1129
898
 
1130
- return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)} (${count})</a>`;
1131
- })
1132
- .filter(Boolean)
1133
- .join('\n');
1134
- }
899
+ for (const number of orderedNumbers) {
900
+ if (previousNumber != null && number - previousNumber > 1) {
901
+ windowItems.push({ kind: 'gap' });
902
+ }
1135
903
 
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
- }
904
+ const pageItem = pageMap.get(number);
905
+ if (pageItem) {
906
+ windowItems.push(pageItem);
907
+ }
908
+ previousNumber = number;
909
+ }
1143
910
 
1144
- return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)} (${count})</a>`;
1145
- })
1146
- .filter(Boolean)
1147
- .join('\n');
911
+ return windowItems;
1148
912
  }
1149
913
 
1150
- function renderInlineTaxonomyLinks(slugs, taxonomyBySlug, kind) {
1151
- const links = slugs
1152
- .map((slug) => taxonomyBySlug.get(slug))
914
+ function buildStructuredPostItems(posts, postBySlug) {
915
+ return posts
916
+ .map((post) => postBySlug.get(post.slug))
1153
917
  .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
- });
918
+ .map((post) => buildStructuredPostSummary(post));
919
+ }
1159
920
 
1160
- if (links.length === 0) {
1161
- return '';
921
+ function buildStructuredPostSummary(post) {
922
+ return {
923
+ title: post.title,
924
+ slug: post.slug,
925
+ url: post.url,
926
+ excerpt: post.excerpt,
927
+ published_at: post.published_at,
928
+ published_at_iso: post.published_at_iso,
929
+ reading_time: post.reading_time,
930
+ featured_image: post.featured_image,
931
+ author: {
932
+ display_name: post.author?.display_name || '',
933
+ avatar: post.author?.avatar || '',
934
+ },
935
+ categories: Array.isArray(post.categories) ? post.categories.map((category) => ({ ...category })) : [],
936
+ tags: Array.isArray(post.tags) ? post.tags.map((tag) => ({ ...tag })) : [],
937
+ };
938
+ }
939
+
940
+ function buildAdjacentPostSummary(post) {
941
+ if (!post) {
942
+ return null;
1162
943
  }
1163
944
 
1164
- return kind === 'category' ? links.join(', ') : links.join(' ');
945
+ return {
946
+ title: post.title,
947
+ slug: post.slug,
948
+ url: post.url,
949
+ excerpt: post.excerpt,
950
+ published_at: post.published_at,
951
+ published_at_iso: post.published_at_iso,
952
+ };
1165
953
  }
1166
954
 
1167
- function renderPagination(paginationData) {
1168
- if (paginationData.totalPages <= 1) {
1169
- return '';
955
+ function buildArchiveGroups(posts, postBySlug) {
956
+ const groups = new Map();
957
+
958
+ for (const post of posts) {
959
+ const prepared = postBySlug.get(post.slug);
960
+ if (!prepared?.published_at_iso) {
961
+ continue;
962
+ }
963
+
964
+ const date = toDate(prepared.published_at_iso);
965
+ const year = date.getUTCFullYear();
966
+ const month = date.getUTCMonth() + 1;
967
+ const label = `${year}-${String(month).padStart(2, '0')}`;
968
+ const current = groups.get(label) || {
969
+ label,
970
+ year,
971
+ month,
972
+ items: [],
973
+ };
974
+
975
+ current.items.push(buildStructuredPostSummary(prepared));
976
+ groups.set(label, current);
1170
977
  }
1171
978
 
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(' ');
979
+ return Array.from(groups.values());
980
+ }
1181
981
 
1182
- return `<nav class="pagination">\n ${prevLink}\n <span class="pages">${pageLinks}</span>\n ${nextLink}\n</nav>`;
982
+ function buildTaxonomyRouteData(kind, item, countBySlug) {
983
+ return {
984
+ kind,
985
+ slug: item.slug,
986
+ name: item.name,
987
+ count: countBySlug.get(item.slug) || 0,
988
+ };
1183
989
  }
1184
990
 
1185
991
  function buildTaxonomyCountMap(posts, fieldName) {
@@ -1351,7 +1157,7 @@ function recordRouteEmission(state, templateName, route, currentUrl) {
1351
1157
  });
1352
1158
  }
1353
1159
 
1354
- async function assertValidThemePackage(themePackage) {
1160
+ async function normalizeAndValidateThemePackage(themePackage) {
1355
1161
  if (!themePackage?.templates || !themePackage?.partials || !themePackage?.assets || !themePackage?.metadata) {
1356
1162
  throw new Error('Invalid themePackage: expected metadata, templates, partials, and assets');
1357
1163
  }
@@ -1389,6 +1195,25 @@ async function assertValidThemePackage(themePackage) {
1389
1195
  if (!validation.ok) {
1390
1196
  throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
1391
1197
  }
1198
+
1199
+ if (!validation.manifest) {
1200
+ throw new Error('Theme validation failed: normalized manifest not available');
1201
+ }
1202
+
1203
+ return {
1204
+ metadata: normalizeThemePackageMetadata(themePackage.metadata, validation.manifest),
1205
+ templates: themePackage.templates,
1206
+ partials: themePackage.partials,
1207
+ assets: themePackage.assets,
1208
+ };
1209
+ }
1210
+
1211
+ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
1212
+ return {
1213
+ ...manifest,
1214
+ ...(sourceMetadata?.thumbnail ? { thumbnail: sourceMetadata.thumbnail } : {}),
1215
+ settings: sourceMetadata?.settings || {},
1216
+ };
1392
1217
  }
1393
1218
 
1394
1219
  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>;
@@ -1,6 +1,8 @@
1
- function isWhitespace(value) {
2
- return /^[\s\r\n\t]*$/.test(value);
3
- }
1
+ import { parsePartialToken } from './partial-resolver.js';
2
+
3
+ const PATH_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*$/;
4
+ const FOR_EXPRESSION_REGEX = /^([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)$/;
5
+ const IF_EQ_EXPRESSION_REGEX = /^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+("(?:[^"\\]|\\.)*")$/;
4
6
 
5
7
  export class ControlFlowRenderer {
6
8
  constructor(options = {}) {
@@ -18,6 +20,13 @@ export class ControlFlowRenderer {
18
20
  }
19
21
 
20
22
  render(template, data, renderOptions = {}) {
23
+ return this.renderTemplate(template, data, {
24
+ ...renderOptions,
25
+ partialStack: Array.isArray(renderOptions.partialStack) ? renderOptions.partialStack : [],
26
+ });
27
+ }
28
+
29
+ renderTemplate(template, data, renderOptions) {
21
30
  const nodes = this.parse(template);
22
31
  return this.renderNodes(nodes, data, renderOptions);
23
32
  }
@@ -94,58 +103,46 @@ export class ControlFlowRenderer {
94
103
  return { nodes, nextIndex: tokenEnd + 2, stopTag: 'else' };
95
104
  }
96
105
 
97
- if (token.startsWith('#if_eq ')) {
98
- const expression = token.slice('#if_eq '.length).trim();
99
- const match = /^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+("(?:[^"\\]|\\.)*")$/.exec(expression);
100
- if (!match) {
101
- throw new Error(`Invalid if_eq expression: ${expression}`);
106
+ if (token.startsWith('#else_if_eq ')) {
107
+ if (!stopTags.has('else_if_eq')) {
108
+ throw new Error('Unexpected else_if_eq tag');
102
109
  }
103
- const [, path, literalSource] = match;
104
- const literal = JSON.parse(literalSource);
105
- const ifResult = this.parseNodes(source, tokenEnd + 2, new Set(['else', 'if_eq']));
106
- let elseNodes = [];
107
- let nextIndex = ifResult.nextIndex;
108
- if (ifResult.stopTag === 'else') {
109
- const elseResult = this.parseNodes(source, ifResult.nextIndex, new Set(['if_eq']));
110
- if (elseResult.stopTag !== 'if_eq') {
111
- throw new Error('Unclosed if_eq block after else');
112
- }
113
- elseNodes = elseResult.nodes;
114
- nextIndex = elseResult.nextIndex;
115
- } else if (ifResult.stopTag !== 'if_eq') {
116
- throw new Error('Unclosed if_eq block');
110
+ return { nodes, nextIndex: tokenEnd + 2, stopTag: token };
111
+ }
112
+
113
+ if (token.startsWith('#else_if ')) {
114
+ if (!stopTags.has('else_if')) {
115
+ throw new Error('Unexpected else_if tag');
117
116
  }
118
- nodes.push({ type: 'if_eq', path, literal, consequent: ifResult.nodes, alternate: elseNodes });
119
- index = nextIndex;
117
+ return { nodes, nextIndex: tokenEnd + 2, stopTag: token };
118
+ }
119
+
120
+ if (token.startsWith('partial:')) {
121
+ const { name, args } = parsePartialToken(token);
122
+ nodes.push({ type: 'partial', name, args });
123
+ index = tokenEnd + 2;
124
+ continue;
125
+ }
126
+
127
+ if (token.startsWith('#if_eq ')) {
128
+ const expression = token.slice('#if_eq '.length).trim();
129
+ const block = this.parseIfEqBlock(source, tokenEnd + 2, expression);
130
+ nodes.push(block.node);
131
+ index = block.nextIndex;
120
132
  continue;
121
133
  }
122
134
 
123
135
  if (token.startsWith('#if ')) {
124
- const path = token.slice('#if '.length).trim();
125
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*$/.test(path)) {
126
- throw new Error(`Invalid if expression: ${path}`);
127
- }
128
- const ifResult = this.parseNodes(source, tokenEnd + 2, new Set(['else', 'if']));
129
- let elseNodes = [];
130
- let nextIndex = ifResult.nextIndex;
131
- if (ifResult.stopTag === 'else') {
132
- const elseResult = this.parseNodes(source, ifResult.nextIndex, new Set(['if']));
133
- if (elseResult.stopTag !== 'if') {
134
- throw new Error('Unclosed if block after else');
135
- }
136
- elseNodes = elseResult.nodes;
137
- nextIndex = elseResult.nextIndex;
138
- } else if (ifResult.stopTag !== 'if') {
139
- throw new Error('Unclosed if block');
140
- }
141
- nodes.push({ type: 'if', path, consequent: ifResult.nodes, alternate: elseNodes });
142
- index = nextIndex;
136
+ const expression = token.slice('#if '.length).trim();
137
+ const block = this.parseIfBlock(source, tokenEnd + 2, expression);
138
+ nodes.push(block.node);
139
+ index = block.nextIndex;
143
140
  continue;
144
141
  }
145
142
 
146
143
  if (token.startsWith('#for ')) {
147
144
  const expression = token.slice('#for '.length).trim();
148
- const match = /^([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)$/.exec(expression);
145
+ const match = FOR_EXPRESSION_REGEX.exec(expression);
149
146
  if (!match) {
150
147
  throw new Error(`Invalid for expression: ${expression}`);
151
148
  }
@@ -164,13 +161,132 @@ export class ControlFlowRenderer {
164
161
  }
165
162
 
166
163
  if (stopTags.size > 0) {
167
- const expected = Array.from(stopTags).filter((entry) => entry !== 'else').join(', ');
164
+ const expected = Array.from(stopTags).join(', ');
168
165
  throw new Error(`Missing closing tag for ${expected}`);
169
166
  }
170
167
 
171
168
  return { nodes, nextIndex: source.length, stopTag: null };
172
169
  }
173
170
 
171
+ parseIfBlock(source, startIndex, initialPath) {
172
+ if (!PATH_REGEX.test(initialPath)) {
173
+ throw new Error(`Invalid if expression: ${initialPath}`);
174
+ }
175
+
176
+ const branches = [];
177
+ let alternate = [];
178
+ let nextIndex = startIndex;
179
+ let currentPath = initialPath;
180
+
181
+ while (true) {
182
+ const branchResult = this.parseNodes(source, nextIndex, new Set(['else', 'else_if', 'if']));
183
+ branches.push({
184
+ path: currentPath,
185
+ consequent: branchResult.nodes,
186
+ });
187
+
188
+ if (branchResult.stopTag === 'else') {
189
+ const elseResult = this.parseNodes(source, branchResult.nextIndex, new Set(['if']));
190
+ if (elseResult.stopTag !== 'if') {
191
+ throw new Error('Unclosed if block after else');
192
+ }
193
+ alternate = elseResult.nodes;
194
+ nextIndex = elseResult.nextIndex;
195
+ break;
196
+ }
197
+
198
+ if (typeof branchResult.stopTag === 'string' && branchResult.stopTag.startsWith('#else_if ')) {
199
+ currentPath = branchResult.stopTag.slice('#else_if '.length).trim();
200
+ if (!PATH_REGEX.test(currentPath)) {
201
+ throw new Error(`Invalid else_if expression: ${currentPath}`);
202
+ }
203
+ nextIndex = branchResult.nextIndex;
204
+ continue;
205
+ }
206
+
207
+ if (branchResult.stopTag === 'if') {
208
+ nextIndex = branchResult.nextIndex;
209
+ break;
210
+ }
211
+
212
+ throw new Error('Unclosed if block');
213
+ }
214
+
215
+ return {
216
+ node: {
217
+ type: 'if',
218
+ branches,
219
+ alternate,
220
+ },
221
+ nextIndex,
222
+ };
223
+ }
224
+
225
+ parseIfEqBlock(source, startIndex, expression) {
226
+ const initialBranch = this.parseIfEqBranchExpression(expression, 'if_eq');
227
+ const branches = [];
228
+ let alternate = [];
229
+ let nextIndex = startIndex;
230
+ let currentBranch = initialBranch;
231
+
232
+ while (true) {
233
+ const branchResult = this.parseNodes(source, nextIndex, new Set(['else', 'else_if_eq', 'if_eq']));
234
+ branches.push({
235
+ path: currentBranch.path,
236
+ literal: currentBranch.literal,
237
+ consequent: branchResult.nodes,
238
+ });
239
+
240
+ if (branchResult.stopTag === 'else') {
241
+ const elseResult = this.parseNodes(source, branchResult.nextIndex, new Set(['if_eq']));
242
+ if (elseResult.stopTag !== 'if_eq') {
243
+ throw new Error('Unclosed if_eq block after else');
244
+ }
245
+ alternate = elseResult.nodes;
246
+ nextIndex = elseResult.nextIndex;
247
+ break;
248
+ }
249
+
250
+ if (typeof branchResult.stopTag === 'string' && branchResult.stopTag.startsWith('#else_if_eq ')) {
251
+ currentBranch = this.parseIfEqBranchExpression(
252
+ branchResult.stopTag.slice('#else_if_eq '.length).trim(),
253
+ 'else_if_eq',
254
+ );
255
+ nextIndex = branchResult.nextIndex;
256
+ continue;
257
+ }
258
+
259
+ if (branchResult.stopTag === 'if_eq') {
260
+ nextIndex = branchResult.nextIndex;
261
+ break;
262
+ }
263
+
264
+ throw new Error('Unclosed if_eq block');
265
+ }
266
+
267
+ return {
268
+ node: {
269
+ type: 'if_eq',
270
+ branches,
271
+ alternate,
272
+ },
273
+ nextIndex,
274
+ };
275
+ }
276
+
277
+ parseIfEqBranchExpression(expression, tagName) {
278
+ const match = IF_EQ_EXPRESSION_REGEX.exec(expression);
279
+ if (!match) {
280
+ throw new Error(`Invalid ${tagName} expression: ${expression}`);
281
+ }
282
+
283
+ const [, path, literalSource] = match;
284
+ return {
285
+ path,
286
+ literal: JSON.parse(literalSource),
287
+ };
288
+ }
289
+
174
290
  renderNodes(nodes, data, renderOptions) {
175
291
  return nodes.map((node) => this.renderNode(node, data, renderOptions)).join('');
176
292
  }
@@ -180,15 +296,15 @@ export class ControlFlowRenderer {
180
296
  case 'text':
181
297
  return this.renderText(node.value, data, renderOptions);
182
298
  case 'if': {
183
- const value = this.resolvePath(data, node.path);
184
- return this.isTruthy(value)
185
- ? this.renderNodes(node.consequent, data, renderOptions)
299
+ const branch = node.branches.find((entry) => this.isTruthy(this.resolvePath(data, entry.path)));
300
+ return branch
301
+ ? this.renderNodes(branch.consequent, data, renderOptions)
186
302
  : this.renderNodes(node.alternate, data, renderOptions);
187
303
  }
188
304
  case 'if_eq': {
189
- const value = this.resolvePath(data, node.path);
190
- return value === node.literal
191
- ? this.renderNodes(node.consequent, data, renderOptions)
305
+ const branch = node.branches.find((entry) => this.resolvePath(data, entry.path) === entry.literal);
306
+ return branch
307
+ ? this.renderNodes(branch.consequent, data, renderOptions)
192
308
  : this.renderNodes(node.alternate, data, renderOptions);
193
309
  }
194
310
  case 'for': {
@@ -200,15 +316,45 @@ export class ControlFlowRenderer {
200
316
  .map((item, index) => this.renderNodes(node.body, {
201
317
  ...data,
202
318
  [node.itemName]: item,
203
- $index: index,
319
+ loop: {
320
+ first: index === 0,
321
+ last: index === items.length - 1,
322
+ index,
323
+ },
204
324
  }, renderOptions))
205
325
  .join('');
206
326
  }
327
+ case 'partial':
328
+ return this.renderPartial(node, data, renderOptions);
207
329
  default:
208
330
  return '';
209
331
  }
210
332
  }
211
333
 
334
+ renderPartial(node, data, renderOptions) {
335
+ const partials = renderOptions?.partials;
336
+ if (!partials || !partials.has(node.name)) {
337
+ throw new Error(`Partial "${node.name}" not found`);
338
+ }
339
+
340
+ const activeStack = Array.isArray(renderOptions?.partialStack) ? renderOptions.partialStack : [];
341
+ if (activeStack.includes(node.name)) {
342
+ const cycle = [...activeStack, node.name].join(' -> ');
343
+ throw new Error(`Circular partial reference detected: ${cycle}`);
344
+ }
345
+
346
+ const partialTemplate = String(partials.get(node.name) || '');
347
+ const partialData = {
348
+ ...data,
349
+ partial: { ...node.args },
350
+ };
351
+
352
+ return this.renderTemplate(partialTemplate, partialData, {
353
+ ...renderOptions,
354
+ partialStack: [...activeStack, node.name],
355
+ });
356
+ }
357
+
212
358
  isTruthy(value) {
213
359
  if (Array.isArray(value)) {
214
360
  return value.length > 0;
@@ -0,0 +1,114 @@
1
+ const PARTIAL_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\/[a-zA-Z_][a-zA-Z0-9_-]*)*$/;
2
+ const IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
3
+
4
+ export function parsePartialToken(token) {
5
+ const source = String(token || '').trim();
6
+ if (!source.startsWith('partial:')) {
7
+ throw new Error(`Invalid partial token: ${source}`);
8
+ }
9
+
10
+ const expression = source.slice('partial:'.length).trim();
11
+ const nameMatch = /^([a-zA-Z_][a-zA-Z0-9_-]*(?:\/[a-zA-Z_][a-zA-Z0-9_-]*)*)(?:\s+|$)/.exec(expression);
12
+ if (!nameMatch) {
13
+ throw new Error(`Invalid partial reference: ${source}`);
14
+ }
15
+
16
+ const name = nameMatch[1];
17
+ if (!PARTIAL_NAME_REGEX.test(name)) {
18
+ throw new Error(`Invalid partial name: ${name}`);
19
+ }
20
+
21
+ const argsSource = expression.slice(name.length).trim();
22
+ const args = parsePartialArgs(argsSource);
23
+
24
+ return { name, args };
25
+ }
26
+
27
+ function parsePartialArgs(source) {
28
+ if (!source) {
29
+ return {};
30
+ }
31
+
32
+ const args = {};
33
+ let index = 0;
34
+
35
+ while (index < source.length) {
36
+ while (index < source.length && /\s/.test(source[index])) {
37
+ index += 1;
38
+ }
39
+
40
+ if (index >= source.length) {
41
+ break;
42
+ }
43
+
44
+ const keyMatch = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(source.slice(index));
45
+ if (!keyMatch) {
46
+ throw new Error(`Invalid partial argument syntax near "${source.slice(index)}"`);
47
+ }
48
+
49
+ const key = keyMatch[0];
50
+ if (!IDENTIFIER_REGEX.test(key)) {
51
+ throw new Error(`Invalid partial argument key: ${key}`);
52
+ }
53
+ if (Object.prototype.hasOwnProperty.call(args, key)) {
54
+ throw new Error(`Duplicate partial argument: ${key}`);
55
+ }
56
+
57
+ index += key.length;
58
+ if (source[index] !== '=') {
59
+ throw new Error(`Expected "=" after partial argument "${key}"`);
60
+ }
61
+ index += 1;
62
+
63
+ if (source[index] === '"') {
64
+ let cursor = index + 1;
65
+ let escaped = false;
66
+
67
+ while (cursor < source.length) {
68
+ const char = source[cursor];
69
+ if (escaped) {
70
+ escaped = false;
71
+ cursor += 1;
72
+ continue;
73
+ }
74
+ if (char === '\\') {
75
+ escaped = true;
76
+ cursor += 1;
77
+ continue;
78
+ }
79
+ if (char === '"') {
80
+ break;
81
+ }
82
+ cursor += 1;
83
+ }
84
+
85
+ if (cursor >= source.length || source[cursor] !== '"') {
86
+ throw new Error(`Unclosed string literal for partial argument "${key}"`);
87
+ }
88
+
89
+ args[key] = JSON.parse(source.slice(index, cursor + 1));
90
+ index = cursor + 1;
91
+ continue;
92
+ }
93
+
94
+ if (source.startsWith('true', index) && isValueBoundary(source, index + 4)) {
95
+ args[key] = true;
96
+ index += 4;
97
+ continue;
98
+ }
99
+
100
+ if (source.startsWith('false', index) && isValueBoundary(source, index + 5)) {
101
+ args[key] = false;
102
+ index += 5;
103
+ continue;
104
+ }
105
+
106
+ throw new Error(`Unsupported partial argument value for "${key}"`);
107
+ }
108
+
109
+ return args;
110
+ }
111
+
112
+ function isValueBoundary(source, index) {
113
+ return index >= source.length || /\s/.test(source[index]);
114
+ }
@@ -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;
@@ -48,14 +48,15 @@ export class ZeroPressEngine {
48
48
  }
49
49
 
50
50
  renderTemplate(template, data) {
51
- if (this.themePackage?.metadata?.runtime === '0.4') {
52
- return this.controlFlowRenderer.render(template, data, {
53
- escapeValues: true,
54
- rawPaths: new Set(['meta.head_tags']),
55
- rawPathPrefixes: new Set(['meta']),
56
- });
51
+ if (this.themePackage?.metadata?.runtime !== '0.5') {
52
+ throw new Error(`Unsupported theme runtime: ${this.themePackage?.metadata?.runtime || 'unknown'}`);
57
53
  }
58
54
 
59
- return this.variableResolver.resolve(template, data);
55
+ return this.controlFlowRenderer.render(template, data, {
56
+ escapeValues: true,
57
+ rawPaths: new Set(['meta.head_tags']),
58
+ rawPathPrefixes: new Set(['meta']),
59
+ partials: this.themePackage.partials,
60
+ });
60
61
  }
61
62
  }
@@ -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,