@zeropress/build-core 0.1.1 → 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.1",
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
 
@@ -166,6 +188,7 @@ async function renderRoute(state, templateName, route) {
166
188
  );
167
189
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
168
190
  await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
191
+ recordRouteEmission(state, templateName, route, currentUrl);
169
192
  }
170
193
 
171
194
  async function renderPost(state, post) {
@@ -179,6 +202,14 @@ async function renderPost(state, post) {
179
202
  html = injectHtmxScript(html);
180
203
  }
181
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
+ });
182
213
  }
183
214
 
184
215
  async function renderPage(state, page) {
@@ -189,6 +220,25 @@ async function renderPage(state, page) {
189
220
  );
190
221
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
191
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');
192
242
  }
193
243
 
194
244
  function validateSelection(selection) {
@@ -208,6 +258,30 @@ function validateSelection(selection) {
208
258
  }
209
259
  }
210
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
+
211
285
  async function assertValidThemePackage(themePackage) {
212
286
  if (!themePackage?.templates || !themePackage?.partials || !themePackage?.assets || !themePackage?.metadata) {
213
287
  throw new Error('Invalid themePackage: expected metadata, templates, partials, and assets');
@@ -328,37 +402,44 @@ function injectHtmxScript(html) {
328
402
  return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
329
403
  }
330
404
 
331
- function buildSitemapXml(previewData) {
405
+ function buildSitemapXml(site, emitted, generatedAt) {
332
406
  const entries = [
333
- { url: '/', lastmod: new Date(), changefreq: 'daily', priority: 1.0 },
334
- ...previewData.content.posts.map((post) => ({
335
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
336
- 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),
337
418
  changefreq: 'weekly',
338
419
  priority: 0.8,
339
420
  })),
340
- ...previewData.content.pages.map((page) => ({
341
- url: `/${encodeSlugSegment(page.slug)}/`,
342
- lastmod: new Date(),
421
+ ...emitted.pages.map((page) => ({
422
+ url: page.url,
423
+ lastmod: generatedAt,
343
424
  changefreq: 'monthly',
344
425
  priority: 0.7,
345
426
  })),
346
- ...previewData.content.categories.filter((category) => category.postCount > 0).map((category) => ({
347
- url: `/categories/${encodeSlugSegment(category.slug)}/`,
348
- lastmod: new Date(),
427
+ ...emitted.categoryRoutes.filter((route) => route.page === 1).map((route) => ({
428
+ url: route.url,
429
+ lastmod: generatedAt,
349
430
  changefreq: 'weekly',
350
431
  priority: 0.6,
351
432
  })),
352
- ...previewData.content.tags.filter((tag) => tag.postCount > 0).map((tag) => ({
353
- url: `/tags/${encodeSlugSegment(tag.slug)}/`,
354
- lastmod: new Date(),
433
+ ...emitted.tagRoutes.filter((route) => route.page === 1).map((route) => ({
434
+ url: route.url,
435
+ lastmod: generatedAt,
355
436
  changefreq: 'weekly',
356
437
  priority: 0.5,
357
438
  })),
358
439
  ];
359
440
 
360
441
  const body = entries.map((entry) => {
361
- const loc = escapeXml(resolveSiteUrl(previewData.site.url, entry.url));
442
+ const loc = escapeXml(resolveSiteUrl(site.url, entry.url));
362
443
  const lastmod = entry.lastmod.toISOString().split('T')[0];
363
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>`;
364
445
  }).join('\n');
@@ -366,36 +447,36 @@ function buildSitemapXml(previewData) {
366
447
  return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
367
448
  }
368
449
 
369
- function buildFeedXml(previewData) {
370
- const items = [...previewData.content.posts]
371
- .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())
372
453
  .slice(0, 20)
373
454
  .map((post) => {
374
- const url = resolveSiteUrl(previewData.site.url, `/posts/${encodeSlugSegment(post.slug)}/`);
375
- 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>`;
376
457
  })
377
458
  .join('\n');
378
459
 
379
- 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>`;
380
461
  }
381
462
 
382
463
  function buildRobotsTxt(previewData) {
383
464
  return `User-agent: *\nAllow: /\n\nSitemap: ${previewData.site.url}/sitemap.xml\n`;
384
465
  }
385
466
 
386
- function buildMetaJson(previewData) {
467
+ function buildMetaJson(site, emitted, generatedAt) {
387
468
  const pages = [
388
- ...previewData.content.posts.map((post) => ({
389
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
469
+ ...emitted.posts.map((post) => ({
470
+ url: post.url,
390
471
  title: post.title,
391
- description: post.excerpt,
392
- publishedAt: post.published_at_iso,
393
- updatedAt: post.updated_at_iso,
472
+ description: post.description,
473
+ publishedAt: post.publishedAt,
474
+ updatedAt: post.updatedAt,
394
475
  type: 'post',
395
476
  metadata: { status: post.status },
396
477
  })),
397
- ...previewData.content.pages.map((page) => ({
398
- url: `/${encodeSlugSegment(page.slug)}/`,
478
+ ...emitted.pages.map((page) => ({
479
+ url: page.url,
399
480
  title: page.title,
400
481
  type: 'page',
401
482
  metadata: { status: page.status },
@@ -403,12 +484,12 @@ function buildMetaJson(previewData) {
403
484
  ];
404
485
 
405
486
  return JSON.stringify({
406
- generated: new Date().toISOString(),
487
+ generated: generatedAt.toISOString(),
407
488
  site: {
408
- title: previewData.site.title,
409
- description: previewData.site.description,
410
- url: previewData.site.url,
411
- language: previewData.site.language,
489
+ title: site.title,
490
+ description: site.description,
491
+ url: site.url,
492
+ language: site.language,
412
493
  },
413
494
  pages,
414
495
  }, null, 2);