@zeropress/build-core 0.1.14 → 0.1.15

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/README.md CHANGED
@@ -186,9 +186,13 @@ Build-core now derives:
186
186
  - taxonomy link HTML
187
187
  - formatted `published_at` / `updated_at`
188
188
  - `reading_time`
189
- - `comments_html`
189
+ - `comments_enabled`
190
190
 
191
- Comment shell rendering is derived from preview-data policy:
191
+ It also emits a comments allowlist artifact:
192
+
193
+ - `/_zeropress/comment-policy.json`
194
+
195
+ Comment availability is derived from preview-data policy:
192
196
 
193
197
  - `site.disallowComments`
194
198
  - `content.posts[].allow_comments`
@@ -216,7 +220,6 @@ Supported options:
216
220
 
217
221
  - `assetHashing`
218
222
  - `generateSpecialFiles`
219
- - `injectHtmx`
220
223
  - `writeManifest`
221
224
 
222
225
  These options apply to both full builds and partial renders where relevant.
@@ -225,7 +228,6 @@ Defaults:
225
228
 
226
229
  - `assetHashing: true`
227
230
  - `generateSpecialFiles: true`
228
- - `injectHtmx: true`
229
231
  - `writeManifest: false`
230
232
 
231
233
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,8 +34,8 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@zeropress/slug-policy": "^0.1.0",
37
- "@zeropress/preview-data-validator": "^0.4.3",
38
- "@zeropress/theme-validator": "^0.3.2",
37
+ "@zeropress/preview-data-validator": "^0.4.4",
38
+ "@zeropress/theme-validator": "^0.3.4",
39
39
  "highlight.js": "^11.11.1",
40
40
  "markdown-it": "^14.1.0",
41
41
  "markdown-it-anchor": "^9.2.0"
@@ -9,7 +9,6 @@ import { ZeroPressEngine } from '../render/zeropress-engine.js';
9
9
  const DEFAULT_OPTIONS = {
10
10
  assetHashing: true,
11
11
  generateSpecialFiles: true,
12
- injectHtmx: true,
13
12
  writeManifest: false,
14
13
  };
15
14
 
@@ -18,7 +17,12 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
18
17
  const DEFAULT_TIME_FORMAT = 'HH:mm';
19
18
  const DEFAULT_TIMEZONE = 'UTC';
20
19
  const DEFAULT_LOCALE = 'en-US';
20
+ const DEFAULT_THEME_RUNTIME = '0.3';
21
+ const THEME_RUNTIME_V0_4 = '0.4';
22
+ const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
21
23
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
24
+ const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
25
+ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
22
26
  export async function buildSite(input) {
23
27
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
24
28
  const state = await createBuildState(input, options);
@@ -58,6 +62,14 @@ export async function buildSite(input) {
58
62
  await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
59
63
  }
60
64
 
65
+ await writeOutput(
66
+ state.writer,
67
+ state.summaries,
68
+ COMMENT_POLICY_OUTPUT_PATH,
69
+ state.commentPolicyContent,
70
+ 'application/json',
71
+ );
72
+
61
73
  if (options.generateSpecialFiles) {
62
74
  await maybeRenderNotFoundPage(state);
63
75
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
@@ -84,7 +96,8 @@ async function createBuildState(input, options) {
84
96
  const summaries = [];
85
97
  const themePackage = input.themePackage;
86
98
  const previewData = normalizePreviewData(input.previewData);
87
- const renderData = createRenderData(previewData);
99
+ const renderData = createRenderData(previewData, themePackage.metadata);
100
+ const themeRuntime = normalizeThemeRuntime(themePackage.metadata?.runtime);
88
101
 
89
102
  engine.initialize(themePackage);
90
103
 
@@ -101,13 +114,15 @@ async function createBuildState(input, options) {
101
114
  writer: input.writer,
102
115
  previewData,
103
116
  renderData,
104
- renderedWidgets: renderWidgetAreas(previewData, renderData),
117
+ themeRuntime,
118
+ widgets: buildWidgetsForTheme(previewData, renderData, themeRuntime),
105
119
  engine,
106
120
  assetProcessor,
107
121
  summaries,
108
122
  assetOutputs,
109
123
  assetMap,
110
124
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
125
+ commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
111
126
  options,
112
127
  generatedAt: new Date(),
113
128
  emitted: {
@@ -149,7 +164,7 @@ async function renderRoute(state, templateName, route) {
149
164
  templateName,
150
165
  {
151
166
  menus: state.previewData.menus,
152
- widgets: state.renderedWidgets,
167
+ widgets: state.widgets,
153
168
  ...route,
154
169
  meta: buildPageMeta(state.previewData.site, {
155
170
  currentUrl,
@@ -172,7 +187,7 @@ async function renderPost(state, post) {
172
187
  'post',
173
188
  {
174
189
  menus: state.previewData.menus,
175
- widgets: state.renderedWidgets,
190
+ widgets: state.widgets,
176
191
  post,
177
192
  meta: buildPageMeta(state.previewData.site, {
178
193
  currentUrl,
@@ -188,9 +203,6 @@ async function renderPost(state, post) {
188
203
  );
189
204
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
190
205
  html = injectCustomCssAssetLink(html, state.customCssHref);
191
- if (state.options.injectHtmx) {
192
- html = injectHtmxScript(html);
193
- }
194
206
  await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
195
207
  state.emitted.posts.push({
196
208
  url: currentUrl,
@@ -208,7 +220,7 @@ async function renderPage(state, page) {
208
220
  'page',
209
221
  {
210
222
  menus: state.previewData.menus,
211
- widgets: state.renderedWidgets,
223
+ widgets: state.widgets,
212
224
  page,
213
225
  meta: buildPageMeta(state.previewData.site, {
214
226
  currentUrl,
@@ -240,7 +252,7 @@ async function maybeRenderNotFoundPage(state) {
240
252
  '404',
241
253
  {
242
254
  menus: state.previewData.menus,
243
- widgets: state.renderedWidgets,
255
+ widgets: state.widgets,
244
256
  meta: buildPageMeta(state.previewData.site, {
245
257
  currentUrl: '/404.html',
246
258
  title: state.previewData.site.title,
@@ -320,7 +332,7 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
320
332
  function normalizeWidgetItem(item, mediaBaseUrl) {
321
333
  const normalizedItem = {
322
334
  ...item,
323
- title: normalizeNonEmptyString(item?.title, 'Widget'),
335
+ title: typeof item?.title === 'string' ? item.title.trim() : '',
324
336
  };
325
337
 
326
338
  if (item?.type === 'profile' && item?.settings && typeof item.settings === 'object') {
@@ -341,7 +353,8 @@ function normalizeCustomCss(customCss) {
341
353
  return content ? { content } : undefined;
342
354
  }
343
355
 
344
- function createRenderData(previewData) {
356
+ function createRenderData(previewData, themeMetadata = {}) {
357
+ const themeSupportsComments = themeMetadata?.features?.comments === true;
345
358
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
346
359
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
347
360
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -362,7 +375,7 @@ function createRenderData(previewData) {
362
375
  }
363
376
  }
364
377
 
365
- const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
378
+ const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments));
366
379
  const pages = previewData.content.pages.map((page) => preparePage(page));
367
380
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
368
381
  const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
@@ -415,6 +428,245 @@ function createRenderData(previewData) {
415
428
  };
416
429
  }
417
430
 
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
+ function resolveWidgetAreas(previewData, renderData) {
444
+ if (!previewData.widgets || typeof previewData.widgets !== 'object') {
445
+ return {};
446
+ }
447
+
448
+ return Object.fromEntries(
449
+ Object.entries(previewData.widgets).map(([widgetAreaId, widgetArea]) => [
450
+ widgetAreaId,
451
+ resolveWidgetArea(widgetArea, previewData, renderData, widgetAreaId),
452
+ ]),
453
+ );
454
+ }
455
+
456
+ function resolveWidgetArea(widgetArea, previewData, renderData, widgetAreaId) {
457
+ if (!widgetArea || !Array.isArray(widgetArea.items)) {
458
+ return {
459
+ name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
460
+ items: [],
461
+ };
462
+ }
463
+
464
+ return {
465
+ name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
466
+ items: widgetArea.items
467
+ .map((item, index) => resolveWidgetItem(item, previewData, renderData, widgetAreaId, index))
468
+ .filter(Boolean),
469
+ };
470
+ }
471
+
472
+ function resolveWidgetItem(item, previewData, renderData, widgetAreaId, index) {
473
+ if (!item || typeof item !== 'object' || typeof item.type !== 'string') {
474
+ return null;
475
+ }
476
+
477
+ const baseWidget = {
478
+ id: `${widgetAreaId}-${index + 1}`,
479
+ type: item.type,
480
+ title: typeof item.title === 'string' ? item.title : '',
481
+ empty: false,
482
+ };
483
+
484
+ switch (item.type) {
485
+ case 'recent-posts':
486
+ return resolveRecentPostsWidget(baseWidget, item.settings, renderData);
487
+ case 'categories':
488
+ return resolveCategoriesWidget(baseWidget, item.settings, previewData);
489
+ case 'tags':
490
+ return resolveTagsWidget(baseWidget, item.settings, previewData);
491
+ case 'archives':
492
+ return resolveArchivesWidget(baseWidget, item.settings, previewData);
493
+ case 'text':
494
+ return resolveTextWidget(baseWidget, item.settings);
495
+ case 'link-list':
496
+ return resolveLinkListWidget(baseWidget, item.settings);
497
+ case 'search':
498
+ return resolveSearchWidget(baseWidget, item.settings, widgetAreaId, index);
499
+ case 'profile':
500
+ return resolveProfileWidget(baseWidget, item.settings);
501
+ default:
502
+ return null;
503
+ }
504
+ }
505
+
506
+ function resolveRecentPostsWidget(baseWidget, settings, renderData) {
507
+ const limit = clampInteger(settings?.limit, 5, 1, 20);
508
+ const items = renderData.posts
509
+ .slice(0, limit)
510
+ .map((post) => ({
511
+ title: post.title,
512
+ url: `/posts/${encodeSlugSegment(post.slug)}/`,
513
+ published_at: post.published_at,
514
+ published_at_iso: post.published_at_iso,
515
+ }));
516
+
517
+ if (items.length === 0) {
518
+ return null;
519
+ }
520
+
521
+ return {
522
+ ...baseWidget,
523
+ show_date: settings?.show_date !== false,
524
+ items,
525
+ };
526
+ }
527
+
528
+ function resolveCategoriesWidget(baseWidget, settings, previewData) {
529
+ const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'category_slugs');
530
+ const items = previewData.content.categories
531
+ .map((category) => ({
532
+ name: category.name,
533
+ slug: category.slug,
534
+ url: `/categories/${encodeSlugSegment(category.slug)}/`,
535
+ count: countBySlug.get(category.slug) || 0,
536
+ depth: 0,
537
+ }))
538
+ .filter((category) => category.count > 0);
539
+
540
+ if (items.length === 0) {
541
+ return null;
542
+ }
543
+
544
+ return {
545
+ ...baseWidget,
546
+ show_count: settings?.show_count === true,
547
+ hierarchical: settings?.hierarchical === true,
548
+ items,
549
+ };
550
+ }
551
+
552
+ function resolveTagsWidget(baseWidget, settings, previewData) {
553
+ const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'tag_slugs');
554
+ const limit = clampInteger(settings?.limit, 20, 1, 100);
555
+ const items = previewData.content.tags
556
+ .map((tag) => ({
557
+ name: tag.name,
558
+ slug: tag.slug,
559
+ url: `/tags/${encodeSlugSegment(tag.slug)}/`,
560
+ count: countBySlug.get(tag.slug) || 0,
561
+ }))
562
+ .filter((tag) => tag.count > 0)
563
+ .slice(0, limit);
564
+
565
+ if (items.length === 0) {
566
+ return null;
567
+ }
568
+
569
+ return {
570
+ ...baseWidget,
571
+ show_count: settings?.show_count === true,
572
+ items,
573
+ };
574
+ }
575
+
576
+ function resolveArchivesWidget(baseWidget, settings, previewData) {
577
+ const limit = clampInteger(settings?.limit, 12, 1, 120);
578
+ const items = buildArchiveEntries(previewData.content.posts, previewData.site)
579
+ .slice(0, limit)
580
+ .map((entry) => ({
581
+ label: entry.label,
582
+ url: '/archive/',
583
+ count: entry.count,
584
+ year: entry.year,
585
+ month: entry.month,
586
+ meta: `${entry.count} posts`,
587
+ }));
588
+
589
+ if (items.length === 0) {
590
+ return null;
591
+ }
592
+
593
+ return {
594
+ ...baseWidget,
595
+ items,
596
+ };
597
+ }
598
+
599
+ function resolveTextWidget(baseWidget, settings) {
600
+ const content = typeof settings?.content === 'string' ? settings.content : '';
601
+ const html = renderDocumentContent(content, normalizeDocumentType(settings?.document_type));
602
+
603
+ if (!normalizeOptionalString(html)) {
604
+ return null;
605
+ }
606
+
607
+ return {
608
+ ...baseWidget,
609
+ html,
610
+ };
611
+ }
612
+
613
+ function resolveLinkListWidget(baseWidget, settings) {
614
+ const items = (Array.isArray(settings?.links) ? settings.links : [])
615
+ .map((link) => {
616
+ const label = normalizeOptionalString(link?.label);
617
+ const url = normalizeThemeLinkUrl(link?.url);
618
+ if (!label || !url) {
619
+ return null;
620
+ }
621
+
622
+ const target = normalizeLinkTarget(link?.target);
623
+ return {
624
+ label,
625
+ url,
626
+ target,
627
+ rel: target === '_blank' ? 'noreferrer noopener' : '',
628
+ };
629
+ })
630
+ .filter(Boolean);
631
+
632
+ if (items.length === 0) {
633
+ return null;
634
+ }
635
+
636
+ return {
637
+ ...baseWidget,
638
+ items,
639
+ };
640
+ }
641
+
642
+ function resolveSearchWidget(baseWidget, settings, widgetAreaId, index) {
643
+ return {
644
+ ...baseWidget,
645
+ placeholder: normalizeNonEmptyString(settings?.placeholder, 'Search...'),
646
+ button_label: normalizeNonEmptyString(settings?.button_label, 'Search'),
647
+ dom_id: `widget-search-${widgetAreaId}-${index + 1}`,
648
+ };
649
+ }
650
+
651
+ function resolveProfileWidget(baseWidget, settings) {
652
+ const displayName = normalizeOptionalString(settings?.display_name);
653
+ const affiliation = normalizeOptionalString(settings?.affiliation);
654
+ const bioText = normalizeOptionalString(settings?.bio_short);
655
+ const avatarUrl = normalizeMediaField(settings?.avatar);
656
+
657
+ if (!displayName && !affiliation && !bioText && !avatarUrl) {
658
+ return null;
659
+ }
660
+
661
+ return {
662
+ ...baseWidget,
663
+ display_name: displayName,
664
+ affiliation,
665
+ avatar_url: avatarUrl,
666
+ bio_text: bioText,
667
+ };
668
+ }
669
+
418
670
  function renderWidgetAreas(previewData, renderData) {
419
671
  if (!previewData.widgets || typeof previewData.widgets !== 'object') {
420
672
  return {};
@@ -583,7 +835,7 @@ function renderLinkListWidget(title, settings) {
583
835
  const items = links
584
836
  .map((link) => {
585
837
  const label = normalizeOptionalString(link?.label);
586
- const url = normalizeOptionalString(link?.url);
838
+ const url = normalizeThemeLinkUrl(link?.url);
587
839
  if (!label || !url) {
588
840
  return '';
589
841
  }
@@ -672,14 +924,26 @@ function preparePage(page) {
672
924
  };
673
925
  }
674
926
 
675
- function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
927
+ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
676
928
  const documentType = normalizeDocumentType(post.document_type);
677
929
  const html = renderDocumentContent(post.content, documentType);
678
930
  const author = authorsById.get(post.author_id);
679
931
 
680
932
  return {
681
- ...post,
933
+ public_id: post.public_id,
934
+ title: post.title,
935
+ slug: post.slug,
936
+ content: post.content,
682
937
  document_type: documentType,
938
+ excerpt: post.excerpt,
939
+ published_at_iso: post.published_at_iso,
940
+ updated_at_iso: post.updated_at_iso,
941
+ author_id: post.author_id,
942
+ featured_image: post.featured_image,
943
+ status: post.status,
944
+ allow_comments: post.allow_comments,
945
+ category_slugs: post.category_slugs,
946
+ tag_slugs: post.tag_slugs,
683
947
  html,
684
948
  author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
685
949
  author_avatar: author?.avatar,
@@ -688,50 +952,20 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
688
952
  reading_time: calculateReadingTime(html),
689
953
  categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
690
954
  tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
691
- comments_html: renderCommentsContainer(site, post),
955
+ comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
692
956
  };
693
957
  }
694
958
 
695
- function renderCommentsContainer(site, post) {
696
- if (site.disallowComments === true || post.allow_comments !== true) {
697
- return '';
698
- }
959
+ function buildCommentPolicyManifest(posts) {
960
+ const commentablePosts = posts
961
+ .filter((post) => post.status === 'published' && post.comments_enabled === true)
962
+ .map((post) => post.public_id)
963
+ .filter((value) => Number.isInteger(value) && value > 0);
699
964
 
700
- const safePostId = encodeURIComponent(post.id);
701
- return `<section id="comments" class="zp-comments">
702
- <h2>댓글</h2>
703
- <div id="comment-list"
704
- hx-get="/api/comments/${safePostId}"
705
- hx-trigger="load"
706
- hx-swap="innerHTML">
707
- <p class="zp-comments-loading">댓글을 불러오는 중...</p>
708
- </div>
709
- <form id="comment-form"
710
- hx-post="/api/comments/${safePostId}"
711
- hx-target="#comment-form-result"
712
- hx-swap="innerHTML">
713
- <div id="comment-form-result"></div>
714
- <div>
715
- <label for="author_name">이름</label>
716
- <input type="text" id="author_name" name="author_name" required>
717
- </div>
718
- <div>
719
- <label for="author_email">이메일</label>
720
- <input type="email" id="author_email" name="author_email" required>
721
- </div>
722
- <div>
723
- <label for="content">댓글</label>
724
- <textarea id="content" name="content" required minlength="1" maxlength="5000"></textarea>
725
- </div>
726
- <div style="position:absolute;left:-9999px;" aria-hidden="true">
727
- <input type="text" name="website" tabindex="-1" autocomplete="off">
728
- </div>
729
- <button type="submit">댓글 작성</button>
730
- </form>
731
- <noscript>
732
- <p>댓글 기능을 사용하려면 JavaScript를 활성화해 주세요.</p>
733
- </noscript>
734
- </section>`;
965
+ return JSON.stringify({
966
+ version: 1,
967
+ commentable_posts: commentablePosts,
968
+ }, null, 2);
735
969
  }
736
970
 
737
971
  function buildTaxonomyRoutes(options) {
@@ -982,6 +1216,8 @@ function buildArchiveEntries(posts, site) {
982
1216
  .map((entry) => ({
983
1217
  label: formatArchiveLabel(entry.date, site),
984
1218
  count: entry.count,
1219
+ year: entry.date.getUTCFullYear(),
1220
+ month: entry.date.getUTCMonth() + 1,
985
1221
  }));
986
1222
  }
987
1223
 
@@ -1131,6 +1367,7 @@ async function assertValidThemePackage(themePackage) {
1131
1367
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
1132
1368
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
1133
1369
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1370
+ ...(themePackage.metadata.features ? { features: themePackage.metadata.features } : {}),
1134
1371
  ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1135
1372
  ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
1136
1373
  settings: themePackage.metadata.settings || {},
@@ -1292,7 +1529,7 @@ function resolveMetaImageUrl(image) {
1292
1529
  }
1293
1530
 
1294
1531
  if (isAbsoluteUrl(normalizedImage)) {
1295
- return normalizedImage;
1532
+ return normalizeAbsoluteUrl(normalizedImage, SAFE_MEDIA_PROTOCOLS);
1296
1533
  }
1297
1534
 
1298
1535
  return '';
@@ -1309,7 +1546,7 @@ function normalizeMediaField(value, mediaBaseUrl) {
1309
1546
  }
1310
1547
 
1311
1548
  if (isAbsoluteUrl(normalizedValue)) {
1312
- return normalizedValue;
1549
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
1313
1550
  }
1314
1551
 
1315
1552
  const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
@@ -1333,6 +1570,31 @@ function isAbsoluteUrl(value) {
1333
1570
  }
1334
1571
  }
1335
1572
 
1573
+ function normalizeAbsoluteUrl(value, allowedProtocols) {
1574
+ try {
1575
+ const url = new URL(value);
1576
+ if (!allowedProtocols.has(url.protocol)) {
1577
+ return '';
1578
+ }
1579
+ return decodeURI(url.toString());
1580
+ } catch {
1581
+ return '';
1582
+ }
1583
+ }
1584
+
1585
+ function normalizeThemeLinkUrl(value) {
1586
+ const normalizedValue = normalizeOptionalString(value);
1587
+ if (!normalizedValue || normalizedValue.startsWith('//')) {
1588
+ return '';
1589
+ }
1590
+
1591
+ if (isAbsoluteUrl(normalizedValue)) {
1592
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_LINK_PROTOCOLS);
1593
+ }
1594
+
1595
+ return normalizedValue;
1596
+ }
1597
+
1336
1598
  async function writeOutput(writer, summaries, path, content, contentType) {
1337
1599
  const rawPath = String(path || '');
1338
1600
  const normalizedPath = normalizeOutputPath(rawPath);
@@ -1391,6 +1653,7 @@ function assertPlannedOutputPathsSafe(state) {
1391
1653
  ...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
1392
1654
  ...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
1393
1655
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1656
+ COMMENT_POLICY_OUTPUT_PATH,
1394
1657
  ];
1395
1658
 
1396
1659
  if (state.options.generateSpecialFiles) {
@@ -1465,10 +1728,6 @@ function sha256(content) {
1465
1728
  return hash.digest('hex');
1466
1729
  }
1467
1730
 
1468
- function injectHtmxScript(html) {
1469
- return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
1470
- }
1471
-
1472
1731
  function injectCustomCssAssetLink(html, href) {
1473
1732
  if (!normalizeOptionalString(href)) {
1474
1733
  return html;
package/src/index.d.ts CHANGED
@@ -8,6 +8,10 @@ export interface ThemePackage {
8
8
  description?: string;
9
9
  thumbnail?: string;
10
10
  settings?: Record<string, unknown>;
11
+ features?: {
12
+ comments?: boolean;
13
+ newsletter?: boolean;
14
+ };
11
15
  menuSlots?: Record<string, {
12
16
  title: string;
13
17
  description?: string;
@@ -19,7 +23,7 @@ export interface ThemePackage {
19
23
  namespace?: string;
20
24
  slug?: string;
21
25
  license?: 'MIT' | 'Apache-2.0' | 'BSD-3-Clause' | 'GPL-3.0-only' | 'GPL-3.0-or-later';
22
- runtime?: '0.3';
26
+ runtime?: '0.3' | '0.4';
23
27
  };
24
28
  templates: Map<string, string>;
25
29
  partials: Map<string, string>;
@@ -39,7 +43,6 @@ export interface BuildWriter {
39
43
  export interface BuildOptions {
40
44
  assetHashing?: boolean;
41
45
  generateSpecialFiles?: boolean;
42
- injectHtmx?: boolean;
43
46
  writeManifest?: boolean;
44
47
  }
45
48
 
@@ -0,0 +1,221 @@
1
+ function isWhitespace(value) {
2
+ return /^[\s\r\n\t]*$/.test(value);
3
+ }
4
+
5
+ export class ControlFlowRenderer {
6
+ constructor(options = {}) {
7
+ this.resolvePath = typeof options.resolvePath === 'function'
8
+ ? options.resolvePath
9
+ : ((data, path) => path.split('.').reduce((current, segment) => {
10
+ if (current == null || typeof current !== 'object') {
11
+ return undefined;
12
+ }
13
+ return current[segment];
14
+ }, data));
15
+ this.renderText = typeof options.renderText === 'function'
16
+ ? options.renderText
17
+ : ((value) => value);
18
+ }
19
+
20
+ render(template, data, renderOptions = {}) {
21
+ const nodes = this.parse(template);
22
+ return this.renderNodes(nodes, data, renderOptions);
23
+ }
24
+
25
+ parse(template) {
26
+ const source = String(template || '');
27
+ const { nodes, nextIndex, stopTag } = this.parseNodes(source, 0, new Set());
28
+ if (stopTag) {
29
+ throw new Error(`Unexpected closing tag ${stopTag}`);
30
+ }
31
+ if (nextIndex !== source.length) {
32
+ throw new Error('Unexpected parser termination');
33
+ }
34
+ return nodes;
35
+ }
36
+
37
+ parseNodes(source, startIndex, stopTags) {
38
+ const nodes = [];
39
+ let index = startIndex;
40
+
41
+ while (index < source.length) {
42
+ const nextDelimiter = source.indexOf('{{', index);
43
+ if (nextDelimiter === -1) {
44
+ nodes.push({ type: 'text', value: source.slice(index) });
45
+ return { nodes, nextIndex: source.length, stopTag: null };
46
+ }
47
+
48
+ if (nextDelimiter > index) {
49
+ nodes.push({ type: 'text', value: source.slice(index, nextDelimiter) });
50
+ }
51
+
52
+ if (source.startsWith('{{!--', nextDelimiter)) {
53
+ const end = source.indexOf('--}}', nextDelimiter + 5);
54
+ if (end === -1) {
55
+ throw new Error('Unclosed block comment');
56
+ }
57
+ const commentBody = source.slice(nextDelimiter + 5, end);
58
+ if (commentBody.includes('{{!--')) {
59
+ throw new Error('Nested block comments are not allowed');
60
+ }
61
+ index = end + 4;
62
+ continue;
63
+ }
64
+
65
+ if (source.startsWith('{{!', nextDelimiter)) {
66
+ const end = source.indexOf('}}', nextDelimiter + 3);
67
+ if (end === -1) {
68
+ throw new Error('Unclosed inline comment');
69
+ }
70
+ index = end + 2;
71
+ continue;
72
+ }
73
+
74
+ const tokenEnd = source.indexOf('}}', nextDelimiter + 2);
75
+ if (tokenEnd === -1) {
76
+ throw new Error('Unclosed template tag');
77
+ }
78
+
79
+ const rawToken = source.slice(nextDelimiter, tokenEnd + 2);
80
+ const token = source.slice(nextDelimiter + 2, tokenEnd).trim();
81
+
82
+ if (token.startsWith('/')) {
83
+ const tagName = token.slice(1).trim();
84
+ if (!stopTags.has(tagName)) {
85
+ throw new Error(`Unexpected closing tag /${tagName}`);
86
+ }
87
+ return { nodes, nextIndex: tokenEnd + 2, stopTag: tagName };
88
+ }
89
+
90
+ if (token === '#else') {
91
+ if (!stopTags.has('else')) {
92
+ throw new Error('Unexpected else tag');
93
+ }
94
+ return { nodes, nextIndex: tokenEnd + 2, stopTag: 'else' };
95
+ }
96
+
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}`);
102
+ }
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');
117
+ }
118
+ nodes.push({ type: 'if_eq', path, literal, consequent: ifResult.nodes, alternate: elseNodes });
119
+ index = nextIndex;
120
+ continue;
121
+ }
122
+
123
+ 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;
143
+ continue;
144
+ }
145
+
146
+ if (token.startsWith('#for ')) {
147
+ 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);
149
+ if (!match) {
150
+ throw new Error(`Invalid for expression: ${expression}`);
151
+ }
152
+ const [, itemName, path] = match;
153
+ const forResult = this.parseNodes(source, tokenEnd + 2, new Set(['for']));
154
+ if (forResult.stopTag !== 'for') {
155
+ throw new Error('Unclosed for block');
156
+ }
157
+ nodes.push({ type: 'for', itemName, path, body: forResult.nodes });
158
+ index = forResult.nextIndex;
159
+ continue;
160
+ }
161
+
162
+ nodes.push({ type: 'text', value: rawToken });
163
+ index = tokenEnd + 2;
164
+ }
165
+
166
+ if (stopTags.size > 0) {
167
+ const expected = Array.from(stopTags).filter((entry) => entry !== 'else').join(', ');
168
+ throw new Error(`Missing closing tag for ${expected}`);
169
+ }
170
+
171
+ return { nodes, nextIndex: source.length, stopTag: null };
172
+ }
173
+
174
+ renderNodes(nodes, data, renderOptions) {
175
+ return nodes.map((node) => this.renderNode(node, data, renderOptions)).join('');
176
+ }
177
+
178
+ renderNode(node, data, renderOptions) {
179
+ switch (node.type) {
180
+ case 'text':
181
+ return this.renderText(node.value, data, renderOptions);
182
+ case 'if': {
183
+ const value = this.resolvePath(data, node.path);
184
+ return this.isTruthy(value)
185
+ ? this.renderNodes(node.consequent, data, renderOptions)
186
+ : this.renderNodes(node.alternate, data, renderOptions);
187
+ }
188
+ case 'if_eq': {
189
+ const value = this.resolvePath(data, node.path);
190
+ return value === node.literal
191
+ ? this.renderNodes(node.consequent, data, renderOptions)
192
+ : this.renderNodes(node.alternate, data, renderOptions);
193
+ }
194
+ case 'for': {
195
+ const items = this.resolvePath(data, node.path);
196
+ if (!Array.isArray(items) || items.length === 0) {
197
+ return '';
198
+ }
199
+ return items
200
+ .map((item, index) => this.renderNodes(node.body, {
201
+ ...data,
202
+ [node.itemName]: item,
203
+ $index: index,
204
+ }, renderOptions))
205
+ .join('');
206
+ }
207
+ default:
208
+ return '';
209
+ }
210
+ }
211
+
212
+ isTruthy(value) {
213
+ if (Array.isArray(value)) {
214
+ return value.length > 0;
215
+ }
216
+ if (typeof value === 'string') {
217
+ return value.length > 0;
218
+ }
219
+ return Boolean(value);
220
+ }
221
+ }
@@ -2,15 +2,16 @@ 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']);
5
6
 
6
- resolve(template, data) {
7
+ resolve(template, data, options = {}) {
7
8
  let result = template.replace(VariableResolver.MENU_PATTERN, (_, menuId) => {
8
9
  return this.renderMenu(data?.menus?.[menuId]);
9
10
  });
10
11
 
11
12
  result = result.replace(VariableResolver.VARIABLE_PATTERN, (_, variablePath) => {
12
13
  const value = this.resolvePath(data, variablePath);
13
- return value == null ? '' : String(value);
14
+ return this.renderValue(value, variablePath, options);
14
15
  });
15
16
 
16
17
  result = result.replace(VariableResolver.STANDALONE_PATTERN, (match, variableName) => {
@@ -19,7 +20,7 @@ export class VariableResolver {
19
20
  }
20
21
 
21
22
  const value = data[variableName];
22
- return value == null ? '' : String(value);
23
+ return this.renderValue(value, variableName, options);
23
24
  });
24
25
 
25
26
  return result;
@@ -64,11 +65,58 @@ export class VariableResolver {
64
65
  return `<li><a href="${url}" target="${target}"${rel}>${title}</a>${children}</li>`;
65
66
  }
66
67
 
68
+ renderValue(value, variablePath, options = {}) {
69
+ if (value == null) {
70
+ return '';
71
+ }
72
+
73
+ const stringValue = String(value);
74
+ if (options.escapeValues !== true) {
75
+ return stringValue;
76
+ }
77
+
78
+ if (this.shouldRenderRaw(variablePath, options)) {
79
+ return stringValue;
80
+ }
81
+
82
+ return this.escapeHtml(stringValue);
83
+ }
84
+
85
+ shouldRenderRaw(variablePath, options = {}) {
86
+ if (options.rawPaths instanceof Set && options.rawPaths.has(variablePath)) {
87
+ return true;
88
+ }
89
+
90
+ if (options.rawPathPrefixes instanceof Set) {
91
+ for (const prefix of options.rawPathPrefixes) {
92
+ if (variablePath === prefix || variablePath.startsWith(`${prefix}.`)) {
93
+ return true;
94
+ }
95
+ }
96
+ }
97
+
98
+ if (VariableResolver.RAW_STANDALONE.has(variablePath)) {
99
+ return true;
100
+ }
101
+
102
+ const lastSegment = variablePath.split('.').pop();
103
+ if (lastSegment === 'html' || lastSegment?.endsWith('_html')) {
104
+ return true;
105
+ }
106
+
107
+ if (lastSegment?.endsWith('_url')) {
108
+ return true;
109
+ }
110
+
111
+ return false;
112
+ }
113
+
67
114
  escapeHtml(value) {
68
115
  return value
69
116
  .replaceAll('&', '&amp;')
70
117
  .replaceAll('<', '&lt;')
71
118
  .replaceAll('>', '&gt;')
72
- .replaceAll('"', '&quot;');
119
+ .replaceAll('"', '&quot;')
120
+ .replaceAll("'", '&#39;');
73
121
  }
74
122
  }
@@ -1,10 +1,15 @@
1
1
  import { SlotResolver } from './slot-resolver.js';
2
2
  import { VariableResolver } from './variable-resolver.js';
3
+ import { ControlFlowRenderer } from './control-flow-renderer.js';
3
4
 
4
5
  export class ZeroPressEngine {
5
6
  constructor() {
6
7
  this.slotResolver = new SlotResolver();
7
8
  this.variableResolver = new VariableResolver();
9
+ this.controlFlowRenderer = new ControlFlowRenderer({
10
+ resolvePath: (data, path) => this.variableResolver.resolvePath(data, path),
11
+ renderText: (value, data, renderOptions) => this.variableResolver.resolve(value, data, renderOptions),
12
+ });
8
13
  this.themePackage = null;
9
14
  }
10
15
 
@@ -27,9 +32,10 @@ export class ZeroPressEngine {
27
32
  throw new Error('Template "layout" not found');
28
33
  }
29
34
 
30
- const renderedContent = this.variableResolver.resolve(template, this.combineRenderData(data, context));
35
+ const renderData = this.combineRenderData(data, context);
36
+ const renderedContent = this.renderTemplate(template, renderData);
31
37
  const layoutWithSlots = this.slotResolver.resolve(layout, this.themePackage.partials, renderedContent);
32
- return this.variableResolver.resolve(layoutWithSlots, this.combineRenderData(data, context));
38
+ return this.renderTemplate(layoutWithSlots, renderData);
33
39
  }
34
40
 
35
41
  combineRenderData(data, context) {
@@ -40,4 +46,16 @@ export class ZeroPressEngine {
40
46
  language: context.language,
41
47
  };
42
48
  }
49
+
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
+ });
57
+ }
58
+
59
+ return this.variableResolver.resolve(template, data);
60
+ }
43
61
  }
@@ -51,6 +51,7 @@ export async function loadThemePackageFromDir(themeDir) {
51
51
  description: themeJson.description,
52
52
  thumbnail: themeJson.thumbnail,
53
53
  settings: themeJson.settings || {},
54
+ features: themeJson.features,
54
55
  menuSlots: themeJson.menuSlots,
55
56
  widgetAreas: themeJson.widgetAreas,
56
57
  namespace: themeJson.namespace,