@zeropress/build-core 0.1.2 → 0.1.4
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 +27 -2
- package/package.json +2 -2
- package/src/api/build-site.js +513 -27
- package/src/index.d.ts +4 -4
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ This package is the deterministic rendering engine used by:
|
|
|
13
13
|
|
|
14
14
|
It accepts canonical preview-data plus a validated theme package and produces static HTML artifacts through a writer interface.
|
|
15
15
|
|
|
16
|
-
`preview-data` stays the
|
|
16
|
+
`preview-data` stays canonical and data-only. Build-core computes the render-ready route state that themes consume at render time. Actual artifact output is the intersection of:
|
|
17
17
|
|
|
18
18
|
- renderable preview-data entries
|
|
19
19
|
- theme template capability
|
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
|
|
49
49
|
- validating preview-data input
|
|
50
50
|
- validating in-memory theme packages
|
|
51
|
+
- computing paginated routes from content data
|
|
52
|
+
- computing theme-facing render data such as post lists, taxonomy links, pagination, and formatted timestamps
|
|
51
53
|
- rendering route HTML
|
|
52
54
|
- processing theme assets
|
|
53
55
|
- generating special files such as:
|
|
@@ -191,6 +193,29 @@ Theme validation is enforced through:
|
|
|
191
193
|
|
|
192
194
|
- [`@zeropress/theme-validator`](https://www.npmjs.com/package/@zeropress/theme-validator)
|
|
193
195
|
|
|
196
|
+
As of `preview-data v0.4`, the payload no longer carries `routes` arrays or raw HTML fragments such as `categories_html`.
|
|
197
|
+
|
|
198
|
+
Build-core now derives:
|
|
199
|
+
|
|
200
|
+
- index/archive/category/tag routes
|
|
201
|
+
- post list HTML blocks
|
|
202
|
+
- pagination HTML
|
|
203
|
+
- taxonomy link HTML
|
|
204
|
+
- formatted `published_at` / `updated_at`
|
|
205
|
+
- `reading_time`
|
|
206
|
+
- `comments_html`
|
|
207
|
+
|
|
208
|
+
Comment shell rendering is derived from preview-data policy:
|
|
209
|
+
|
|
210
|
+
- `site.disallowComments`
|
|
211
|
+
- `content.posts[].allow_comments`
|
|
212
|
+
|
|
213
|
+
The canonical `preview-data v0.4` site contract uses:
|
|
214
|
+
|
|
215
|
+
- `site.locale`
|
|
216
|
+
- `site.timezone`
|
|
217
|
+
- `site.disallowComments`
|
|
218
|
+
|
|
194
219
|
Optional route templates behave as rendering capabilities, not guaranteed outputs:
|
|
195
220
|
|
|
196
221
|
- `archive.html`
|
|
@@ -198,7 +223,7 @@ Optional route templates behave as rendering capabilities, not guaranteed output
|
|
|
198
223
|
- `tag.html`
|
|
199
224
|
- `404.html`
|
|
200
225
|
|
|
201
|
-
If preview-data includes archive/category/tag
|
|
226
|
+
If preview-data includes content that could produce archive/category/tag pages but the theme omits the matching optional template, build-core skips those outputs. Special files are derived from emitted outputs rather than raw preview-data alone.
|
|
202
227
|
|
|
203
228
|
---
|
|
204
229
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Shared ZeroPress build and partial-render core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=18.18.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zeropress/preview-data-validator": "^0.
|
|
36
|
+
"@zeropress/preview-data-validator": "^0.3.1",
|
|
37
37
|
"@zeropress/theme-validator": "^0.2.2"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/api/build-site.js
CHANGED
|
@@ -11,36 +11,41 @@ const DEFAULT_OPTIONS = {
|
|
|
11
11
|
writeManifest: false,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
const DEFAULT_POSTS_PER_PAGE = 10;
|
|
15
|
+
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
|
16
|
+
const DEFAULT_TIME_FORMAT = 'HH:mm';
|
|
17
|
+
const DEFAULT_TIMEZONE = 'UTC';
|
|
18
|
+
const DEFAULT_LOCALE = 'en-US';
|
|
14
19
|
export async function buildSite(input) {
|
|
15
20
|
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
16
21
|
const state = await createBuildState(input, options);
|
|
17
22
|
|
|
18
|
-
for (const route of
|
|
23
|
+
for (const route of state.renderData.indexRoutes) {
|
|
19
24
|
await renderRoute(state, 'index', route);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
for (const post of
|
|
27
|
+
for (const post of state.renderData.posts) {
|
|
23
28
|
await renderPost(state, post);
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
for (const page of
|
|
31
|
+
for (const page of state.previewData.content.pages) {
|
|
27
32
|
await renderPage(state, page);
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
if (hasTemplate(state, 'category')) {
|
|
31
|
-
for (const route of
|
|
36
|
+
for (const route of state.renderData.categoryRoutes) {
|
|
32
37
|
await renderRoute(state, 'category', route);
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
if (hasTemplate(state, 'tag')) {
|
|
37
|
-
for (const route of
|
|
42
|
+
for (const route of state.renderData.tagRoutes) {
|
|
38
43
|
await renderRoute(state, 'tag', route);
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
if (hasTemplate(state, 'archive')) {
|
|
43
|
-
for (const route of
|
|
48
|
+
for (const route of state.renderData.archiveRoutes) {
|
|
44
49
|
await renderRoute(state, 'archive', route);
|
|
45
50
|
}
|
|
46
51
|
}
|
|
@@ -51,9 +56,11 @@ export async function buildSite(input) {
|
|
|
51
56
|
|
|
52
57
|
if (options.generateSpecialFiles) {
|
|
53
58
|
await maybeRenderNotFoundPage(state);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
if (hasCanonicalSiteUrl(state.previewData.site.url)) {
|
|
60
|
+
await writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt), 'application/xml');
|
|
61
|
+
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
|
|
62
|
+
}
|
|
63
|
+
await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
|
|
57
64
|
await writeOutput(state.writer, state.summaries, 'meta.json', buildMetaJson(state.previewData.site, state.emitted, state.generatedAt), 'application/json');
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -71,20 +78,20 @@ export async function buildSelectedRoutes(input) {
|
|
|
71
78
|
const selectedCategoryRoutes = new Set((input.selection.categoryRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
72
79
|
const selectedTagRoutes = new Set((input.selection.tagRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
73
80
|
|
|
74
|
-
for (const post of
|
|
81
|
+
for (const post of state.renderData.posts) {
|
|
75
82
|
if (selectedPostSlugs.has(post.slug)) {
|
|
76
83
|
await renderPost(state, post);
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
for (const route of
|
|
87
|
+
for (const route of state.renderData.indexRoutes) {
|
|
81
88
|
if (selectedIndexRoutes.has(normalizeRoutePath(route.path))) {
|
|
82
89
|
await renderRoute(state, 'index', route);
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
if (hasTemplate(state, 'archive')) {
|
|
87
|
-
for (const route of
|
|
94
|
+
for (const route of state.renderData.archiveRoutes) {
|
|
88
95
|
if (selectedArchiveRoutes.has(normalizeRoutePath(route.path))) {
|
|
89
96
|
await renderRoute(state, 'archive', route);
|
|
90
97
|
}
|
|
@@ -92,7 +99,7 @@ export async function buildSelectedRoutes(input) {
|
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
if (hasTemplate(state, 'category')) {
|
|
95
|
-
for (const route of
|
|
102
|
+
for (const route of state.renderData.categoryRoutes) {
|
|
96
103
|
if (selectedCategoryRoutes.has(normalizeRoutePath(route.path))) {
|
|
97
104
|
await renderRoute(state, 'category', route);
|
|
98
105
|
}
|
|
@@ -100,7 +107,7 @@ export async function buildSelectedRoutes(input) {
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
if (hasTemplate(state, 'tag')) {
|
|
103
|
-
for (const route of
|
|
110
|
+
for (const route of state.renderData.tagRoutes) {
|
|
104
111
|
if (selectedTagRoutes.has(normalizeRoutePath(route.path))) {
|
|
105
112
|
await renderRoute(state, 'tag', route);
|
|
106
113
|
}
|
|
@@ -128,6 +135,7 @@ async function createBuildState(input, options) {
|
|
|
128
135
|
const assetProcessor = new AssetProcessor();
|
|
129
136
|
const summaries = [];
|
|
130
137
|
const themePackage = input.themePackage;
|
|
138
|
+
const previewData = normalizePreviewData(input.previewData);
|
|
131
139
|
|
|
132
140
|
engine.initialize(themePackage);
|
|
133
141
|
|
|
@@ -138,7 +146,8 @@ async function createBuildState(input, options) {
|
|
|
138
146
|
|
|
139
147
|
return {
|
|
140
148
|
writer: input.writer,
|
|
141
|
-
previewData
|
|
149
|
+
previewData,
|
|
150
|
+
renderData: createRenderData(previewData),
|
|
142
151
|
engine,
|
|
143
152
|
assetProcessor,
|
|
144
153
|
summaries,
|
|
@@ -192,10 +201,11 @@ async function renderRoute(state, templateName, route) {
|
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
async function renderPost(state, post) {
|
|
204
|
+
const currentUrl = `/posts/${encodeSlugSegment(post.slug)}/`;
|
|
195
205
|
let html = await state.engine.render(
|
|
196
206
|
'post',
|
|
197
207
|
{ post },
|
|
198
|
-
createRenderContext(state.previewData.site,
|
|
208
|
+
createRenderContext(state.previewData.site, currentUrl),
|
|
199
209
|
);
|
|
200
210
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
201
211
|
if (state.options.injectHtmx) {
|
|
@@ -203,7 +213,7 @@ async function renderPost(state, post) {
|
|
|
203
213
|
}
|
|
204
214
|
await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
|
|
205
215
|
state.emitted.posts.push({
|
|
206
|
-
url:
|
|
216
|
+
url: currentUrl,
|
|
207
217
|
title: post.title,
|
|
208
218
|
description: post.excerpt,
|
|
209
219
|
publishedAt: post.published_at_iso,
|
|
@@ -258,6 +268,459 @@ function validateSelection(selection) {
|
|
|
258
268
|
}
|
|
259
269
|
}
|
|
260
270
|
|
|
271
|
+
function normalizePreviewData(previewData) {
|
|
272
|
+
return {
|
|
273
|
+
...previewData,
|
|
274
|
+
site: {
|
|
275
|
+
...previewData.site,
|
|
276
|
+
postsPerPage: Number.isInteger(previewData.site.postsPerPage) && previewData.site.postsPerPage > 0
|
|
277
|
+
? previewData.site.postsPerPage
|
|
278
|
+
: DEFAULT_POSTS_PER_PAGE,
|
|
279
|
+
dateFormat: normalizeNonEmptyString(previewData.site.dateFormat, DEFAULT_DATE_FORMAT),
|
|
280
|
+
timeFormat: typeof previewData.site.timeFormat === 'string' ? previewData.site.timeFormat : DEFAULT_TIME_FORMAT,
|
|
281
|
+
timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
|
|
282
|
+
locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
|
|
283
|
+
disallowComments: previewData.site.disallowComments === true,
|
|
284
|
+
},
|
|
285
|
+
content: {
|
|
286
|
+
...previewData.content,
|
|
287
|
+
posts: [...previewData.content.posts].sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
|
|
288
|
+
pages: [...previewData.content.pages],
|
|
289
|
+
categories: [...previewData.content.categories],
|
|
290
|
+
tags: [...previewData.content.tags],
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function createRenderData(previewData) {
|
|
296
|
+
const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
|
|
297
|
+
const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
|
|
298
|
+
const categoryPostsBySlug = new Map();
|
|
299
|
+
const tagPostsBySlug = new Map();
|
|
300
|
+
const categoryCountBySlug = new Map();
|
|
301
|
+
const tagCountBySlug = new Map();
|
|
302
|
+
|
|
303
|
+
for (const post of previewData.content.posts) {
|
|
304
|
+
for (const slug of post.category_slugs) {
|
|
305
|
+
pushToSlugMap(categoryPostsBySlug, slug, post);
|
|
306
|
+
categoryCountBySlug.set(slug, (categoryCountBySlug.get(slug) || 0) + 1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const slug of post.tag_slugs) {
|
|
310
|
+
pushToSlugMap(tagPostsBySlug, slug, post);
|
|
311
|
+
tagCountBySlug.set(slug, (tagCountBySlug.get(slug) || 0) + 1);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, categoriesBySlug, tagsBySlug));
|
|
316
|
+
const postBySlug = new Map(posts.map((post) => [post.slug, post]));
|
|
317
|
+
const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
|
|
318
|
+
const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
posts,
|
|
322
|
+
postBySlug,
|
|
323
|
+
indexRoutes: buildPaginatedCollection({
|
|
324
|
+
items: previewData.content.posts,
|
|
325
|
+
postsPerPage: previewData.site.postsPerPage,
|
|
326
|
+
basePath: '/',
|
|
327
|
+
}).map((entry) => ({
|
|
328
|
+
path: entry.path,
|
|
329
|
+
page: entry.page,
|
|
330
|
+
totalPages: entry.totalPages,
|
|
331
|
+
posts: renderPostList(entry.items, postBySlug),
|
|
332
|
+
categories: categoryLinks,
|
|
333
|
+
tags: tagLinks,
|
|
334
|
+
pagination: renderPagination(entry.paginationData),
|
|
335
|
+
})),
|
|
336
|
+
archiveRoutes: buildPaginatedCollection({
|
|
337
|
+
items: previewData.content.posts,
|
|
338
|
+
postsPerPage: previewData.site.postsPerPage,
|
|
339
|
+
basePath: '/archive/',
|
|
340
|
+
}).map((entry) => ({
|
|
341
|
+
path: entry.path,
|
|
342
|
+
page: entry.page,
|
|
343
|
+
totalPages: entry.totalPages,
|
|
344
|
+
posts: renderArchive(entry.items, postBySlug),
|
|
345
|
+
pagination: renderPagination(entry.paginationData),
|
|
346
|
+
})),
|
|
347
|
+
categoryRoutes: buildTaxonomyRoutes({
|
|
348
|
+
items: previewData.content.categories,
|
|
349
|
+
postsBySlug: categoryPostsBySlug,
|
|
350
|
+
postsPerPage: previewData.site.postsPerPage,
|
|
351
|
+
buildBasePath: (category) => `/categories/${encodeSlugSegment(category.slug)}/`,
|
|
352
|
+
renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
|
|
353
|
+
renderExtras: () => ({ categories: categoryLinks }),
|
|
354
|
+
}),
|
|
355
|
+
tagRoutes: buildTaxonomyRoutes({
|
|
356
|
+
items: previewData.content.tags,
|
|
357
|
+
postsBySlug: tagPostsBySlug,
|
|
358
|
+
postsPerPage: previewData.site.postsPerPage,
|
|
359
|
+
buildBasePath: (tag) => `/tags/${encodeSlugSegment(tag.slug)}/`,
|
|
360
|
+
renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
|
|
361
|
+
renderExtras: () => ({ tags: tagLinks }),
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function preparePost(post, site, categoriesBySlug, tagsBySlug) {
|
|
367
|
+
return {
|
|
368
|
+
...post,
|
|
369
|
+
published_at: formatTimestamp(post.published_at_iso, site),
|
|
370
|
+
updated_at: formatTimestamp(post.updated_at_iso, site),
|
|
371
|
+
reading_time: calculateReadingTime(post.html),
|
|
372
|
+
categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
|
|
373
|
+
tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
|
|
374
|
+
comments_html: renderCommentsContainer(site, post),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function renderCommentsContainer(site, post) {
|
|
379
|
+
if (site.disallowComments === true || post.allow_comments !== true) {
|
|
380
|
+
return '';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const safePostId = encodeURIComponent(post.id);
|
|
384
|
+
return `<section id="comments" class="zp-comments">
|
|
385
|
+
<h2>댓글</h2>
|
|
386
|
+
<div id="comment-list"
|
|
387
|
+
hx-get="/api/comments/${safePostId}"
|
|
388
|
+
hx-trigger="load"
|
|
389
|
+
hx-swap="innerHTML">
|
|
390
|
+
<p class="zp-comments-loading">댓글을 불러오는 중...</p>
|
|
391
|
+
</div>
|
|
392
|
+
<form id="comment-form"
|
|
393
|
+
hx-post="/api/comments/${safePostId}"
|
|
394
|
+
hx-target="#comment-form-result"
|
|
395
|
+
hx-swap="innerHTML">
|
|
396
|
+
<div id="comment-form-result"></div>
|
|
397
|
+
<div>
|
|
398
|
+
<label for="author_name">이름</label>
|
|
399
|
+
<input type="text" id="author_name" name="author_name" required>
|
|
400
|
+
</div>
|
|
401
|
+
<div>
|
|
402
|
+
<label for="author_email">이메일</label>
|
|
403
|
+
<input type="email" id="author_email" name="author_email" required>
|
|
404
|
+
</div>
|
|
405
|
+
<div>
|
|
406
|
+
<label for="content">댓글</label>
|
|
407
|
+
<textarea id="content" name="content" required minlength="1" maxlength="5000"></textarea>
|
|
408
|
+
</div>
|
|
409
|
+
<div style="position:absolute;left:-9999px;" aria-hidden="true">
|
|
410
|
+
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
|
411
|
+
</div>
|
|
412
|
+
<button type="submit">댓글 작성</button>
|
|
413
|
+
</form>
|
|
414
|
+
<noscript>
|
|
415
|
+
<p>댓글 기능을 사용하려면 JavaScript를 활성화해 주세요.</p>
|
|
416
|
+
</noscript>
|
|
417
|
+
</section>`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function buildTaxonomyRoutes(options) {
|
|
421
|
+
const routes = [];
|
|
422
|
+
|
|
423
|
+
for (const item of options.items) {
|
|
424
|
+
const matchedPosts = options.postsBySlug.get(item.slug) || [];
|
|
425
|
+
if (matchedPosts.length === 0) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const paginated = buildPaginatedCollection({
|
|
430
|
+
items: matchedPosts,
|
|
431
|
+
postsPerPage: options.postsPerPage,
|
|
432
|
+
basePath: options.buildBasePath(item),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
for (const entry of paginated) {
|
|
436
|
+
routes.push({
|
|
437
|
+
path: entry.path,
|
|
438
|
+
slug: item.slug,
|
|
439
|
+
page: entry.page,
|
|
440
|
+
totalPages: entry.totalPages,
|
|
441
|
+
posts: options.renderList(entry.items),
|
|
442
|
+
pagination: renderPagination(entry.paginationData),
|
|
443
|
+
...options.renderExtras(item),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return routes;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function buildPaginatedCollection(options) {
|
|
452
|
+
const basePath = normalizePaginationBasePath(options.basePath);
|
|
453
|
+
const postsPerPage = Number.isInteger(options.postsPerPage) && options.postsPerPage > 0 ? options.postsPerPage : DEFAULT_POSTS_PER_PAGE;
|
|
454
|
+
const totalPosts = options.items.length;
|
|
455
|
+
const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
|
|
456
|
+
const pages = [];
|
|
457
|
+
|
|
458
|
+
for (let page = 1; page <= totalPages; page += 1) {
|
|
459
|
+
const start = (page - 1) * postsPerPage;
|
|
460
|
+
const end = start + postsPerPage;
|
|
461
|
+
|
|
462
|
+
pages.push({
|
|
463
|
+
path: buildPaginatedPath(basePath, page),
|
|
464
|
+
page,
|
|
465
|
+
totalPages,
|
|
466
|
+
items: options.items.slice(start, end),
|
|
467
|
+
paginationData: buildPaginationData(page, totalPages, totalPosts, basePath),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return pages;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function buildPaginatedPath(basePath, page) {
|
|
475
|
+
const normalizedBasePath = normalizePaginationBasePath(basePath);
|
|
476
|
+
if (page <= 1) {
|
|
477
|
+
return normalizedBasePath;
|
|
478
|
+
}
|
|
479
|
+
if (normalizedBasePath === '/') {
|
|
480
|
+
return `/page/${page}/`;
|
|
481
|
+
}
|
|
482
|
+
return `${normalizedBasePath}page/${page}/`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function normalizePaginationBasePath(basePath) {
|
|
486
|
+
if (!basePath || basePath === '/') {
|
|
487
|
+
return '/';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const normalized = decodeRoutePath(String(basePath)).replace(/^\/+|\/+$/g, '');
|
|
491
|
+
return `/${normalized}/`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildPaginationData(currentPage, totalPages, totalPosts, basePath) {
|
|
495
|
+
return {
|
|
496
|
+
currentPage,
|
|
497
|
+
totalPages,
|
|
498
|
+
totalPosts,
|
|
499
|
+
hasNext: currentPage < totalPages,
|
|
500
|
+
hasPrev: currentPage > 1,
|
|
501
|
+
nextUrl: currentPage < totalPages ? buildPaginatedPath(basePath, currentPage + 1) : undefined,
|
|
502
|
+
prevUrl: currentPage > 1 ? buildPaginatedPath(basePath, currentPage - 1) : undefined,
|
|
503
|
+
pages: Array.from({ length: totalPages }, (_, index) => {
|
|
504
|
+
const page = index + 1;
|
|
505
|
+
return {
|
|
506
|
+
number: page,
|
|
507
|
+
url: buildPaginatedPath(basePath, page),
|
|
508
|
+
current: page === currentPage,
|
|
509
|
+
};
|
|
510
|
+
}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function renderPostList(posts, postBySlug) {
|
|
515
|
+
if (posts.length === 0) {
|
|
516
|
+
return '<p>No posts</p>';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return posts
|
|
520
|
+
.map((post) => {
|
|
521
|
+
const prepared = postBySlug.get(post.slug);
|
|
522
|
+
if (!prepared) {
|
|
523
|
+
return '';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return [
|
|
527
|
+
'<article class="post-list-item">',
|
|
528
|
+
` <h2><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a></h2>`,
|
|
529
|
+
` <div class="post-meta"><time datetime="${escapeHtml(prepared.published_at_iso)}">${escapeHtml(prepared.published_at)}</time> • ${escapeHtml(prepared.reading_time)}</div>`,
|
|
530
|
+
` <div class="post-excerpt">${escapeHtml(prepared.excerpt)}</div>`,
|
|
531
|
+
'</article>',
|
|
532
|
+
].join('\n');
|
|
533
|
+
})
|
|
534
|
+
.filter(Boolean)
|
|
535
|
+
.join('\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function renderArchive(posts, postBySlug) {
|
|
539
|
+
if (posts.length === 0) {
|
|
540
|
+
return '<p>No posts</p>';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const groups = new Map();
|
|
544
|
+
|
|
545
|
+
for (const post of posts) {
|
|
546
|
+
const key = `${toDate(post.published_at_iso).getUTCFullYear()}-${String(toDate(post.published_at_iso).getUTCMonth() + 1).padStart(2, '0')}`;
|
|
547
|
+
const items = groups.get(key) || [];
|
|
548
|
+
items.push(post);
|
|
549
|
+
groups.set(key, items);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return Array.from(groups.entries())
|
|
553
|
+
.map(([key, groupPosts]) => {
|
|
554
|
+
const items = groupPosts
|
|
555
|
+
.map((post) => {
|
|
556
|
+
const prepared = postBySlug.get(post.slug);
|
|
557
|
+
if (!prepared) {
|
|
558
|
+
return '';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return `<li><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a> <time datetime="${escapeHtml(prepared.published_at_iso)}">${escapeHtml(prepared.published_at)}</time></li>`;
|
|
562
|
+
})
|
|
563
|
+
.filter(Boolean)
|
|
564
|
+
.join('\n');
|
|
565
|
+
|
|
566
|
+
return `<section class="archive-group"><h2>${escapeHtml(key)}</h2><ul>${items}</ul></section>`;
|
|
567
|
+
})
|
|
568
|
+
.join('\n');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function renderCategoryLinks(categories, countBySlug) {
|
|
572
|
+
return categories
|
|
573
|
+
.map((category) => {
|
|
574
|
+
const count = countBySlug.get(category.slug) || 0;
|
|
575
|
+
if (count === 0) {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)} (${count})</a>`;
|
|
580
|
+
})
|
|
581
|
+
.filter(Boolean)
|
|
582
|
+
.join('\n');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderTagLinks(tags, countBySlug) {
|
|
586
|
+
return tags
|
|
587
|
+
.map((tag) => {
|
|
588
|
+
const count = countBySlug.get(tag.slug) || 0;
|
|
589
|
+
if (count === 0) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)} (${count})</a>`;
|
|
594
|
+
})
|
|
595
|
+
.filter(Boolean)
|
|
596
|
+
.join('\n');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renderInlineTaxonomyLinks(slugs, taxonomyBySlug, kind) {
|
|
600
|
+
const links = slugs
|
|
601
|
+
.map((slug) => taxonomyBySlug.get(slug))
|
|
602
|
+
.filter(Boolean)
|
|
603
|
+
.map((item) => {
|
|
604
|
+
const base = kind === 'category' ? '/categories/' : '/tags/';
|
|
605
|
+
const className = kind === 'category' ? 'category-link' : 'tag-link';
|
|
606
|
+
return `<a href="${base}${escapeHtml(encodeSlugSegment(item.slug))}/" class="${className}">${escapeHtml(item.name)}</a>`;
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
if (links.length === 0) {
|
|
610
|
+
return '';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return kind === 'category' ? links.join(', ') : links.join(' ');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderPagination(paginationData) {
|
|
617
|
+
if (paginationData.totalPages <= 1) {
|
|
618
|
+
return '';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const prevLink = paginationData.hasPrev && paginationData.prevUrl
|
|
622
|
+
? `<a href="${paginationData.prevUrl}" class="prev">Previous</a>`
|
|
623
|
+
: '';
|
|
624
|
+
const nextLink = paginationData.hasNext && paginationData.nextUrl
|
|
625
|
+
? `<a href="${paginationData.nextUrl}" class="next">Next</a>`
|
|
626
|
+
: '';
|
|
627
|
+
const pageLinks = paginationData.pages
|
|
628
|
+
.map((page) => `<a href="${page.url}" class="page-link ${page.current ? 'current' : ''}">${page.number}</a>`)
|
|
629
|
+
.join(' ');
|
|
630
|
+
|
|
631
|
+
return `<nav class="pagination">\n ${prevLink}\n <span class="pages">${pageLinks}</span>\n ${nextLink}\n</nav>`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function formatTimestamp(value, site) {
|
|
635
|
+
const date = toDate(value);
|
|
636
|
+
const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
|
|
637
|
+
const dateFormat = normalizeNonEmptyString(site.dateFormat, DEFAULT_DATE_FORMAT);
|
|
638
|
+
const timeFormat = typeof site.timeFormat === 'string' ? site.timeFormat : DEFAULT_TIME_FORMAT;
|
|
639
|
+
const siteTimezone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
|
|
640
|
+
|
|
641
|
+
const dateParts = new Intl.DateTimeFormat(locale, {
|
|
642
|
+
timeZone: siteTimezone,
|
|
643
|
+
year: 'numeric',
|
|
644
|
+
month: dateFormat === 'MMMM D, YYYY' ? 'long' : '2-digit',
|
|
645
|
+
day: '2-digit',
|
|
646
|
+
}).formatToParts(date);
|
|
647
|
+
|
|
648
|
+
const year = dateParts.find((part) => part.type === 'year')?.value || '';
|
|
649
|
+
const month = dateParts.find((part) => part.type === 'month')?.value || '';
|
|
650
|
+
const day = dateParts.find((part) => part.type === 'day')?.value || '';
|
|
651
|
+
|
|
652
|
+
let formattedDate = `${year}-${month}-${day}`;
|
|
653
|
+
if (dateFormat === 'DD/MM/YYYY') {
|
|
654
|
+
formattedDate = `${day}/${month}/${year}`;
|
|
655
|
+
} else if (dateFormat === 'MM/DD/YYYY') {
|
|
656
|
+
formattedDate = `${month}/${day}/${year}`;
|
|
657
|
+
} else if (dateFormat === 'MMMM D, YYYY') {
|
|
658
|
+
formattedDate = `${month} ${String(Number(day))}, ${year}`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (!timeFormat) {
|
|
662
|
+
return formattedDate;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const timeParts = new Intl.DateTimeFormat(locale, {
|
|
666
|
+
timeZone: siteTimezone,
|
|
667
|
+
hour: '2-digit',
|
|
668
|
+
minute: '2-digit',
|
|
669
|
+
hour12: timeFormat === 'hh:mm A',
|
|
670
|
+
}).formatToParts(date);
|
|
671
|
+
|
|
672
|
+
const hour = timeParts.find((part) => part.type === 'hour')?.value || '';
|
|
673
|
+
const minute = timeParts.find((part) => part.type === 'minute')?.value || '';
|
|
674
|
+
const dayPeriod = (timeParts.find((part) => part.type === 'dayPeriod')?.value || '').toUpperCase();
|
|
675
|
+
|
|
676
|
+
const formattedTime = timeFormat === 'hh:mm A'
|
|
677
|
+
? `${hour}:${minute}${dayPeriod ? ` ${dayPeriod}` : ''}`
|
|
678
|
+
: `${hour}:${minute}`;
|
|
679
|
+
|
|
680
|
+
return `${formattedDate} ${formattedTime}`.trim();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function calculateReadingTime(html) {
|
|
684
|
+
const plainText = String(html || '').replace(/<[^>]*>/g, ' ');
|
|
685
|
+
const wordCount = plainText.trim().split(/\s+/).filter(Boolean).length;
|
|
686
|
+
const minutes = Math.max(1, Math.ceil(wordCount / 200));
|
|
687
|
+
return minutes === 1 ? '1 min read' : `${minutes} min read`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function pushToSlugMap(target, slug, value) {
|
|
691
|
+
const items = target.get(slug) || [];
|
|
692
|
+
items.push(value);
|
|
693
|
+
target.set(slug, items);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizeNonEmptyString(value, fallback) {
|
|
697
|
+
return typeof value === 'string' && value.trim() ? value : fallback;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function normalizeLocale(value) {
|
|
701
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
702
|
+
return DEFAULT_LOCALE;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const parts = value.trim().replace(/_/g, '-').split('-').filter(Boolean);
|
|
706
|
+
if (parts.length === 0) {
|
|
707
|
+
return DEFAULT_LOCALE;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return parts.map((part, index) => {
|
|
711
|
+
if (index === 0) {
|
|
712
|
+
return part.toLowerCase();
|
|
713
|
+
}
|
|
714
|
+
if (part.length === 2 || part.length === 3) {
|
|
715
|
+
return part.toUpperCase();
|
|
716
|
+
}
|
|
717
|
+
if (part.length === 4) {
|
|
718
|
+
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
|
719
|
+
}
|
|
720
|
+
return part.toLowerCase();
|
|
721
|
+
}).join('-');
|
|
722
|
+
}
|
|
723
|
+
|
|
261
724
|
function hasTemplate(state, templateName) {
|
|
262
725
|
return state.engine.themePackage?.templates?.has(templateName) === true;
|
|
263
726
|
}
|
|
@@ -353,7 +816,7 @@ function createRenderContext(site, currentUrl) {
|
|
|
353
816
|
return {
|
|
354
817
|
site,
|
|
355
818
|
currentUrl,
|
|
356
|
-
language: site.
|
|
819
|
+
language: site.locale,
|
|
357
820
|
};
|
|
358
821
|
}
|
|
359
822
|
|
|
@@ -407,11 +870,11 @@ function buildSitemapXml(site, emitted, generatedAt) {
|
|
|
407
870
|
...emitted.indexRoutes
|
|
408
871
|
.filter((route) => route.url === '/')
|
|
409
872
|
.map((route) => ({
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
873
|
+
url: route.url,
|
|
874
|
+
lastmod: generatedAt,
|
|
875
|
+
changefreq: 'daily',
|
|
876
|
+
priority: 1.0,
|
|
877
|
+
})),
|
|
415
878
|
...emitted.posts.map((post) => ({
|
|
416
879
|
url: post.url,
|
|
417
880
|
lastmod: toDate(post.updatedAt),
|
|
@@ -448,6 +911,8 @@ function buildSitemapXml(site, emitted, generatedAt) {
|
|
|
448
911
|
}
|
|
449
912
|
|
|
450
913
|
function buildFeedXml(site, emitted, generatedAt) {
|
|
914
|
+
const channelLink = resolveSiteUrl(site.url, '/');
|
|
915
|
+
const selfLink = resolveSiteUrl(site.url, '/feed.xml');
|
|
451
916
|
const items = [...emitted.posts]
|
|
452
917
|
.sort((a, b) => toDate(b.publishedAt).getTime() - toDate(a.publishedAt).getTime())
|
|
453
918
|
.slice(0, 20)
|
|
@@ -457,11 +922,15 @@ function buildFeedXml(site, emitted, generatedAt) {
|
|
|
457
922
|
})
|
|
458
923
|
.join('\n');
|
|
459
924
|
|
|
460
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(
|
|
925
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${generatedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
|
|
461
926
|
}
|
|
462
927
|
|
|
463
|
-
function buildRobotsTxt(
|
|
464
|
-
|
|
928
|
+
function buildRobotsTxt(site) {
|
|
929
|
+
const lines = ['User-agent: *', 'Allow: /'];
|
|
930
|
+
if (site.url) {
|
|
931
|
+
lines.push('', `Sitemap: ${resolveSiteUrl(site.url, '/sitemap.xml')}`);
|
|
932
|
+
}
|
|
933
|
+
return `${lines.join('\n')}\n`;
|
|
465
934
|
}
|
|
466
935
|
|
|
467
936
|
function buildMetaJson(site, emitted, generatedAt) {
|
|
@@ -489,7 +958,7 @@ function buildMetaJson(site, emitted, generatedAt) {
|
|
|
489
958
|
title: site.title,
|
|
490
959
|
description: site.description,
|
|
491
960
|
url: site.url,
|
|
492
|
-
|
|
961
|
+
locale: site.locale,
|
|
493
962
|
},
|
|
494
963
|
pages,
|
|
495
964
|
}, null, 2);
|
|
@@ -550,9 +1019,17 @@ function decodeRoutePath(routePath) {
|
|
|
550
1019
|
}
|
|
551
1020
|
|
|
552
1021
|
function resolveSiteUrl(siteUrl, relativePath) {
|
|
1022
|
+
if (!siteUrl) {
|
|
1023
|
+
return normalizeRoutePath(relativePath);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
553
1026
|
return decodeURI(new URL(relativePath, siteUrl).toString());
|
|
554
1027
|
}
|
|
555
1028
|
|
|
1029
|
+
function hasCanonicalSiteUrl(siteUrl) {
|
|
1030
|
+
return typeof siteUrl === 'string' && siteUrl.trim() !== '';
|
|
1031
|
+
}
|
|
1032
|
+
|
|
556
1033
|
function toDate(value) {
|
|
557
1034
|
return value ? new Date(value) : new Date();
|
|
558
1035
|
}
|
|
@@ -565,3 +1042,12 @@ function escapeXml(str) {
|
|
|
565
1042
|
.replace(/"/g, '"')
|
|
566
1043
|
.replace(/'/g, ''');
|
|
567
1044
|
}
|
|
1045
|
+
|
|
1046
|
+
function escapeHtml(value) {
|
|
1047
|
+
return String(value || '')
|
|
1048
|
+
.replaceAll('&', '&')
|
|
1049
|
+
.replaceAll('<', '<')
|
|
1050
|
+
.replaceAll('>', '>')
|
|
1051
|
+
.replaceAll('"', '"')
|
|
1052
|
+
.replaceAll("'", ''');
|
|
1053
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PreviewDataV04 } from '@zeropress/preview-data-validator';
|
|
2
2
|
|
|
3
3
|
export interface ThemePackage {
|
|
4
4
|
metadata: {
|
|
@@ -62,14 +62,14 @@ export interface BuildSiteResult {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function buildSite(input: {
|
|
65
|
-
previewData:
|
|
65
|
+
previewData: PreviewDataV04;
|
|
66
66
|
themePackage: ThemePackage;
|
|
67
67
|
writer: BuildWriter;
|
|
68
68
|
options?: BuildOptions;
|
|
69
69
|
}): Promise<BuildSiteResult>;
|
|
70
70
|
|
|
71
71
|
export function buildSelectedRoutes(input: {
|
|
72
|
-
previewData:
|
|
72
|
+
previewData: PreviewDataV04;
|
|
73
73
|
themePackage: ThemePackage;
|
|
74
74
|
writer: BuildWriter;
|
|
75
75
|
selection: BuildSelection;
|
|
@@ -77,7 +77,7 @@ export function buildSelectedRoutes(input: {
|
|
|
77
77
|
}): Promise<BuildSiteResult>;
|
|
78
78
|
|
|
79
79
|
export function buildSiteFromThemeDir(input: {
|
|
80
|
-
previewData:
|
|
80
|
+
previewData: PreviewDataV04;
|
|
81
81
|
themeDir: string;
|
|
82
82
|
writer: BuildWriter;
|
|
83
83
|
options?: BuildOptions;
|