@zeropress/build-core 0.1.13 → 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.13",
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,
@@ -272,7 +284,7 @@ function normalizePreviewData(previewData) {
272
284
  return {
273
285
  ...previewData,
274
286
  site: normalizedSite,
275
- widgets: normalizeWidgetAreas(previewData.widgets),
287
+ widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
276
288
  custom_css: normalizeCustomCss(previewData.custom_css),
277
289
  content: {
278
290
  ...previewData.content,
@@ -298,7 +310,7 @@ function normalizePreviewData(previewData) {
298
310
  };
299
311
  }
300
312
 
301
- function normalizeWidgetAreas(widgetAreas) {
313
+ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
302
314
  if (!widgetAreas || typeof widgetAreas !== 'object') {
303
315
  return {};
304
316
  }
@@ -310,22 +322,39 @@ function normalizeWidgetAreas(widgetAreas) {
310
322
  ...widgetArea,
311
323
  name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
312
324
  items: Array.isArray(widgetArea?.items)
313
- ? widgetArea.items.map((item) => ({
314
- ...item,
315
- title: normalizeNonEmptyString(item?.title, 'Widget'),
316
- }))
325
+ ? widgetArea.items.map((item) => normalizeWidgetItem(item, mediaBaseUrl))
317
326
  : [],
318
327
  },
319
328
  ]),
320
329
  );
321
330
  }
322
331
 
332
+ function normalizeWidgetItem(item, mediaBaseUrl) {
333
+ const normalizedItem = {
334
+ ...item,
335
+ title: typeof item?.title === 'string' ? item.title.trim() : '',
336
+ };
337
+
338
+ if (item?.type === 'profile' && item?.settings && typeof item.settings === 'object') {
339
+ return {
340
+ ...normalizedItem,
341
+ settings: {
342
+ ...item.settings,
343
+ avatar: normalizeMediaField(item.settings.avatar, mediaBaseUrl),
344
+ },
345
+ };
346
+ }
347
+
348
+ return normalizedItem;
349
+ }
350
+
323
351
  function normalizeCustomCss(customCss) {
324
352
  const content = normalizeOptionalString(customCss?.content);
325
353
  return content ? { content } : undefined;
326
354
  }
327
355
 
328
- function createRenderData(previewData) {
356
+ function createRenderData(previewData, themeMetadata = {}) {
357
+ const themeSupportsComments = themeMetadata?.features?.comments === true;
329
358
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
330
359
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
331
360
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -346,7 +375,7 @@ function createRenderData(previewData) {
346
375
  }
347
376
  }
348
377
 
349
- 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));
350
379
  const pages = previewData.content.pages.map((page) => preparePage(page));
351
380
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
352
381
  const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
@@ -399,6 +428,245 @@ function createRenderData(previewData) {
399
428
  };
400
429
  }
401
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
+
402
670
  function renderWidgetAreas(previewData, renderData) {
403
671
  if (!previewData.widgets || typeof previewData.widgets !== 'object') {
404
672
  return {};
@@ -567,7 +835,7 @@ function renderLinkListWidget(title, settings) {
567
835
  const items = links
568
836
  .map((link) => {
569
837
  const label = normalizeOptionalString(link?.label);
570
- const url = normalizeOptionalString(link?.url);
838
+ const url = normalizeThemeLinkUrl(link?.url);
571
839
  if (!label || !url) {
572
840
  return '';
573
841
  }
@@ -656,14 +924,26 @@ function preparePage(page) {
656
924
  };
657
925
  }
658
926
 
659
- function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
927
+ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
660
928
  const documentType = normalizeDocumentType(post.document_type);
661
929
  const html = renderDocumentContent(post.content, documentType);
662
930
  const author = authorsById.get(post.author_id);
663
931
 
664
932
  return {
665
- ...post,
933
+ public_id: post.public_id,
934
+ title: post.title,
935
+ slug: post.slug,
936
+ content: post.content,
666
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,
667
947
  html,
668
948
  author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
669
949
  author_avatar: author?.avatar,
@@ -672,50 +952,20 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
672
952
  reading_time: calculateReadingTime(html),
673
953
  categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
674
954
  tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
675
- comments_html: renderCommentsContainer(site, post),
955
+ comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
676
956
  };
677
957
  }
678
958
 
679
- function renderCommentsContainer(site, post) {
680
- if (site.disallowComments === true || post.allow_comments !== true) {
681
- return '';
682
- }
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);
683
964
 
684
- const safePostId = encodeURIComponent(post.id);
685
- return `<section id="comments" class="zp-comments">
686
- <h2>댓글</h2>
687
- <div id="comment-list"
688
- hx-get="/api/comments/${safePostId}"
689
- hx-trigger="load"
690
- hx-swap="innerHTML">
691
- <p class="zp-comments-loading">댓글을 불러오는 중...</p>
692
- </div>
693
- <form id="comment-form"
694
- hx-post="/api/comments/${safePostId}"
695
- hx-target="#comment-form-result"
696
- hx-swap="innerHTML">
697
- <div id="comment-form-result"></div>
698
- <div>
699
- <label for="author_name">이름</label>
700
- <input type="text" id="author_name" name="author_name" required>
701
- </div>
702
- <div>
703
- <label for="author_email">이메일</label>
704
- <input type="email" id="author_email" name="author_email" required>
705
- </div>
706
- <div>
707
- <label for="content">댓글</label>
708
- <textarea id="content" name="content" required minlength="1" maxlength="5000"></textarea>
709
- </div>
710
- <div style="position:absolute;left:-9999px;" aria-hidden="true">
711
- <input type="text" name="website" tabindex="-1" autocomplete="off">
712
- </div>
713
- <button type="submit">댓글 작성</button>
714
- </form>
715
- <noscript>
716
- <p>댓글 기능을 사용하려면 JavaScript를 활성화해 주세요.</p>
717
- </noscript>
718
- </section>`;
965
+ return JSON.stringify({
966
+ version: 1,
967
+ commentable_posts: commentablePosts,
968
+ }, null, 2);
719
969
  }
720
970
 
721
971
  function buildTaxonomyRoutes(options) {
@@ -966,6 +1216,8 @@ function buildArchiveEntries(posts, site) {
966
1216
  .map((entry) => ({
967
1217
  label: formatArchiveLabel(entry.date, site),
968
1218
  count: entry.count,
1219
+ year: entry.date.getUTCFullYear(),
1220
+ month: entry.date.getUTCMonth() + 1,
969
1221
  }));
970
1222
  }
971
1223
 
@@ -1115,6 +1367,7 @@ async function assertValidThemePackage(themePackage) {
1115
1367
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
1116
1368
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
1117
1369
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1370
+ ...(themePackage.metadata.features ? { features: themePackage.metadata.features } : {}),
1118
1371
  ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1119
1372
  ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
1120
1373
  settings: themePackage.metadata.settings || {},
@@ -1276,7 +1529,7 @@ function resolveMetaImageUrl(image) {
1276
1529
  }
1277
1530
 
1278
1531
  if (isAbsoluteUrl(normalizedImage)) {
1279
- return normalizedImage;
1532
+ return normalizeAbsoluteUrl(normalizedImage, SAFE_MEDIA_PROTOCOLS);
1280
1533
  }
1281
1534
 
1282
1535
  return '';
@@ -1293,7 +1546,7 @@ function normalizeMediaField(value, mediaBaseUrl) {
1293
1546
  }
1294
1547
 
1295
1548
  if (isAbsoluteUrl(normalizedValue)) {
1296
- return normalizedValue;
1549
+ return normalizeAbsoluteUrl(normalizedValue, SAFE_MEDIA_PROTOCOLS);
1297
1550
  }
1298
1551
 
1299
1552
  const normalizedBaseUrl = normalizeOptionalString(mediaBaseUrl);
@@ -1317,6 +1570,31 @@ function isAbsoluteUrl(value) {
1317
1570
  }
1318
1571
  }
1319
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
+
1320
1598
  async function writeOutput(writer, summaries, path, content, contentType) {
1321
1599
  const rawPath = String(path || '');
1322
1600
  const normalizedPath = normalizeOutputPath(rawPath);
@@ -1375,6 +1653,7 @@ function assertPlannedOutputPathsSafe(state) {
1375
1653
  ...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
1376
1654
  ...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
1377
1655
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1656
+ COMMENT_POLICY_OUTPUT_PATH,
1378
1657
  ];
1379
1658
 
1380
1659
  if (state.options.generateSpecialFiles) {
@@ -1449,10 +1728,6 @@ function sha256(content) {
1449
1728
  return hash.digest('hex');
1450
1729
  }
1451
1730
 
1452
- function injectHtmxScript(html) {
1453
- return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
1454
- }
1455
-
1456
1731
  function injectCustomCssAssetLink(html, href) {
1457
1732
  if (!normalizeOptionalString(href)) {
1458
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,