@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 +2 -2
- package/src/api/build-site.js +207 -382
- package/src/index.d.ts +2 -21
- package/src/render/control-flow-renderer.js +198 -52
- package/src/render/partial-resolver.js +114 -0
- package/src/render/variable-resolver.js +0 -5
- package/src/render/zeropress-engine.js +8 -7
- package/src/theme/load-theme-dir.js +5 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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"
|
package/src/api/build-site.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
397
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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:
|
|
993
|
-
pagination:
|
|
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
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
886
|
+
if (currentPage >= totalPages - 3) {
|
|
887
|
+
for (let number = totalPages - 4; number <= totalPages; number += 1) {
|
|
888
|
+
pageNumbers.add(number);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
1095
891
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
899
|
+
for (const number of orderedNumbers) {
|
|
900
|
+
if (previousNumber != null && number - previousNumber > 1) {
|
|
901
|
+
windowItems.push({ kind: 'gap' });
|
|
902
|
+
}
|
|
1135
903
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
}
|
|
904
|
+
const pageItem = pageMap.get(number);
|
|
905
|
+
if (pageItem) {
|
|
906
|
+
windowItems.push(pageItem);
|
|
907
|
+
}
|
|
908
|
+
previousNumber = number;
|
|
909
|
+
}
|
|
1143
910
|
|
|
1144
|
-
|
|
1145
|
-
})
|
|
1146
|
-
.filter(Boolean)
|
|
1147
|
-
.join('\n');
|
|
911
|
+
return windowItems;
|
|
1148
912
|
}
|
|
1149
913
|
|
|
1150
|
-
function
|
|
1151
|
-
|
|
1152
|
-
.map((
|
|
914
|
+
function buildStructuredPostItems(posts, postBySlug) {
|
|
915
|
+
return posts
|
|
916
|
+
.map((post) => postBySlug.get(post.slug))
|
|
1153
917
|
.filter(Boolean)
|
|
1154
|
-
.map((
|
|
1155
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
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
|
|
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
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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('#
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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 =
|
|
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).
|
|
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
|
|
184
|
-
return
|
|
185
|
-
? this.renderNodes(
|
|
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
|
|
190
|
-
return
|
|
191
|
-
? this.renderNodes(
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|