@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Shared ZeroPress build and partial-render core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,16 +27,22 @@ export async function buildSite(input) {
27
27
  await renderPage(state, page);
28
28
  }
29
29
 
30
- for (const route of input.previewData.routes.categories) {
31
- await renderRoute(state, 'category', route);
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
- for (const route of input.previewData.routes.tags) {
35
- await renderRoute(state, 'tag', route);
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
- for (const route of input.previewData.routes.archive) {
39
- await renderRoute(state, 'archive', route);
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 writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(input.previewData), 'application/xml');
48
- await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(input.previewData), 'application/rss+xml');
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(input.previewData), 'application/json');
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
- for (const route of input.previewData.routes.archive) {
80
- if (selectedArchiveRoutes.has(normalizeRoutePath(route.path))) {
81
- await renderRoute(state, 'archive', route);
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
- for (const route of input.previewData.routes.categories) {
86
- if (selectedCategoryRoutes.has(normalizeRoutePath(route.path))) {
87
- await renderRoute(state, 'category', route);
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
- for (const route of input.previewData.routes.tags) {
92
- if (selectedTagRoutes.has(normalizeRoutePath(route.path))) {
93
- await renderRoute(state, 'tag', route);
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, route.path),
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
- return `/${String(routePath).replace(/^\/+|\/+$/g, '')}/`;
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(previewData) {
405
+ function buildSitemapXml(site, emitted, generatedAt) {
330
406
  const entries = [
331
- { url: '/', lastmod: new Date(), changefreq: 'daily', priority: 1.0 },
332
- ...previewData.content.posts.map((post) => ({
333
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
334
- lastmod: toDate(post.updated_at_iso),
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
- ...previewData.content.pages.map((page) => ({
339
- url: `/${encodeSlugSegment(page.slug)}/`,
340
- lastmod: new Date(),
421
+ ...emitted.pages.map((page) => ({
422
+ url: page.url,
423
+ lastmod: generatedAt,
341
424
  changefreq: 'monthly',
342
425
  priority: 0.7,
343
426
  })),
344
- ...previewData.content.categories.filter((category) => category.postCount > 0).map((category) => ({
345
- url: `/categories/${encodeSlugSegment(category.slug)}/`,
346
- lastmod: new Date(),
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
- ...previewData.content.tags.filter((tag) => tag.postCount > 0).map((tag) => ({
351
- url: `/tags/${encodeSlugSegment(tag.slug)}/`,
352
- lastmod: new Date(),
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(new URL(entry.url, previewData.site.url).toString());
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(previewData) {
368
- const items = [...previewData.content.posts]
369
- .sort((a, b) => toDate(b.published_at_iso).getTime() - toDate(a.published_at_iso).getTime())
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 = new URL(`/posts/${encodeSlugSegment(post.slug)}/`, previewData.site.url).toString();
373
- return ` <item>\n <title>${escapeXml(post.title)}</title>\n <link>${escapeXml(url)}</link>\n <guid>${escapeXml(url)}</guid>\n <pubDate>${toDate(post.published_at_iso).toUTCString()}</pubDate>\n <description>${escapeXml(post.excerpt)}</description>\n </item>`;
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(previewData.site.title)}</title>\n <link>${escapeXml(previewData.site.url)}</link>\n <description>${escapeXml(previewData.site.description)}</description>\n <language>${previewData.site.language}</language>\n <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(new URL('/feed.xml', previewData.site.url).toString())}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
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(previewData) {
467
+ function buildMetaJson(site, emitted, generatedAt) {
385
468
  const pages = [
386
- ...previewData.content.posts.map((post) => ({
387
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
469
+ ...emitted.posts.map((post) => ({
470
+ url: post.url,
388
471
  title: post.title,
389
- description: post.excerpt,
390
- publishedAt: post.published_at_iso,
391
- updatedAt: post.updated_at_iso,
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
- ...previewData.content.pages.map((page) => ({
396
- url: `/${encodeSlugSegment(page.slug)}/`,
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: new Date().toISOString(),
487
+ generated: generatedAt.toISOString(),
405
488
  site: {
406
- title: previewData.site.title,
407
- description: previewData.site.description,
408
- url: previewData.site.url,
409
- language: previewData.site.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 encodeURIComponent(String(slug));
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) {