@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 +6 -4
- package/package.json +3 -3
- package/src/api/build-site.js +342 -67
- 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,
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
955
|
+
comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
|
|
676
956
|
};
|
|
677
957
|
}
|
|
678
958
|
|
|
679
|
-
function
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
|
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,
|