@zeropress/build-core 0.1.2 → 0.1.3

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,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 canonical input graph. Actual artifact output is the intersection of:
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,18 @@ 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
+
194
208
  Optional route templates behave as rendering capabilities, not guaranteed outputs:
195
209
 
196
210
  - `archive.html`
@@ -198,7 +212,7 @@ Optional route templates behave as rendering capabilities, not guaranteed output
198
212
  - `tag.html`
199
213
  - `404.html`
200
214
 
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.
215
+ 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
216
 
203
217
  ---
204
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.2.0",
36
+ "@zeropress/preview-data-validator": "^0.3.0",
37
37
  "@zeropress/theme-validator": "^0.2.2"
38
38
  }
39
39
  }
@@ -11,36 +11,43 @@ 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';
19
+ const COMMENTS_HTML = '<section id="comments"></section>';
20
+
14
21
  export async function buildSite(input) {
15
22
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
16
23
  const state = await createBuildState(input, options);
17
24
 
18
- for (const route of input.previewData.routes.index) {
25
+ for (const route of state.renderData.indexRoutes) {
19
26
  await renderRoute(state, 'index', route);
20
27
  }
21
28
 
22
- for (const post of input.previewData.content.posts) {
29
+ for (const post of state.renderData.posts) {
23
30
  await renderPost(state, post);
24
31
  }
25
32
 
26
- for (const page of input.previewData.content.pages) {
33
+ for (const page of state.previewData.content.pages) {
27
34
  await renderPage(state, page);
28
35
  }
29
36
 
30
37
  if (hasTemplate(state, 'category')) {
31
- for (const route of input.previewData.routes.categories) {
38
+ for (const route of state.renderData.categoryRoutes) {
32
39
  await renderRoute(state, 'category', route);
33
40
  }
34
41
  }
35
42
 
36
43
  if (hasTemplate(state, 'tag')) {
37
- for (const route of input.previewData.routes.tags) {
44
+ for (const route of state.renderData.tagRoutes) {
38
45
  await renderRoute(state, 'tag', route);
39
46
  }
40
47
  }
41
48
 
42
49
  if (hasTemplate(state, 'archive')) {
43
- for (const route of input.previewData.routes.archive) {
50
+ for (const route of state.renderData.archiveRoutes) {
44
51
  await renderRoute(state, 'archive', route);
45
52
  }
46
53
  }
@@ -53,7 +60,7 @@ export async function buildSite(input) {
53
60
  await maybeRenderNotFoundPage(state);
54
61
  await writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt), 'application/xml');
55
62
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
56
- await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(input.previewData), 'text/plain');
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 input.previewData.content.posts) {
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 input.previewData.routes.index) {
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 input.previewData.routes.archive) {
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 input.previewData.routes.categories) {
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 input.previewData.routes.tags) {
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: input.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, `/posts/${encodeSlugSegment(post.slug)}/`),
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: `/posts/${encodeSlugSegment(post.slug)}/`,
216
+ url: currentUrl,
207
217
  title: post.title,
208
218
  description: post.excerpt,
209
219
  publishedAt: post.published_at_iso,
@@ -258,6 +268,416 @@ 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
+ siteTimezone: normalizeNonEmptyString(previewData.site.siteTimezone, DEFAULT_TIMEZONE),
282
+ siteLocale: normalizeLocale(previewData.site.siteLocale || previewData.site.language || DEFAULT_LOCALE),
283
+ },
284
+ content: {
285
+ ...previewData.content,
286
+ posts: [...previewData.content.posts].sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
287
+ pages: [...previewData.content.pages],
288
+ categories: [...previewData.content.categories],
289
+ tags: [...previewData.content.tags],
290
+ },
291
+ };
292
+ }
293
+
294
+ function createRenderData(previewData) {
295
+ const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
296
+ const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
297
+ const categoryPostsBySlug = new Map();
298
+ const tagPostsBySlug = new Map();
299
+ const categoryCountBySlug = new Map();
300
+ const tagCountBySlug = new Map();
301
+
302
+ for (const post of previewData.content.posts) {
303
+ for (const slug of post.category_slugs) {
304
+ pushToSlugMap(categoryPostsBySlug, slug, post);
305
+ categoryCountBySlug.set(slug, (categoryCountBySlug.get(slug) || 0) + 1);
306
+ }
307
+
308
+ for (const slug of post.tag_slugs) {
309
+ pushToSlugMap(tagPostsBySlug, slug, post);
310
+ tagCountBySlug.set(slug, (tagCountBySlug.get(slug) || 0) + 1);
311
+ }
312
+ }
313
+
314
+ const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, categoriesBySlug, tagsBySlug));
315
+ const postBySlug = new Map(posts.map((post) => [post.slug, post]));
316
+ const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
317
+ const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
318
+
319
+ return {
320
+ posts,
321
+ postBySlug,
322
+ indexRoutes: buildPaginatedCollection({
323
+ items: previewData.content.posts,
324
+ postsPerPage: previewData.site.postsPerPage,
325
+ basePath: '/',
326
+ }).map((entry) => ({
327
+ path: entry.path,
328
+ page: entry.page,
329
+ totalPages: entry.totalPages,
330
+ posts: renderPostList(entry.items, postBySlug),
331
+ categories: categoryLinks,
332
+ tags: tagLinks,
333
+ pagination: renderPagination(entry.paginationData),
334
+ })),
335
+ archiveRoutes: buildPaginatedCollection({
336
+ items: previewData.content.posts,
337
+ postsPerPage: previewData.site.postsPerPage,
338
+ basePath: '/archive/',
339
+ }).map((entry) => ({
340
+ path: entry.path,
341
+ page: entry.page,
342
+ totalPages: entry.totalPages,
343
+ posts: renderArchive(entry.items, postBySlug),
344
+ pagination: renderPagination(entry.paginationData),
345
+ })),
346
+ categoryRoutes: buildTaxonomyRoutes({
347
+ items: previewData.content.categories,
348
+ postsBySlug: categoryPostsBySlug,
349
+ postsPerPage: previewData.site.postsPerPage,
350
+ buildBasePath: (category) => `/categories/${encodeSlugSegment(category.slug)}/`,
351
+ renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
352
+ renderExtras: () => ({ categories: categoryLinks }),
353
+ }),
354
+ tagRoutes: buildTaxonomyRoutes({
355
+ items: previewData.content.tags,
356
+ postsBySlug: tagPostsBySlug,
357
+ postsPerPage: previewData.site.postsPerPage,
358
+ buildBasePath: (tag) => `/tags/${encodeSlugSegment(tag.slug)}/`,
359
+ renderList: (postsForRoute) => renderPostList(postsForRoute, postBySlug),
360
+ renderExtras: () => ({ tags: tagLinks }),
361
+ }),
362
+ };
363
+ }
364
+
365
+ function preparePost(post, site, categoriesBySlug, tagsBySlug) {
366
+ return {
367
+ ...post,
368
+ published_at: formatTimestamp(post.published_at_iso, site),
369
+ updated_at: formatTimestamp(post.updated_at_iso, site),
370
+ reading_time: calculateReadingTime(post.html),
371
+ categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
372
+ tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
373
+ comments_html: COMMENTS_HTML,
374
+ };
375
+ }
376
+
377
+ function buildTaxonomyRoutes(options) {
378
+ const routes = [];
379
+
380
+ for (const item of options.items) {
381
+ const matchedPosts = options.postsBySlug.get(item.slug) || [];
382
+ if (matchedPosts.length === 0) {
383
+ continue;
384
+ }
385
+
386
+ const paginated = buildPaginatedCollection({
387
+ items: matchedPosts,
388
+ postsPerPage: options.postsPerPage,
389
+ basePath: options.buildBasePath(item),
390
+ });
391
+
392
+ for (const entry of paginated) {
393
+ routes.push({
394
+ path: entry.path,
395
+ slug: item.slug,
396
+ page: entry.page,
397
+ totalPages: entry.totalPages,
398
+ posts: options.renderList(entry.items),
399
+ pagination: renderPagination(entry.paginationData),
400
+ ...options.renderExtras(item),
401
+ });
402
+ }
403
+ }
404
+
405
+ return routes;
406
+ }
407
+
408
+ function buildPaginatedCollection(options) {
409
+ const basePath = normalizePaginationBasePath(options.basePath);
410
+ const postsPerPage = Number.isInteger(options.postsPerPage) && options.postsPerPage > 0 ? options.postsPerPage : DEFAULT_POSTS_PER_PAGE;
411
+ const totalPosts = options.items.length;
412
+ const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
413
+ const pages = [];
414
+
415
+ for (let page = 1; page <= totalPages; page += 1) {
416
+ const start = (page - 1) * postsPerPage;
417
+ const end = start + postsPerPage;
418
+
419
+ pages.push({
420
+ path: buildPaginatedPath(basePath, page),
421
+ page,
422
+ totalPages,
423
+ items: options.items.slice(start, end),
424
+ paginationData: buildPaginationData(page, totalPages, totalPosts, basePath),
425
+ });
426
+ }
427
+
428
+ return pages;
429
+ }
430
+
431
+ function buildPaginatedPath(basePath, page) {
432
+ const normalizedBasePath = normalizePaginationBasePath(basePath);
433
+ if (page <= 1) {
434
+ return normalizedBasePath;
435
+ }
436
+ if (normalizedBasePath === '/') {
437
+ return `/page/${page}/`;
438
+ }
439
+ return `${normalizedBasePath}page/${page}/`;
440
+ }
441
+
442
+ function normalizePaginationBasePath(basePath) {
443
+ if (!basePath || basePath === '/') {
444
+ return '/';
445
+ }
446
+
447
+ const normalized = decodeRoutePath(String(basePath)).replace(/^\/+|\/+$/g, '');
448
+ return `/${normalized}/`;
449
+ }
450
+
451
+ function buildPaginationData(currentPage, totalPages, totalPosts, basePath) {
452
+ return {
453
+ currentPage,
454
+ totalPages,
455
+ totalPosts,
456
+ hasNext: currentPage < totalPages,
457
+ hasPrev: currentPage > 1,
458
+ nextUrl: currentPage < totalPages ? buildPaginatedPath(basePath, currentPage + 1) : undefined,
459
+ prevUrl: currentPage > 1 ? buildPaginatedPath(basePath, currentPage - 1) : undefined,
460
+ pages: Array.from({ length: totalPages }, (_, index) => {
461
+ const page = index + 1;
462
+ return {
463
+ number: page,
464
+ url: buildPaginatedPath(basePath, page),
465
+ current: page === currentPage,
466
+ };
467
+ }),
468
+ };
469
+ }
470
+
471
+ function renderPostList(posts, postBySlug) {
472
+ if (posts.length === 0) {
473
+ return '<p>No posts</p>';
474
+ }
475
+
476
+ return posts
477
+ .map((post) => {
478
+ const prepared = postBySlug.get(post.slug);
479
+ if (!prepared) {
480
+ return '';
481
+ }
482
+
483
+ return [
484
+ '<article class="post-list-item">',
485
+ ` <h2><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a></h2>`,
486
+ ` <div class="post-meta"><time datetime="${escapeHtml(prepared.published_at_iso)}">${escapeHtml(prepared.published_at)}</time> • ${escapeHtml(prepared.reading_time)}</div>`,
487
+ ` <div class="post-excerpt">${escapeHtml(prepared.excerpt)}</div>`,
488
+ '</article>',
489
+ ].join('\n');
490
+ })
491
+ .filter(Boolean)
492
+ .join('\n');
493
+ }
494
+
495
+ function renderArchive(posts, postBySlug) {
496
+ if (posts.length === 0) {
497
+ return '<p>No posts</p>';
498
+ }
499
+
500
+ const groups = new Map();
501
+
502
+ for (const post of posts) {
503
+ const key = `${toDate(post.published_at_iso).getUTCFullYear()}-${String(toDate(post.published_at_iso).getUTCMonth() + 1).padStart(2, '0')}`;
504
+ const items = groups.get(key) || [];
505
+ items.push(post);
506
+ groups.set(key, items);
507
+ }
508
+
509
+ return Array.from(groups.entries())
510
+ .map(([key, groupPosts]) => {
511
+ const items = groupPosts
512
+ .map((post) => {
513
+ const prepared = postBySlug.get(post.slug);
514
+ if (!prepared) {
515
+ return '';
516
+ }
517
+
518
+ 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>`;
519
+ })
520
+ .filter(Boolean)
521
+ .join('\n');
522
+
523
+ return `<section class="archive-group"><h2>${escapeHtml(key)}</h2><ul>${items}</ul></section>`;
524
+ })
525
+ .join('\n');
526
+ }
527
+
528
+ function renderCategoryLinks(categories, countBySlug) {
529
+ return categories
530
+ .map((category) => {
531
+ const count = countBySlug.get(category.slug) || 0;
532
+ if (count === 0) {
533
+ return null;
534
+ }
535
+
536
+ return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)} (${count})</a>`;
537
+ })
538
+ .filter(Boolean)
539
+ .join('\n');
540
+ }
541
+
542
+ function renderTagLinks(tags, countBySlug) {
543
+ return tags
544
+ .map((tag) => {
545
+ const count = countBySlug.get(tag.slug) || 0;
546
+ if (count === 0) {
547
+ return null;
548
+ }
549
+
550
+ return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)} (${count})</a>`;
551
+ })
552
+ .filter(Boolean)
553
+ .join('\n');
554
+ }
555
+
556
+ function renderInlineTaxonomyLinks(slugs, taxonomyBySlug, kind) {
557
+ const links = slugs
558
+ .map((slug) => taxonomyBySlug.get(slug))
559
+ .filter(Boolean)
560
+ .map((item) => {
561
+ const base = kind === 'category' ? '/categories/' : '/tags/';
562
+ const className = kind === 'category' ? 'category-link' : 'tag-link';
563
+ return `<a href="${base}${escapeHtml(encodeSlugSegment(item.slug))}/" class="${className}">${escapeHtml(item.name)}</a>`;
564
+ });
565
+
566
+ if (links.length === 0) {
567
+ return '';
568
+ }
569
+
570
+ return kind === 'category' ? links.join(', ') : links.join(' ');
571
+ }
572
+
573
+ function renderPagination(paginationData) {
574
+ if (paginationData.totalPages <= 1) {
575
+ return '';
576
+ }
577
+
578
+ const prevLink = paginationData.hasPrev && paginationData.prevUrl
579
+ ? `<a href="${paginationData.prevUrl}" class="prev">Previous</a>`
580
+ : '';
581
+ const nextLink = paginationData.hasNext && paginationData.nextUrl
582
+ ? `<a href="${paginationData.nextUrl}" class="next">Next</a>`
583
+ : '';
584
+ const pageLinks = paginationData.pages
585
+ .map((page) => `<a href="${page.url}" class="page-link ${page.current ? 'current' : ''}">${page.number}</a>`)
586
+ .join(' ');
587
+
588
+ return `<nav class="pagination">\n ${prevLink}\n <span class="pages">${pageLinks}</span>\n ${nextLink}\n</nav>`;
589
+ }
590
+
591
+ function formatTimestamp(value, site) {
592
+ const date = toDate(value);
593
+ const locale = normalizeLocale(site.siteLocale || site.language || DEFAULT_LOCALE);
594
+ const dateFormat = normalizeNonEmptyString(site.dateFormat, DEFAULT_DATE_FORMAT);
595
+ const timeFormat = typeof site.timeFormat === 'string' ? site.timeFormat : DEFAULT_TIME_FORMAT;
596
+ const siteTimezone = normalizeNonEmptyString(site.siteTimezone, DEFAULT_TIMEZONE);
597
+
598
+ const dateParts = new Intl.DateTimeFormat(locale, {
599
+ timeZone: siteTimezone,
600
+ year: 'numeric',
601
+ month: dateFormat === 'MMMM D, YYYY' ? 'long' : '2-digit',
602
+ day: '2-digit',
603
+ }).formatToParts(date);
604
+
605
+ const year = dateParts.find((part) => part.type === 'year')?.value || '';
606
+ const month = dateParts.find((part) => part.type === 'month')?.value || '';
607
+ const day = dateParts.find((part) => part.type === 'day')?.value || '';
608
+
609
+ let formattedDate = `${year}-${month}-${day}`;
610
+ if (dateFormat === 'DD/MM/YYYY') {
611
+ formattedDate = `${day}/${month}/${year}`;
612
+ } else if (dateFormat === 'MM/DD/YYYY') {
613
+ formattedDate = `${month}/${day}/${year}`;
614
+ } else if (dateFormat === 'MMMM D, YYYY') {
615
+ formattedDate = `${month} ${String(Number(day))}, ${year}`;
616
+ }
617
+
618
+ if (!timeFormat) {
619
+ return formattedDate;
620
+ }
621
+
622
+ const timeParts = new Intl.DateTimeFormat(locale, {
623
+ timeZone: siteTimezone,
624
+ hour: '2-digit',
625
+ minute: '2-digit',
626
+ hour12: timeFormat === 'hh:mm A',
627
+ }).formatToParts(date);
628
+
629
+ const hour = timeParts.find((part) => part.type === 'hour')?.value || '';
630
+ const minute = timeParts.find((part) => part.type === 'minute')?.value || '';
631
+ const dayPeriod = (timeParts.find((part) => part.type === 'dayPeriod')?.value || '').toUpperCase();
632
+
633
+ const formattedTime = timeFormat === 'hh:mm A'
634
+ ? `${hour}:${minute}${dayPeriod ? ` ${dayPeriod}` : ''}`
635
+ : `${hour}:${minute}`;
636
+
637
+ return `${formattedDate} ${formattedTime}`.trim();
638
+ }
639
+
640
+ function calculateReadingTime(html) {
641
+ const plainText = String(html || '').replace(/<[^>]*>/g, ' ');
642
+ const wordCount = plainText.trim().split(/\s+/).filter(Boolean).length;
643
+ const minutes = Math.max(1, Math.ceil(wordCount / 200));
644
+ return minutes === 1 ? '1 min read' : `${minutes} min read`;
645
+ }
646
+
647
+ function pushToSlugMap(target, slug, value) {
648
+ const items = target.get(slug) || [];
649
+ items.push(value);
650
+ target.set(slug, items);
651
+ }
652
+
653
+ function normalizeNonEmptyString(value, fallback) {
654
+ return typeof value === 'string' && value.trim() ? value : fallback;
655
+ }
656
+
657
+ function normalizeLocale(value) {
658
+ if (typeof value !== 'string' || !value.trim()) {
659
+ return DEFAULT_LOCALE;
660
+ }
661
+
662
+ const parts = value.trim().replace(/_/g, '-').split('-').filter(Boolean);
663
+ if (parts.length === 0) {
664
+ return DEFAULT_LOCALE;
665
+ }
666
+
667
+ return parts.map((part, index) => {
668
+ if (index === 0) {
669
+ return part.toLowerCase();
670
+ }
671
+ if (part.length === 2 || part.length === 3) {
672
+ return part.toUpperCase();
673
+ }
674
+ if (part.length === 4) {
675
+ return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
676
+ }
677
+ return part.toLowerCase();
678
+ }).join('-');
679
+ }
680
+
261
681
  function hasTemplate(state, templateName) {
262
682
  return state.engine.themePackage?.templates?.has(templateName) === true;
263
683
  }
@@ -407,11 +827,11 @@ function buildSitemapXml(site, emitted, generatedAt) {
407
827
  ...emitted.indexRoutes
408
828
  .filter((route) => route.url === '/')
409
829
  .map((route) => ({
410
- url: route.url,
411
- lastmod: generatedAt,
412
- changefreq: 'daily',
413
- priority: 1.0,
414
- })),
830
+ url: route.url,
831
+ lastmod: generatedAt,
832
+ changefreq: 'daily',
833
+ priority: 1.0,
834
+ })),
415
835
  ...emitted.posts.map((post) => ({
416
836
  url: post.url,
417
837
  lastmod: toDate(post.updatedAt),
@@ -448,6 +868,8 @@ function buildSitemapXml(site, emitted, generatedAt) {
448
868
  }
449
869
 
450
870
  function buildFeedXml(site, emitted, generatedAt) {
871
+ const channelLink = resolveSiteUrl(site.url, '/');
872
+ const selfLink = resolveSiteUrl(site.url, '/feed.xml');
451
873
  const items = [...emitted.posts]
452
874
  .sort((a, b) => toDate(b.publishedAt).getTime() - toDate(a.publishedAt).getTime())
453
875
  .slice(0, 20)
@@ -457,11 +879,15 @@ function buildFeedXml(site, emitted, generatedAt) {
457
879
  })
458
880
  .join('\n');
459
881
 
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>`;
882
+ 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.language}</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
883
  }
462
884
 
463
- function buildRobotsTxt(previewData) {
464
- return `User-agent: *\nAllow: /\n\nSitemap: ${previewData.site.url}/sitemap.xml\n`;
885
+ function buildRobotsTxt(site) {
886
+ const lines = ['User-agent: *', 'Allow: /'];
887
+ if (site.url) {
888
+ lines.push('', `Sitemap: ${resolveSiteUrl(site.url, '/sitemap.xml')}`);
889
+ }
890
+ return `${lines.join('\n')}\n`;
465
891
  }
466
892
 
467
893
  function buildMetaJson(site, emitted, generatedAt) {
@@ -550,6 +976,10 @@ function decodeRoutePath(routePath) {
550
976
  }
551
977
 
552
978
  function resolveSiteUrl(siteUrl, relativePath) {
979
+ if (!siteUrl) {
980
+ return normalizeRoutePath(relativePath);
981
+ }
982
+
553
983
  return decodeURI(new URL(relativePath, siteUrl).toString());
554
984
  }
555
985
 
@@ -565,3 +995,12 @@ function escapeXml(str) {
565
995
  .replace(/"/g, '&quot;')
566
996
  .replace(/'/g, '&apos;');
567
997
  }
998
+
999
+ function escapeHtml(value) {
1000
+ return String(value || '')
1001
+ .replaceAll('&', '&amp;')
1002
+ .replaceAll('<', '&lt;')
1003
+ .replaceAll('>', '&gt;')
1004
+ .replaceAll('"', '&quot;')
1005
+ .replaceAll("'", '&#39;');
1006
+ }
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PreviewDataV03 } from '@zeropress/preview-data-validator';
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: PreviewDataV03;
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: PreviewDataV03;
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: PreviewDataV03;
80
+ previewData: PreviewDataV04;
81
81
  themeDir: string;
82
82
  writer: BuildWriter;
83
83
  options?: BuildOptions;