@zeropress/build-core 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/api/build-site.js +469 -19
- package/src/index.d.ts +4 -0
- package/src/theme/load-theme-dir.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Shared ZeroPress build core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"node": ">=18.18.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zeropress/
|
|
37
|
-
"@zeropress/
|
|
36
|
+
"@zeropress/slug-policy": "^0.1.0",
|
|
37
|
+
"@zeropress/preview-data-validator": "^0.4.3",
|
|
38
|
+
"@zeropress/theme-validator": "^0.3.2",
|
|
38
39
|
"highlight.js": "^11.11.1",
|
|
39
40
|
"markdown-it": "^14.1.0",
|
|
40
41
|
"markdown-it-anchor": "^9.2.0"
|
package/src/api/build-site.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { assertPreviewData } from '@zeropress/preview-data-validator';
|
|
3
|
+
import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
|
|
3
4
|
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
4
5
|
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
5
6
|
import { renderDocumentContent } from '../render/content-renderer.js';
|
|
@@ -17,9 +18,11 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
|
|
17
18
|
const DEFAULT_TIME_FORMAT = 'HH:mm';
|
|
18
19
|
const DEFAULT_TIMEZONE = 'UTC';
|
|
19
20
|
const DEFAULT_LOCALE = 'en-US';
|
|
21
|
+
const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
|
|
20
22
|
export async function buildSite(input) {
|
|
21
23
|
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
22
24
|
const state = await createBuildState(input, options);
|
|
25
|
+
assertPlannedOutputPathsSafe(state);
|
|
23
26
|
|
|
24
27
|
for (const route of state.renderData.indexRoutes) {
|
|
25
28
|
await renderRoute(state, 'index', route);
|
|
@@ -81,10 +84,15 @@ async function createBuildState(input, options) {
|
|
|
81
84
|
const summaries = [];
|
|
82
85
|
const themePackage = input.themePackage;
|
|
83
86
|
const previewData = normalizePreviewData(input.previewData);
|
|
87
|
+
const renderData = createRenderData(previewData);
|
|
84
88
|
|
|
85
89
|
engine.initialize(themePackage);
|
|
86
90
|
|
|
87
91
|
const assetOutputs = await buildAssetOutputs(themePackage.assets, assetProcessor, options);
|
|
92
|
+
const customCssAsset = await buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
|
|
93
|
+
if (customCssAsset) {
|
|
94
|
+
assetOutputs.push(customCssAsset);
|
|
95
|
+
}
|
|
88
96
|
const assetMap = new Map(
|
|
89
97
|
assetOutputs.map((asset) => [`/assets/${asset.originalPath}`, `/${asset.path}`]),
|
|
90
98
|
);
|
|
@@ -92,12 +100,14 @@ async function createBuildState(input, options) {
|
|
|
92
100
|
return {
|
|
93
101
|
writer: input.writer,
|
|
94
102
|
previewData,
|
|
95
|
-
renderData
|
|
103
|
+
renderData,
|
|
104
|
+
renderedWidgets: renderWidgetAreas(previewData, renderData),
|
|
96
105
|
engine,
|
|
97
106
|
assetProcessor,
|
|
98
107
|
summaries,
|
|
99
108
|
assetOutputs,
|
|
100
109
|
assetMap,
|
|
110
|
+
customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
|
|
101
111
|
options,
|
|
102
112
|
generatedAt: new Date(),
|
|
103
113
|
emitted: {
|
|
@@ -139,6 +149,7 @@ async function renderRoute(state, templateName, route) {
|
|
|
139
149
|
templateName,
|
|
140
150
|
{
|
|
141
151
|
menus: state.previewData.menus,
|
|
152
|
+
widgets: state.renderedWidgets,
|
|
142
153
|
...route,
|
|
143
154
|
meta: buildPageMeta(state.previewData.site, {
|
|
144
155
|
currentUrl,
|
|
@@ -150,6 +161,7 @@ async function renderRoute(state, templateName, route) {
|
|
|
150
161
|
createRenderContext(state.previewData.site, currentUrl),
|
|
151
162
|
);
|
|
152
163
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
164
|
+
html = injectCustomCssAssetLink(html, state.customCssHref);
|
|
153
165
|
await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
|
|
154
166
|
recordRouteEmission(state, templateName, route, currentUrl);
|
|
155
167
|
}
|
|
@@ -160,6 +172,7 @@ async function renderPost(state, post) {
|
|
|
160
172
|
'post',
|
|
161
173
|
{
|
|
162
174
|
menus: state.previewData.menus,
|
|
175
|
+
widgets: state.renderedWidgets,
|
|
163
176
|
post,
|
|
164
177
|
meta: buildPageMeta(state.previewData.site, {
|
|
165
178
|
currentUrl,
|
|
@@ -174,6 +187,7 @@ async function renderPost(state, post) {
|
|
|
174
187
|
createRenderContext(state.previewData.site, currentUrl),
|
|
175
188
|
);
|
|
176
189
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
190
|
+
html = injectCustomCssAssetLink(html, state.customCssHref);
|
|
177
191
|
if (state.options.injectHtmx) {
|
|
178
192
|
html = injectHtmxScript(html);
|
|
179
193
|
}
|
|
@@ -194,6 +208,7 @@ async function renderPage(state, page) {
|
|
|
194
208
|
'page',
|
|
195
209
|
{
|
|
196
210
|
menus: state.previewData.menus,
|
|
211
|
+
widgets: state.renderedWidgets,
|
|
197
212
|
page,
|
|
198
213
|
meta: buildPageMeta(state.previewData.site, {
|
|
199
214
|
currentUrl,
|
|
@@ -206,6 +221,7 @@ async function renderPage(state, page) {
|
|
|
206
221
|
createRenderContext(state.previewData.site, currentUrl),
|
|
207
222
|
);
|
|
208
223
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
224
|
+
html = injectCustomCssAssetLink(html, state.customCssHref);
|
|
209
225
|
await writeOutput(state.writer, state.summaries, `${encodeSlugSegment(page.slug)}/index.html`, html, 'text/html');
|
|
210
226
|
state.emitted.pages.push({
|
|
211
227
|
url: currentUrl,
|
|
@@ -224,6 +240,7 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
224
240
|
'404',
|
|
225
241
|
{
|
|
226
242
|
menus: state.previewData.menus,
|
|
243
|
+
widgets: state.renderedWidgets,
|
|
227
244
|
meta: buildPageMeta(state.previewData.site, {
|
|
228
245
|
currentUrl: '/404.html',
|
|
229
246
|
title: state.previewData.site.title,
|
|
@@ -234,6 +251,7 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
234
251
|
createRenderContext(state.previewData.site, '/404.html'),
|
|
235
252
|
);
|
|
236
253
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
254
|
+
html = injectCustomCssAssetLink(html, state.customCssHref);
|
|
237
255
|
await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
|
|
238
256
|
}
|
|
239
257
|
|
|
@@ -254,6 +272,8 @@ function normalizePreviewData(previewData) {
|
|
|
254
272
|
return {
|
|
255
273
|
...previewData,
|
|
256
274
|
site: normalizedSite,
|
|
275
|
+
widgets: normalizeWidgetAreas(previewData.widgets),
|
|
276
|
+
custom_css: normalizeCustomCss(previewData.custom_css),
|
|
257
277
|
content: {
|
|
258
278
|
...previewData.content,
|
|
259
279
|
authors: previewData.content.authors.map((author) => ({
|
|
@@ -278,6 +298,33 @@ function normalizePreviewData(previewData) {
|
|
|
278
298
|
};
|
|
279
299
|
}
|
|
280
300
|
|
|
301
|
+
function normalizeWidgetAreas(widgetAreas) {
|
|
302
|
+
if (!widgetAreas || typeof widgetAreas !== 'object') {
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return Object.fromEntries(
|
|
307
|
+
Object.entries(widgetAreas).map(([widgetAreaId, widgetArea]) => [
|
|
308
|
+
widgetAreaId,
|
|
309
|
+
{
|
|
310
|
+
...widgetArea,
|
|
311
|
+
name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
|
|
312
|
+
items: Array.isArray(widgetArea?.items)
|
|
313
|
+
? widgetArea.items.map((item) => ({
|
|
314
|
+
...item,
|
|
315
|
+
title: normalizeNonEmptyString(item?.title, 'Widget'),
|
|
316
|
+
}))
|
|
317
|
+
: [],
|
|
318
|
+
},
|
|
319
|
+
]),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeCustomCss(customCss) {
|
|
324
|
+
const content = normalizeOptionalString(customCss?.content);
|
|
325
|
+
return content ? { content } : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
281
328
|
function createRenderData(previewData) {
|
|
282
329
|
const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
|
|
283
330
|
const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
|
|
@@ -352,6 +399,253 @@ function createRenderData(previewData) {
|
|
|
352
399
|
};
|
|
353
400
|
}
|
|
354
401
|
|
|
402
|
+
function renderWidgetAreas(previewData, renderData) {
|
|
403
|
+
if (!previewData.widgets || typeof previewData.widgets !== 'object') {
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return Object.fromEntries(
|
|
408
|
+
Object.entries(previewData.widgets).map(([widgetAreaId, widgetArea]) => [
|
|
409
|
+
widgetAreaId,
|
|
410
|
+
renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId),
|
|
411
|
+
]),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId) {
|
|
416
|
+
if (!widgetArea || !Array.isArray(widgetArea.items) || widgetArea.items.length === 0) {
|
|
417
|
+
return '';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return widgetArea.items
|
|
421
|
+
.map((item, index) => renderWidgetItem(item, previewData, renderData, widgetAreaId, index))
|
|
422
|
+
.filter(Boolean)
|
|
423
|
+
.join('\n');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderWidgetItem(item, previewData, renderData, widgetAreaId, index) {
|
|
427
|
+
if (!item || typeof item !== 'object') {
|
|
428
|
+
return '';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const title = normalizeNonEmptyString(item.title, 'Widget');
|
|
432
|
+
|
|
433
|
+
switch (item.type) {
|
|
434
|
+
case 'recent-posts':
|
|
435
|
+
return renderRecentPostsWidget(title, item.settings, renderData);
|
|
436
|
+
case 'categories':
|
|
437
|
+
return renderCategoriesWidget(title, item.settings, previewData);
|
|
438
|
+
case 'tags':
|
|
439
|
+
return renderTagsWidget(title, item.settings, previewData);
|
|
440
|
+
case 'archives':
|
|
441
|
+
return renderArchivesWidget(title, item.settings, previewData);
|
|
442
|
+
case 'image':
|
|
443
|
+
return renderImageWidget(title, item.settings);
|
|
444
|
+
case 'text':
|
|
445
|
+
return renderTextWidget(title, item.settings);
|
|
446
|
+
case 'link-list':
|
|
447
|
+
return renderLinkListWidget(title, item.settings);
|
|
448
|
+
case 'search':
|
|
449
|
+
return renderSearchWidget(title, item.settings, widgetAreaId, index);
|
|
450
|
+
case 'profile':
|
|
451
|
+
return renderProfileWidget(title, item.settings);
|
|
452
|
+
default:
|
|
453
|
+
return '';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function renderRecentPostsWidget(title, settings, renderData) {
|
|
458
|
+
const limit = clampInteger(settings?.limit, 5, 1, 20);
|
|
459
|
+
const showDate = settings?.show_date !== false;
|
|
460
|
+
const items = renderData.posts
|
|
461
|
+
.slice(0, limit)
|
|
462
|
+
.map((post) => {
|
|
463
|
+
const dateMeta = showDate
|
|
464
|
+
? `<span class="widget-list__meta">${escapeHtml(post.published_at)}</span>`
|
|
465
|
+
: '';
|
|
466
|
+
|
|
467
|
+
return `<li class="widget-list__item"><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a>${dateMeta}</li>`;
|
|
468
|
+
})
|
|
469
|
+
.join('');
|
|
470
|
+
|
|
471
|
+
if (!items) {
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return renderWidgetCard(title, 'recent-posts', `<ul class="widget-list">${items}</ul>`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function renderCategoriesWidget(title, settings, previewData) {
|
|
479
|
+
const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'category_slugs');
|
|
480
|
+
const items = previewData.content.categories
|
|
481
|
+
.map((category) => {
|
|
482
|
+
const count = countBySlug.get(category.slug) || 0;
|
|
483
|
+
if (count === 0) {
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${count})</span>` : '';
|
|
488
|
+
return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)}${suffix}</a>`;
|
|
489
|
+
})
|
|
490
|
+
.filter(Boolean)
|
|
491
|
+
.join('');
|
|
492
|
+
|
|
493
|
+
if (!items) {
|
|
494
|
+
return '';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return renderWidgetCard(title, 'categories', `<div class="taxonomy-list taxonomy-list--stack widget-taxonomy">${items}</div>`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderTagsWidget(title, settings, previewData) {
|
|
501
|
+
const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'tag_slugs');
|
|
502
|
+
const limit = clampInteger(settings?.limit, 20, 1, 100);
|
|
503
|
+
const items = previewData.content.tags
|
|
504
|
+
.map((tag) => ({
|
|
505
|
+
...tag,
|
|
506
|
+
count: countBySlug.get(tag.slug) || 0,
|
|
507
|
+
}))
|
|
508
|
+
.filter((tag) => tag.count > 0)
|
|
509
|
+
.slice(0, limit)
|
|
510
|
+
.map((tag) => {
|
|
511
|
+
const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${tag.count})</span>` : '';
|
|
512
|
+
return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)}${suffix}</a>`;
|
|
513
|
+
})
|
|
514
|
+
.join('');
|
|
515
|
+
|
|
516
|
+
if (!items) {
|
|
517
|
+
return '';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return renderWidgetCard(title, 'tags', `<div class="taxonomy-list widget-taxonomy">${items}</div>`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function renderArchivesWidget(title, settings, previewData) {
|
|
524
|
+
const limit = clampInteger(settings?.limit, 12, 1, 120);
|
|
525
|
+
const archiveEntries = buildArchiveEntries(previewData.content.posts, previewData.site).slice(0, limit);
|
|
526
|
+
const items = archiveEntries
|
|
527
|
+
.map((entry) => `<li class="widget-list__item"><a href="/archive/">${escapeHtml(entry.label)}</a><span class="widget-list__meta">${entry.count} posts</span></li>`)
|
|
528
|
+
.join('');
|
|
529
|
+
|
|
530
|
+
if (!items) {
|
|
531
|
+
return '';
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return renderWidgetCard(title, 'archives', `<ul class="widget-list">${items}</ul>`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function renderImageWidget(title, settings) {
|
|
538
|
+
const src = normalizeOptionalString(settings?.src);
|
|
539
|
+
if (!src) {
|
|
540
|
+
return '';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const alt = escapeHtml(normalizeOptionalString(settings?.alt));
|
|
544
|
+
const caption = normalizeOptionalString(settings?.caption);
|
|
545
|
+
const href = normalizeOptionalString(settings?.href);
|
|
546
|
+
const target = normalizeLinkTarget(settings?.target);
|
|
547
|
+
const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
|
|
548
|
+
const image = `<img class="widget-image__media" src="${escapeHtml(src)}" alt="${alt}">`;
|
|
549
|
+
const linkedImage = href ? `<a href="${escapeHtml(href)}" target="${target}"${rel}>${image}</a>` : image;
|
|
550
|
+
const captionHtml = caption ? `<figcaption class="widget-image__caption">${escapeHtml(caption)}</figcaption>` : '';
|
|
551
|
+
|
|
552
|
+
return renderWidgetCard(title, 'image', `<figure class="widget-image">${linkedImage}${captionHtml}</figure>`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function renderTextWidget(title, settings) {
|
|
556
|
+
const content = typeof settings?.content === 'string' ? settings.content : '';
|
|
557
|
+
const html = renderDocumentContent(content, normalizeDocumentType(settings?.document_type));
|
|
558
|
+
if (!normalizeOptionalString(html)) {
|
|
559
|
+
return '';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return renderWidgetCard(title, 'text', `<div class="widget-copy">${html}</div>`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function renderLinkListWidget(title, settings) {
|
|
566
|
+
const links = Array.isArray(settings?.links) ? settings.links : [];
|
|
567
|
+
const items = links
|
|
568
|
+
.map((link) => {
|
|
569
|
+
const label = normalizeOptionalString(link?.label);
|
|
570
|
+
const url = normalizeOptionalString(link?.url);
|
|
571
|
+
if (!label || !url) {
|
|
572
|
+
return '';
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const target = normalizeLinkTarget(link?.target);
|
|
576
|
+
const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
|
|
577
|
+
return `<a href="${escapeHtml(url)}" target="${target}"${rel}>${escapeHtml(label)}</a>`;
|
|
578
|
+
})
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.join('');
|
|
581
|
+
|
|
582
|
+
if (!items) {
|
|
583
|
+
return '';
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return renderWidgetCard(title, 'link-list', `<div class="taxonomy-list taxonomy-list--stack widget-link-list">${items}</div>`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function renderSearchWidget(title, settings, widgetAreaId, index) {
|
|
590
|
+
const placeholder = escapeHtml(normalizeNonEmptyString(settings?.placeholder, 'Search...'));
|
|
591
|
+
const buttonLabel = escapeHtml(normalizeNonEmptyString(settings?.button_label, 'Search'));
|
|
592
|
+
const searchId = `widget-search-${widgetAreaId}-${index + 1}`;
|
|
593
|
+
|
|
594
|
+
return renderWidgetCard(title, 'search', [
|
|
595
|
+
'<div class="widget-search" data-search-root>',
|
|
596
|
+
` <label class="sr-only" for="${escapeHtml(searchId)}">${escapeHtml(title)}</label>`,
|
|
597
|
+
' <form class="search-theme-wrapper" data-search-form>',
|
|
598
|
+
` <input id="${escapeHtml(searchId)}" class="search-input" data-theme-search type="search" placeholder="${placeholder}" autocomplete="off" enterkeyhint="search">`,
|
|
599
|
+
` <button class="widget-search-button" type="submit">${buttonLabel}</button>`,
|
|
600
|
+
' </form>',
|
|
601
|
+
' <div class="search-results" data-search-results hidden></div>',
|
|
602
|
+
' <p class="search-feedback search-feedback--sidebar" data-search-feedback aria-live="polite"></p>',
|
|
603
|
+
'</div>',
|
|
604
|
+
].join('\n'));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function renderProfileWidget(title, settings) {
|
|
608
|
+
const displayName = normalizeOptionalString(settings?.display_name);
|
|
609
|
+
const affiliation = normalizeOptionalString(settings?.affiliation);
|
|
610
|
+
const bioShort = normalizeOptionalString(settings?.bio_short);
|
|
611
|
+
const avatar = normalizeOptionalString(settings?.avatar);
|
|
612
|
+
|
|
613
|
+
if (!displayName && !bioShort && !avatar) {
|
|
614
|
+
return '';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const avatarHtml = avatar
|
|
618
|
+
? `<img class="widget-profile__avatar" src="${escapeHtml(avatar)}" alt="${escapeHtml(displayName || title)}">`
|
|
619
|
+
: '';
|
|
620
|
+
const affiliationHtml = affiliation ? `<p class="widget-profile__affiliation">${escapeHtml(affiliation)}</p>` : '';
|
|
621
|
+
const bioHtml = bioShort ? `<p class="sidebar-copy">${escapeHtml(bioShort)}</p>` : '';
|
|
622
|
+
|
|
623
|
+
return renderWidgetCard(title, 'profile', [
|
|
624
|
+
'<div class="widget-profile">',
|
|
625
|
+
avatarHtml,
|
|
626
|
+
' <div class="widget-profile__body">',
|
|
627
|
+
displayName ? ` <p class="widget-profile__name">${escapeHtml(displayName)}</p>` : '',
|
|
628
|
+
affiliationHtml ? ` ${affiliationHtml}` : '',
|
|
629
|
+
bioHtml ? ` ${bioHtml}` : '',
|
|
630
|
+
' </div>',
|
|
631
|
+
'</div>',
|
|
632
|
+
].filter(Boolean).join('\n'));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function renderWidgetCard(title, modifier, body) {
|
|
636
|
+
if (!normalizeOptionalString(body)) {
|
|
637
|
+
return '';
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return [
|
|
641
|
+
`<section class="content-card sidebar-card widget-card widget-card--${escapeHtml(modifier)}">`,
|
|
642
|
+
' <p class="section-kicker">Widget</p>',
|
|
643
|
+
` <h2>${escapeHtml(title)}</h2>`,
|
|
644
|
+
` ${body}`,
|
|
645
|
+
'</section>',
|
|
646
|
+
].join('\n');
|
|
647
|
+
}
|
|
648
|
+
|
|
355
649
|
function preparePage(page) {
|
|
356
650
|
const documentType = normalizeDocumentType(page.document_type);
|
|
357
651
|
|
|
@@ -638,6 +932,51 @@ function renderPagination(paginationData) {
|
|
|
638
932
|
return `<nav class="pagination">\n ${prevLink}\n <span class="pages">${pageLinks}</span>\n ${nextLink}\n</nav>`;
|
|
639
933
|
}
|
|
640
934
|
|
|
935
|
+
function buildTaxonomyCountMap(posts, fieldName) {
|
|
936
|
+
const counts = new Map();
|
|
937
|
+
|
|
938
|
+
for (const post of posts) {
|
|
939
|
+
const values = Array.isArray(post?.[fieldName]) ? post[fieldName] : [];
|
|
940
|
+
for (const value of values) {
|
|
941
|
+
counts.set(value, (counts.get(value) || 0) + 1);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return counts;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function buildArchiveEntries(posts, site) {
|
|
949
|
+
const entries = new Map();
|
|
950
|
+
|
|
951
|
+
for (const post of posts) {
|
|
952
|
+
const publishedAt = normalizeIsoTimestamp(post?.published_at_iso);
|
|
953
|
+
if (!publishedAt) {
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const date = toDate(publishedAt);
|
|
958
|
+
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
959
|
+
const current = entries.get(key) || { date, count: 0 };
|
|
960
|
+
current.count += 1;
|
|
961
|
+
entries.set(key, current);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return Array.from(entries.values())
|
|
965
|
+
.sort((left, right) => right.date.getTime() - left.date.getTime())
|
|
966
|
+
.map((entry) => ({
|
|
967
|
+
label: formatArchiveLabel(entry.date, site),
|
|
968
|
+
count: entry.count,
|
|
969
|
+
}));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function formatArchiveLabel(date, site) {
|
|
973
|
+
return new Intl.DateTimeFormat(normalizeLocale(site.locale || DEFAULT_LOCALE), {
|
|
974
|
+
timeZone: normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE),
|
|
975
|
+
year: 'numeric',
|
|
976
|
+
month: 'long',
|
|
977
|
+
}).format(date);
|
|
978
|
+
}
|
|
979
|
+
|
|
641
980
|
function formatTimestamp(value, site) {
|
|
642
981
|
const date = toDate(value);
|
|
643
982
|
const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
|
|
@@ -776,6 +1115,8 @@ async function assertValidThemePackage(themePackage) {
|
|
|
776
1115
|
...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
|
|
777
1116
|
...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
|
|
778
1117
|
...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
|
|
1118
|
+
...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
|
|
1119
|
+
...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
|
|
779
1120
|
settings: themePackage.metadata.settings || {},
|
|
780
1121
|
}));
|
|
781
1122
|
|
|
@@ -827,6 +1168,24 @@ async function buildAssetOutputs(assets, assetProcessor, options) {
|
|
|
827
1168
|
return outputs;
|
|
828
1169
|
}
|
|
829
1170
|
|
|
1171
|
+
async function buildCustomCssAsset(customCss, assetProcessor, options) {
|
|
1172
|
+
const content = normalizeOptionalString(customCss?.content);
|
|
1173
|
+
if (!content) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const sourceBuffer = new TextEncoder().encode(content);
|
|
1178
|
+
const processedContent = await assetProcessor.processCSS(content);
|
|
1179
|
+
const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
|
|
1180
|
+
|
|
1181
|
+
return {
|
|
1182
|
+
originalPath: '__zeropress_custom.css',
|
|
1183
|
+
path: `assets/zeropress-custom${hash}.css`,
|
|
1184
|
+
content: processedContent,
|
|
1185
|
+
contentType: 'text/css',
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
830
1189
|
function createRenderContext(site, currentUrl) {
|
|
831
1190
|
return {
|
|
832
1191
|
site,
|
|
@@ -959,7 +1318,9 @@ function isAbsoluteUrl(value) {
|
|
|
959
1318
|
}
|
|
960
1319
|
|
|
961
1320
|
async function writeOutput(writer, summaries, path, content, contentType) {
|
|
962
|
-
const
|
|
1321
|
+
const rawPath = String(path || '');
|
|
1322
|
+
const normalizedPath = normalizeOutputPath(rawPath);
|
|
1323
|
+
assertSafeRelativeOutputPath(rawPath, normalizedPath);
|
|
963
1324
|
await writer.write({ path: normalizedPath, content, contentType });
|
|
964
1325
|
summaries.push({
|
|
965
1326
|
path: normalizedPath,
|
|
@@ -989,6 +1350,95 @@ function normalizeOutputPath(filePath) {
|
|
|
989
1350
|
return String(filePath || '').replace(/^\/+/, '');
|
|
990
1351
|
}
|
|
991
1352
|
|
|
1353
|
+
function assertPlannedOutputPathsSafe(state) {
|
|
1354
|
+
for (const post of state.renderData.posts) {
|
|
1355
|
+
assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
for (const page of state.renderData.pages) {
|
|
1359
|
+
assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
for (const category of state.previewData.content.categories) {
|
|
1363
|
+
assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
for (const tag of state.previewData.content.tags) {
|
|
1367
|
+
assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const plannedPaths = [
|
|
1371
|
+
...state.renderData.indexRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1372
|
+
...state.renderData.archiveRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1373
|
+
...state.renderData.categoryRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1374
|
+
...state.renderData.tagRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1375
|
+
...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
|
|
1376
|
+
...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
|
|
1377
|
+
...state.assetOutputs.map((assetOutput) => assetOutput.path),
|
|
1378
|
+
];
|
|
1379
|
+
|
|
1380
|
+
if (state.options.generateSpecialFiles) {
|
|
1381
|
+
plannedPaths.push('404.html', 'robots.txt', 'meta.json');
|
|
1382
|
+
if (hasCanonicalSiteUrl(state.previewData.site.url)) {
|
|
1383
|
+
plannedPaths.push('sitemap.xml', 'feed.xml');
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
for (const plannedPath of plannedPaths) {
|
|
1388
|
+
const rawPath = String(plannedPath || '');
|
|
1389
|
+
const normalizedPath = normalizeOutputPath(rawPath);
|
|
1390
|
+
assertSafeRelativeOutputPath(rawPath, normalizedPath);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
|
|
1395
|
+
const originalSlug = typeof rawSlug === 'string' ? rawSlug : '';
|
|
1396
|
+
const decodedSlug = encodeSlugSegment(rawSlug);
|
|
1397
|
+
|
|
1398
|
+
if (
|
|
1399
|
+
!isSafeSlugPathSegment(originalSlug) ||
|
|
1400
|
+
!isSafeSlugPathSegment(decodedSlug)
|
|
1401
|
+
) {
|
|
1402
|
+
throw new Error(`Unsafe output path detected: ${outputPath}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function isSafeSlugPathSegment(value) {
|
|
1407
|
+
return isSafeSlugSegment(value);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputPath(rawPath)) {
|
|
1411
|
+
const originalPath = String(rawPath || '');
|
|
1412
|
+
const candidatePath = String(normalizedPath || '');
|
|
1413
|
+
const normalizedSeparators = candidatePath.replace(/\\/g, '/');
|
|
1414
|
+
|
|
1415
|
+
if (originalPath.trim() === '' || candidatePath.trim() === '') {
|
|
1416
|
+
throw new Error(`Unsafe output path detected: ${originalPath || candidatePath || '<empty>'}`);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(originalPath) || OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(candidatePath)) {
|
|
1420
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (candidatePath.includes('%')) {
|
|
1424
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (
|
|
1428
|
+
originalPath.startsWith('/') ||
|
|
1429
|
+
originalPath.startsWith('\\') ||
|
|
1430
|
+
/^[A-Za-z]:[\\/]/.test(originalPath) ||
|
|
1431
|
+
normalizedSeparators.startsWith('//')
|
|
1432
|
+
) {
|
|
1433
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const segments = normalizedSeparators.split('/');
|
|
1437
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
1438
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
992
1442
|
function getContentSize(content) {
|
|
993
1443
|
return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
994
1444
|
}
|
|
@@ -1003,6 +1453,14 @@ function injectHtmxScript(html) {
|
|
|
1003
1453
|
return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
|
|
1004
1454
|
}
|
|
1005
1455
|
|
|
1456
|
+
function injectCustomCssAssetLink(html, href) {
|
|
1457
|
+
if (!normalizeOptionalString(href)) {
|
|
1458
|
+
return html;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return html.replace('</head>', ` <link rel="stylesheet" href="${escapeHtml(href)}">\n</head>`);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1006
1464
|
function buildSitemapXml(site, emitted, generatedAt) {
|
|
1007
1465
|
const entries = [
|
|
1008
1466
|
...emitted.indexRoutes
|
|
@@ -1120,23 +1578,6 @@ function encodeSlugSegment(slug) {
|
|
|
1120
1578
|
return normalizeStoredSlug(slug);
|
|
1121
1579
|
}
|
|
1122
1580
|
|
|
1123
|
-
function normalizeStoredSlug(slug) {
|
|
1124
|
-
if (typeof slug !== 'string') {
|
|
1125
|
-
return '';
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const trimmed = slug.trim();
|
|
1129
|
-
if (!trimmed.includes('%')) {
|
|
1130
|
-
return trimmed;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
try {
|
|
1134
|
-
return decodeURIComponent(trimmed);
|
|
1135
|
-
} catch {
|
|
1136
|
-
return trimmed;
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
1581
|
function decodeRoutePath(routePath) {
|
|
1141
1582
|
try {
|
|
1142
1583
|
return decodeURI(routePath);
|
|
@@ -1186,3 +1627,12 @@ function escapeHtml(value) {
|
|
|
1186
1627
|
.replaceAll('"', '"')
|
|
1187
1628
|
.replaceAll("'", ''');
|
|
1188
1629
|
}
|
|
1630
|
+
|
|
1631
|
+
function clampInteger(value, fallback, min, max) {
|
|
1632
|
+
const normalized = Number.isInteger(value) ? value : fallback;
|
|
1633
|
+
return Math.min(max, Math.max(min, normalized));
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function normalizeLinkTarget(value) {
|
|
1637
|
+
return value === '_blank' ? '_blank' : '_self';
|
|
1638
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface ThemePackage {
|
|
|
12
12
|
title: string;
|
|
13
13
|
description?: string;
|
|
14
14
|
}>;
|
|
15
|
+
widgetAreas?: Record<string, {
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}>;
|
|
15
19
|
namespace?: string;
|
|
16
20
|
slug?: string;
|
|
17
21
|
license?: 'MIT' | 'Apache-2.0' | 'BSD-3-Clause' | 'GPL-3.0-only' | 'GPL-3.0-or-later';
|
|
@@ -51,6 +51,8 @@ export async function loadThemePackageFromDir(themeDir) {
|
|
|
51
51
|
description: themeJson.description,
|
|
52
52
|
thumbnail: themeJson.thumbnail,
|
|
53
53
|
settings: themeJson.settings || {},
|
|
54
|
+
menuSlots: themeJson.menuSlots,
|
|
55
|
+
widgetAreas: themeJson.widgetAreas,
|
|
54
56
|
namespace: themeJson.namespace,
|
|
55
57
|
slug: themeJson.slug,
|
|
56
58
|
license: themeJson.license,
|