@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 +6 -4
- package/package.json +3 -3
- package/src/api/build-site.js +321 -62
- package/src/index.d.ts +5 -2
- package/src/render/control-flow-renderer.js +221 -0
- package/src/render/variable-resolver.js +52 -4
- package/src/render/zeropress-engine.js +20 -2
- package/src/theme/load-theme-dir.js +1 -0
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
|
-
- `
|
|
189
|
+
- `comments_enabled`
|
|
190
190
|
|
|
191
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@zeropress/theme-validator": "^0.3.
|
|
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"
|
package/src/api/build-site.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
955
|
+
comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
|
|
692
956
|
};
|
|
693
957
|
}
|
|
694
958
|
|
|
695
|
-
function
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
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
|
|
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('&', '&')
|
|
70
117
|
.replaceAll('<', '<')
|
|
71
118
|
.replaceAll('>', '>')
|
|
72
|
-
.replaceAll('"', '"')
|
|
119
|
+
.replaceAll('"', '"')
|
|
120
|
+
.replaceAll("'", ''');
|
|
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
|
|
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.
|
|
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,
|