@zeropress/build-core 0.1.0 → 0.1.2
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 +23 -0
- package/package.json +1 -1
- package/src/api/build-site.js +167 -55
package/README.md
CHANGED
|
@@ -13,6 +13,11 @@ 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 canonical input graph. Actual artifact output is the intersection of:
|
|
17
|
+
|
|
18
|
+
- renderable preview-data entries
|
|
19
|
+
- theme template capability
|
|
20
|
+
|
|
16
21
|
---
|
|
17
22
|
|
|
18
23
|
## Install
|
|
@@ -109,6 +114,15 @@ Renders only selected post and list routes while preserving full-build parity fo
|
|
|
109
114
|
|
|
110
115
|
This is intended for selective publish and other partial-render workflows.
|
|
111
116
|
|
|
117
|
+
Optional route templates are respected during both full and partial renders:
|
|
118
|
+
|
|
119
|
+
- missing `archive.html` skips archive outputs
|
|
120
|
+
- missing `category.html` skips category outputs
|
|
121
|
+
- missing `tag.html` skips tag outputs
|
|
122
|
+
- missing `404.html` skips `404.html` emission
|
|
123
|
+
|
|
124
|
+
These cases do not fail the build.
|
|
125
|
+
|
|
112
126
|
```js
|
|
113
127
|
await buildSelectedRoutes({
|
|
114
128
|
previewData,
|
|
@@ -177,6 +191,15 @@ Theme validation is enforced through:
|
|
|
177
191
|
|
|
178
192
|
- [`@zeropress/theme-validator`](https://www.npmjs.com/package/@zeropress/theme-validator)
|
|
179
193
|
|
|
194
|
+
Optional route templates behave as rendering capabilities, not guaranteed outputs:
|
|
195
|
+
|
|
196
|
+
- `archive.html`
|
|
197
|
+
- `category.html`
|
|
198
|
+
- `tag.html`
|
|
199
|
+
- `404.html`
|
|
200
|
+
|
|
201
|
+
If preview-data includes archive/category/tag route arrays 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
|
+
|
|
180
203
|
---
|
|
181
204
|
|
|
182
205
|
## Build Options
|
package/package.json
CHANGED
package/src/api/build-site.js
CHANGED
|
@@ -27,16 +27,22 @@ export async function buildSite(input) {
|
|
|
27
27
|
await renderPage(state, page);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (hasTemplate(state, 'category')) {
|
|
31
|
+
for (const route of input.previewData.routes.categories) {
|
|
32
|
+
await renderRoute(state, 'category', route);
|
|
33
|
+
}
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
if (hasTemplate(state, 'tag')) {
|
|
37
|
+
for (const route of input.previewData.routes.tags) {
|
|
38
|
+
await renderRoute(state, 'tag', route);
|
|
39
|
+
}
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
if (hasTemplate(state, 'archive')) {
|
|
43
|
+
for (const route of input.previewData.routes.archive) {
|
|
44
|
+
await renderRoute(state, 'archive', route);
|
|
45
|
+
}
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
for (const assetOutput of state.assetOutputs) {
|
|
@@ -44,10 +50,11 @@ export async function buildSite(input) {
|
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
if (options.generateSpecialFiles) {
|
|
47
|
-
await
|
|
48
|
-
await writeOutput(state.writer, state.summaries, '
|
|
53
|
+
await maybeRenderNotFoundPage(state);
|
|
54
|
+
await writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt), 'application/xml');
|
|
55
|
+
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
|
|
49
56
|
await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(input.previewData), 'text/plain');
|
|
50
|
-
await writeOutput(state.writer, state.summaries, 'meta.json', buildMetaJson(
|
|
57
|
+
await writeOutput(state.writer, state.summaries, 'meta.json', buildMetaJson(state.previewData.site, state.emitted, state.generatedAt), 'application/json');
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
@@ -76,21 +83,27 @@ export async function buildSelectedRoutes(input) {
|
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
if (hasTemplate(state, 'archive')) {
|
|
87
|
+
for (const route of input.previewData.routes.archive) {
|
|
88
|
+
if (selectedArchiveRoutes.has(normalizeRoutePath(route.path))) {
|
|
89
|
+
await renderRoute(state, 'archive', route);
|
|
90
|
+
}
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
if (hasTemplate(state, 'category')) {
|
|
95
|
+
for (const route of input.previewData.routes.categories) {
|
|
96
|
+
if (selectedCategoryRoutes.has(normalizeRoutePath(route.path))) {
|
|
97
|
+
await renderRoute(state, 'category', route);
|
|
98
|
+
}
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
if (hasTemplate(state, 'tag')) {
|
|
103
|
+
for (const route of input.previewData.routes.tags) {
|
|
104
|
+
if (selectedTagRoutes.has(normalizeRoutePath(route.path))) {
|
|
105
|
+
await renderRoute(state, 'tag', route);
|
|
106
|
+
}
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -132,6 +145,15 @@ async function createBuildState(input, options) {
|
|
|
132
145
|
assetOutputs,
|
|
133
146
|
assetMap,
|
|
134
147
|
options,
|
|
148
|
+
generatedAt: new Date(),
|
|
149
|
+
emitted: {
|
|
150
|
+
indexRoutes: [],
|
|
151
|
+
archiveRoutes: [],
|
|
152
|
+
categoryRoutes: [],
|
|
153
|
+
tagRoutes: [],
|
|
154
|
+
posts: [],
|
|
155
|
+
pages: [],
|
|
156
|
+
},
|
|
135
157
|
};
|
|
136
158
|
}
|
|
137
159
|
|
|
@@ -158,13 +180,15 @@ async function finalizeBuildResult(writer, summaries, options) {
|
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
async function renderRoute(state, templateName, route) {
|
|
183
|
+
const currentUrl = normalizeRoutePath(route.path);
|
|
161
184
|
let html = await state.engine.render(
|
|
162
185
|
templateName,
|
|
163
186
|
{ ...route },
|
|
164
|
-
createRenderContext(state.previewData.site,
|
|
187
|
+
createRenderContext(state.previewData.site, currentUrl),
|
|
165
188
|
);
|
|
166
189
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
167
190
|
await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
|
|
191
|
+
recordRouteEmission(state, templateName, route, currentUrl);
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
async function renderPost(state, post) {
|
|
@@ -178,6 +202,14 @@ async function renderPost(state, post) {
|
|
|
178
202
|
html = injectHtmxScript(html);
|
|
179
203
|
}
|
|
180
204
|
await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
|
|
205
|
+
state.emitted.posts.push({
|
|
206
|
+
url: `/posts/${encodeSlugSegment(post.slug)}/`,
|
|
207
|
+
title: post.title,
|
|
208
|
+
description: post.excerpt,
|
|
209
|
+
publishedAt: post.published_at_iso,
|
|
210
|
+
updatedAt: post.updated_at_iso,
|
|
211
|
+
status: post.status,
|
|
212
|
+
});
|
|
181
213
|
}
|
|
182
214
|
|
|
183
215
|
async function renderPage(state, page) {
|
|
@@ -188,6 +220,25 @@ async function renderPage(state, page) {
|
|
|
188
220
|
);
|
|
189
221
|
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
190
222
|
await writeOutput(state.writer, state.summaries, `${encodeSlugSegment(page.slug)}/index.html`, html, 'text/html');
|
|
223
|
+
state.emitted.pages.push({
|
|
224
|
+
url: `/${encodeSlugSegment(page.slug)}/`,
|
|
225
|
+
title: page.title,
|
|
226
|
+
status: page.status,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function maybeRenderNotFoundPage(state) {
|
|
231
|
+
if (!state.engine.themePackage?.templates?.has('404')) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let html = await state.engine.render(
|
|
236
|
+
'404',
|
|
237
|
+
{},
|
|
238
|
+
createRenderContext(state.previewData.site, '/404.html'),
|
|
239
|
+
);
|
|
240
|
+
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
241
|
+
await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
|
|
191
242
|
}
|
|
192
243
|
|
|
193
244
|
function validateSelection(selection) {
|
|
@@ -207,6 +258,30 @@ function validateSelection(selection) {
|
|
|
207
258
|
}
|
|
208
259
|
}
|
|
209
260
|
|
|
261
|
+
function hasTemplate(state, templateName) {
|
|
262
|
+
return state.engine.themePackage?.templates?.has(templateName) === true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function recordRouteEmission(state, templateName, route, currentUrl) {
|
|
266
|
+
const targetMap = {
|
|
267
|
+
index: state.emitted.indexRoutes,
|
|
268
|
+
archive: state.emitted.archiveRoutes,
|
|
269
|
+
category: state.emitted.categoryRoutes,
|
|
270
|
+
tag: state.emitted.tagRoutes,
|
|
271
|
+
}[templateName];
|
|
272
|
+
|
|
273
|
+
if (!targetMap) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
targetMap.push({
|
|
278
|
+
url: currentUrl,
|
|
279
|
+
page: route.page,
|
|
280
|
+
totalPages: route.totalPages,
|
|
281
|
+
slug: route.slug,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
210
285
|
async function assertValidThemePackage(themePackage) {
|
|
211
286
|
if (!themePackage?.templates || !themePackage?.partials || !themePackage?.assets || !themePackage?.metadata) {
|
|
212
287
|
throw new Error('Invalid themePackage: expected metadata, templates, partials, and assets');
|
|
@@ -305,7 +380,8 @@ function normalizeRoutePath(routePath) {
|
|
|
305
380
|
if (!routePath || routePath === '/') {
|
|
306
381
|
return '/';
|
|
307
382
|
}
|
|
308
|
-
|
|
383
|
+
const normalized = decodeRoutePath(String(routePath)).replace(/^\/+|\/+$/g, '');
|
|
384
|
+
return `/${normalized}/`;
|
|
309
385
|
}
|
|
310
386
|
|
|
311
387
|
function normalizeOutputPath(filePath) {
|
|
@@ -326,37 +402,44 @@ function injectHtmxScript(html) {
|
|
|
326
402
|
return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
|
|
327
403
|
}
|
|
328
404
|
|
|
329
|
-
function buildSitemapXml(
|
|
405
|
+
function buildSitemapXml(site, emitted, generatedAt) {
|
|
330
406
|
const entries = [
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
407
|
+
...emitted.indexRoutes
|
|
408
|
+
.filter((route) => route.url === '/')
|
|
409
|
+
.map((route) => ({
|
|
410
|
+
url: route.url,
|
|
411
|
+
lastmod: generatedAt,
|
|
412
|
+
changefreq: 'daily',
|
|
413
|
+
priority: 1.0,
|
|
414
|
+
})),
|
|
415
|
+
...emitted.posts.map((post) => ({
|
|
416
|
+
url: post.url,
|
|
417
|
+
lastmod: toDate(post.updatedAt),
|
|
335
418
|
changefreq: 'weekly',
|
|
336
419
|
priority: 0.8,
|
|
337
420
|
})),
|
|
338
|
-
...
|
|
339
|
-
url:
|
|
340
|
-
lastmod:
|
|
421
|
+
...emitted.pages.map((page) => ({
|
|
422
|
+
url: page.url,
|
|
423
|
+
lastmod: generatedAt,
|
|
341
424
|
changefreq: 'monthly',
|
|
342
425
|
priority: 0.7,
|
|
343
426
|
})),
|
|
344
|
-
...
|
|
345
|
-
url:
|
|
346
|
-
lastmod:
|
|
427
|
+
...emitted.categoryRoutes.filter((route) => route.page === 1).map((route) => ({
|
|
428
|
+
url: route.url,
|
|
429
|
+
lastmod: generatedAt,
|
|
347
430
|
changefreq: 'weekly',
|
|
348
431
|
priority: 0.6,
|
|
349
432
|
})),
|
|
350
|
-
...
|
|
351
|
-
url:
|
|
352
|
-
lastmod:
|
|
433
|
+
...emitted.tagRoutes.filter((route) => route.page === 1).map((route) => ({
|
|
434
|
+
url: route.url,
|
|
435
|
+
lastmod: generatedAt,
|
|
353
436
|
changefreq: 'weekly',
|
|
354
437
|
priority: 0.5,
|
|
355
438
|
})),
|
|
356
439
|
];
|
|
357
440
|
|
|
358
441
|
const body = entries.map((entry) => {
|
|
359
|
-
const loc = escapeXml(
|
|
442
|
+
const loc = escapeXml(resolveSiteUrl(site.url, entry.url));
|
|
360
443
|
const lastmod = entry.lastmod.toISOString().split('T')[0];
|
|
361
444
|
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod}</lastmod>\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
|
|
362
445
|
}).join('\n');
|
|
@@ -364,36 +447,36 @@ function buildSitemapXml(previewData) {
|
|
|
364
447
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
|
|
365
448
|
}
|
|
366
449
|
|
|
367
|
-
function buildFeedXml(
|
|
368
|
-
const items = [...
|
|
369
|
-
.sort((a, b) => toDate(b.
|
|
450
|
+
function buildFeedXml(site, emitted, generatedAt) {
|
|
451
|
+
const items = [...emitted.posts]
|
|
452
|
+
.sort((a, b) => toDate(b.publishedAt).getTime() - toDate(a.publishedAt).getTime())
|
|
370
453
|
.slice(0, 20)
|
|
371
454
|
.map((post) => {
|
|
372
|
-
const url =
|
|
373
|
-
return ` <item>\n <title>${escapeXml(post.title)}</title>\n <link>${escapeXml(url)}</link>\n <guid>${escapeXml(url)}</guid>\n <pubDate>${toDate(post.
|
|
455
|
+
const url = resolveSiteUrl(site.url, post.url);
|
|
456
|
+
return ` <item>\n <title>${escapeXml(post.title)}</title>\n <link>${escapeXml(url)}</link>\n <guid>${escapeXml(url)}</guid>\n <pubDate>${toDate(post.publishedAt).toUTCString()}</pubDate>\n <description>${escapeXml(post.description)}</description>\n </item>`;
|
|
374
457
|
})
|
|
375
458
|
.join('\n');
|
|
376
459
|
|
|
377
|
-
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(
|
|
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(site.url)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.language}</language>\n <lastBuildDate>${generatedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(new URL('/feed.xml', site.url).toString())}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
|
|
378
461
|
}
|
|
379
462
|
|
|
380
463
|
function buildRobotsTxt(previewData) {
|
|
381
464
|
return `User-agent: *\nAllow: /\n\nSitemap: ${previewData.site.url}/sitemap.xml\n`;
|
|
382
465
|
}
|
|
383
466
|
|
|
384
|
-
function buildMetaJson(
|
|
467
|
+
function buildMetaJson(site, emitted, generatedAt) {
|
|
385
468
|
const pages = [
|
|
386
|
-
...
|
|
387
|
-
url:
|
|
469
|
+
...emitted.posts.map((post) => ({
|
|
470
|
+
url: post.url,
|
|
388
471
|
title: post.title,
|
|
389
|
-
description: post.
|
|
390
|
-
publishedAt: post.
|
|
391
|
-
updatedAt: post.
|
|
472
|
+
description: post.description,
|
|
473
|
+
publishedAt: post.publishedAt,
|
|
474
|
+
updatedAt: post.updatedAt,
|
|
392
475
|
type: 'post',
|
|
393
476
|
metadata: { status: post.status },
|
|
394
477
|
})),
|
|
395
|
-
...
|
|
396
|
-
url:
|
|
478
|
+
...emitted.pages.map((page) => ({
|
|
479
|
+
url: page.url,
|
|
397
480
|
title: page.title,
|
|
398
481
|
type: 'page',
|
|
399
482
|
metadata: { status: page.status },
|
|
@@ -401,12 +484,12 @@ function buildMetaJson(previewData) {
|
|
|
401
484
|
];
|
|
402
485
|
|
|
403
486
|
return JSON.stringify({
|
|
404
|
-
generated:
|
|
487
|
+
generated: generatedAt.toISOString(),
|
|
405
488
|
site: {
|
|
406
|
-
title:
|
|
407
|
-
description:
|
|
408
|
-
url:
|
|
409
|
-
language:
|
|
489
|
+
title: site.title,
|
|
490
|
+
description: site.description,
|
|
491
|
+
url: site.url,
|
|
492
|
+
language: site.language,
|
|
410
493
|
},
|
|
411
494
|
pages,
|
|
412
495
|
}, null, 2);
|
|
@@ -438,7 +521,36 @@ function getContentType(assetPath) {
|
|
|
438
521
|
}
|
|
439
522
|
|
|
440
523
|
function encodeSlugSegment(slug) {
|
|
441
|
-
return
|
|
524
|
+
return normalizeStoredSlug(slug);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function normalizeStoredSlug(slug) {
|
|
528
|
+
if (typeof slug !== 'string') {
|
|
529
|
+
return '';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const trimmed = slug.trim();
|
|
533
|
+
if (!trimmed.includes('%')) {
|
|
534
|
+
return trimmed;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
return decodeURIComponent(trimmed);
|
|
539
|
+
} catch {
|
|
540
|
+
return trimmed;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function decodeRoutePath(routePath) {
|
|
545
|
+
try {
|
|
546
|
+
return decodeURI(routePath);
|
|
547
|
+
} catch {
|
|
548
|
+
return routePath;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function resolveSiteUrl(siteUrl, relativePath) {
|
|
553
|
+
return decodeURI(new URL(relativePath, siteUrl).toString());
|
|
442
554
|
}
|
|
443
555
|
|
|
444
556
|
function toDate(value) {
|