@zeropress/build-core 0.1.8 → 0.1.10

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
@@ -9,7 +9,7 @@ Shared build and partial-render core for ZeroPress.
9
9
  This package is the deterministic rendering engine used by:
10
10
 
11
11
  - `zeropress-admin-api-v2`
12
- - future `@zeropress/build` CLI flows
12
+ - [`@zeropress/build`](https://www.npmjs.com/package/@zeropress/build) package
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
 
@@ -59,7 +59,12 @@ import {
59
59
  - `meta.json`
60
60
  - writing outputs through a pluggable writer
61
61
 
62
- It does not talk to databases, queues, KV, Durable Objects, R2, or deployment infrastructure directly.
62
+ It does not:
63
+
64
+ - fetch content from databases or APIs
65
+ - package or validate theme directories on its own unless the caller uses `buildSiteFromThemeDir`
66
+ - watch files, run a dev server, or perform deployment
67
+ - talk to queues, KV, Durable Objects, R2, GitHub, or other infrastructure directly
63
68
 
64
69
  ---
65
70
 
@@ -97,7 +102,7 @@ Returns:
97
102
  }
98
103
  ],
99
104
  manifest: {
100
- generatedAt: '2026-04-02T00:00:00.000Z',
105
+ generatedAt: '2026-04-02T00:00:00Z',
101
106
  files: [
102
107
  {
103
108
  path: 'index.html',
@@ -110,11 +115,29 @@ Returns:
110
115
  }
111
116
  ```
112
117
 
118
+ Notes:
119
+
120
+ - `writer` is required
121
+ - `previewData` must already satisfy the canonical preview-data contract
122
+ - `themePackage` must already be a validated in-memory theme package
123
+ - `sitemap.xml` and `feed.xml` are emitted only when `site.url` is a non-empty canonical URL
124
+ - `robots.txt` and `meta.json` are still emitted when `generateSpecialFiles` is enabled
125
+
113
126
  ### `buildSelectedRoutes(input)`
114
127
 
115
128
  Renders only selected post and list routes while preserving full-build parity for those outputs.
116
129
 
117
- This is intended for selective publish and other partial-render workflows.
130
+ This is intended for targeted rebuilds, output parity checks, and other partial-render workflows.
131
+
132
+ Current selection scope is limited to:
133
+
134
+ - post detail routes
135
+ - index pagination routes
136
+ - archive routes
137
+ - category routes
138
+ - tag routes
139
+
140
+ It does not currently support selecting individual page routes.
118
141
 
119
142
  Optional route templates are respected during both full and partial renders:
120
143
 
@@ -145,7 +168,7 @@ await buildSelectedRoutes({
145
168
 
146
169
  Loads a theme directory from disk and renders it using the same core pipeline.
147
170
 
148
- This is useful for local tooling and CLI flows.
171
+ This is useful for local tooling that wants filesystem theme loading but still uses the same deterministic core renderer.
149
172
 
150
173
  ---
151
174
 
@@ -193,6 +216,8 @@ Theme validation is enforced through:
193
216
 
194
217
  - [`@zeropress/theme-validator`](https://www.npmjs.com/package/@zeropress/theme-validator)
195
218
 
219
+ `buildSiteFromThemeDir()` is the convenience entry point that loads a theme directory and converts it into the required in-memory `themePackage`.
220
+
196
221
  As of `preview-data v0.4`, the payload no longer carries `routes` arrays or raw HTML fragments such as `categories_html`.
197
222
 
198
223
  Build-core now derives:
@@ -238,6 +263,13 @@ Supported options:
238
263
 
239
264
  These options apply to both full builds and partial renders where relevant.
240
265
 
266
+ Defaults:
267
+
268
+ - `assetHashing: true`
269
+ - `generateSpecialFiles: true`
270
+ - `injectHtmx: true`
271
+ - `writeManifest: false`
272
+
241
273
  ---
242
274
 
243
275
  ## Requirements
@@ -251,7 +283,7 @@ These options apply to both full builds and partial renders where relevant.
251
283
 
252
284
  - [@zeropress/preview-data-validator](https://www.npmjs.com/package/@zeropress/preview-data-validator)
253
285
  - [@zeropress/theme-validator](https://www.npmjs.com/package/@zeropress/theme-validator)
254
- - [zeropress-theme](https://www.npmjs.com/package/zeropress-theme)
286
+ - [@zeropress/theme](https://www.npmjs.com/package/@zeropress/theme)
255
287
 
256
288
  ---
257
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Shared ZeroPress build and partial-render core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,10 @@
33
33
  "node": ">=18.18.0"
34
34
  },
35
35
  "dependencies": {
36
- "@zeropress/preview-data-validator": "^0.3.3",
37
- "@zeropress/theme-validator": "^0.2.4"
36
+ "@zeropress/preview-data-validator": "^0.4.0",
37
+ "@zeropress/theme-validator": "^0.2.4",
38
+ "highlight.js": "^11.11.1",
39
+ "markdown-it": "^14.1.0",
40
+ "markdown-it-anchor": "^9.2.0"
38
41
  }
39
42
  }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { assertPreviewData } from '@zeropress/preview-data-validator';
3
3
  import { validateThemeFiles } from '@zeropress/theme-validator';
4
4
  import { AssetProcessor } from '../assets/asset-processor.js';
5
+ import { renderDocumentContent } from '../render/content-renderer.js';
5
6
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
6
7
 
7
8
  const DEFAULT_OPTIONS = {
@@ -28,7 +29,7 @@ export async function buildSite(input) {
28
29
  await renderPost(state, post);
29
30
  }
30
31
 
31
- for (const page of state.previewData.content.pages) {
32
+ for (const page of state.renderData.pages) {
32
33
  await renderPage(state, page);
33
34
  }
34
35
 
@@ -170,7 +171,7 @@ async function finalizeBuildResult(writer, summaries, options) {
170
171
  let manifest;
171
172
  if (options.writeManifest) {
172
173
  manifest = {
173
- generatedAt: new Date().toISOString(),
174
+ generatedAt: formatUtcIsoSeconds(new Date()),
174
175
  files: summaries.map((file) => ({ ...file })),
175
176
  };
176
177
  await writeOutput(
@@ -324,10 +325,15 @@ function normalizePreviewData(previewData) {
324
325
  site: normalizedSite,
325
326
  content: {
326
327
  ...previewData.content,
328
+ authors: previewData.content.authors.map((author) => ({
329
+ ...author,
330
+ avatar: normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl),
331
+ })),
327
332
  posts: previewData.content.posts
328
333
  .map((post) => ({
329
334
  ...post,
330
- author_avatar: normalizeMediaField(post.author_avatar, normalizedSite.mediaBaseUrl),
335
+ published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
336
+ updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
331
337
  featured_image: normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl),
332
338
  }))
333
339
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
@@ -342,6 +348,7 @@ function normalizePreviewData(previewData) {
342
348
  }
343
349
 
344
350
  function createRenderData(previewData) {
351
+ const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
345
352
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
346
353
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
347
354
  const categoryPostsBySlug = new Map();
@@ -361,13 +368,15 @@ function createRenderData(previewData) {
361
368
  }
362
369
  }
363
370
 
364
- const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, categoriesBySlug, tagsBySlug));
371
+ const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
372
+ const pages = previewData.content.pages.map((page) => preparePage(page));
365
373
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
366
374
  const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
367
375
  const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
368
376
 
369
377
  return {
370
378
  posts,
379
+ pages,
371
380
  postBySlug,
372
381
  indexRoutes: buildPaginatedCollection({
373
382
  items: previewData.content.posts,
@@ -412,12 +421,30 @@ function createRenderData(previewData) {
412
421
  };
413
422
  }
414
423
 
415
- function preparePost(post, site, categoriesBySlug, tagsBySlug) {
424
+ function preparePage(page) {
425
+ const documentType = normalizeDocumentType(page.document_type);
426
+
427
+ return {
428
+ ...page,
429
+ document_type: documentType,
430
+ html: renderDocumentContent(page.content, documentType),
431
+ };
432
+ }
433
+
434
+ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
435
+ const documentType = normalizeDocumentType(post.document_type);
436
+ const html = renderDocumentContent(post.content, documentType);
437
+ const author = authorsById.get(post.author_id);
438
+
416
439
  return {
417
440
  ...post,
441
+ document_type: documentType,
442
+ html,
443
+ author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
444
+ author_avatar: author?.avatar,
418
445
  published_at: formatTimestamp(post.published_at_iso, site),
419
446
  updated_at: formatTimestamp(post.updated_at_iso, site),
420
- reading_time: calculateReadingTime(post.html),
447
+ reading_time: calculateReadingTime(html),
421
448
  categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
422
449
  tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
423
450
  comments_html: renderCommentsContainer(site, post),
@@ -774,6 +801,10 @@ function normalizeLocale(value) {
774
801
  }).join('-');
775
802
  }
776
803
 
804
+ function normalizeDocumentType(value) {
805
+ return value === 'plaintext' || value === 'html' ? value : 'markdown';
806
+ }
807
+
777
808
  function hasTemplate(state, templateName) {
778
809
  return state.engine.themePackage?.templates?.has(templateName) === true;
779
810
  }
@@ -1066,7 +1097,7 @@ function buildSitemapXml(site, emitted, generatedAt) {
1066
1097
  const body = entries.map((entry) => {
1067
1098
  const loc = escapeXml(resolveSiteUrl(site.url, entry.url));
1068
1099
  const lastmod = entry.lastmod
1069
- ? `\n <lastmod>${entry.lastmod.toISOString()}</lastmod>`
1100
+ ? `\n <lastmod>${formatUtcIsoSeconds(entry.lastmod)}</lastmod>`
1070
1101
  : '';
1071
1102
  return ` <url>\n <loc>${loc}</loc>${lastmod}\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
1072
1103
  }).join('\n');
@@ -1118,7 +1149,7 @@ function buildMetaJson(site, emitted, generatedAt) {
1118
1149
  ];
1119
1150
 
1120
1151
  return JSON.stringify({
1121
- generated: generatedAt.toISOString(),
1152
+ generated: formatUtcIsoSeconds(generatedAt),
1122
1153
  site: {
1123
1154
  title: site.title,
1124
1155
  description: site.description,
@@ -1199,6 +1230,14 @@ function toDate(value) {
1199
1230
  return value ? new Date(value) : new Date();
1200
1231
  }
1201
1232
 
1233
+ function formatUtcIsoSeconds(value) {
1234
+ return toDate(value).toISOString().replace(/\.\d{3}Z$/, 'Z');
1235
+ }
1236
+
1237
+ function normalizeIsoTimestamp(value) {
1238
+ return value ? formatUtcIsoSeconds(value) : '';
1239
+ }
1240
+
1202
1241
  function escapeXml(str) {
1203
1242
  return String(str || '')
1204
1243
  .replace(/&/g, '&amp;')
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PreviewDataV04 } from '@zeropress/preview-data-validator';
1
+ import type { PreviewDataV05 } 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: PreviewDataV04;
65
+ previewData: PreviewDataV05;
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: PreviewDataV04;
72
+ previewData: PreviewDataV05;
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: PreviewDataV04;
80
+ previewData: PreviewDataV05;
81
81
  themeDir: string;
82
82
  writer: BuildWriter;
83
83
  options?: BuildOptions;
@@ -0,0 +1,156 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import hljs from 'highlight.js';
3
+ import anchor from 'markdown-it-anchor';
4
+
5
+ const markdown = new MarkdownIt({
6
+ html: true,
7
+ linkify: true,
8
+ typographer: true,
9
+ breaks: true,
10
+ highlight(value, language) {
11
+ if (language && hljs.getLanguage(language)) {
12
+ try {
13
+ return hljs.highlight(value, { language }).value;
14
+ } catch {
15
+ return value;
16
+ }
17
+ }
18
+
19
+ try {
20
+ return hljs.highlightAuto(value).value;
21
+ } catch {
22
+ return value;
23
+ }
24
+ },
25
+ });
26
+
27
+ markdown.use(anchor, {
28
+ permalink: anchor.permalink.headerLink(),
29
+ slugify: slugify,
30
+ });
31
+
32
+ export function renderDocumentContent(content, documentType = 'markdown') {
33
+ const normalizedContent = typeof content === 'string' ? content : '';
34
+ const normalizedType = normalizeDocumentType(documentType);
35
+
36
+ if (normalizedType === 'plaintext') {
37
+ return transformPlaintext(normalizedContent);
38
+ }
39
+
40
+ if (normalizedType === 'html') {
41
+ return sanitizeHtml(normalizedContent);
42
+ }
43
+
44
+ return sanitizeHtml(markdown.render(normalizedContent));
45
+ }
46
+
47
+ function normalizeDocumentType(value) {
48
+ return value === 'plaintext' || value === 'html' ? value : 'markdown';
49
+ }
50
+
51
+ function transformPlaintext(content) {
52
+ const escaped = content
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;')
57
+ .replace(/'/g, '&#39;');
58
+
59
+ const paragraphs = escaped
60
+ .split(/\n\s*\n/)
61
+ .map((entry) => entry.trim())
62
+ .filter(Boolean);
63
+
64
+ if (paragraphs.length === 0) {
65
+ return '';
66
+ }
67
+
68
+ return paragraphs.map((entry) => `<p>${entry}</p>`).join('\n');
69
+ }
70
+
71
+ function sanitizeHtml(html) {
72
+ const allowedTags = new Set([
73
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
74
+ 'p', 'br', 'hr',
75
+ 'strong', 'em', 'u', 's', 'code', 'pre',
76
+ 'a', 'img',
77
+ 'ul', 'ol', 'li',
78
+ 'blockquote',
79
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
80
+ 'div', 'span', 'nav',
81
+ 'iframe',
82
+ ]);
83
+
84
+ const allowedAttributes = {
85
+ a: new Set(['href', 'title', 'class', 'id']),
86
+ img: new Set(['src', 'alt', 'title', 'class', 'id', 'width', 'height']),
87
+ iframe: new Set(['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class']),
88
+ '*': new Set(['class', 'id']),
89
+ };
90
+
91
+ const safeUriPattern = /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
92
+
93
+ const stripped = html
94
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
95
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
96
+
97
+ const sanitized = stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)?\/?>/g, (match, tag, attributeString) => {
98
+ const normalizedTag = tag.toLowerCase();
99
+
100
+ if (match.startsWith('</')) {
101
+ return allowedTags.has(normalizedTag) ? `</${normalizedTag}>` : '';
102
+ }
103
+
104
+ if (!allowedTags.has(normalizedTag)) {
105
+ return '';
106
+ }
107
+
108
+ const tagAllowedAttributes = allowedAttributes[normalizedTag] || new Set();
109
+ const globalAllowedAttributes = allowedAttributes['*'];
110
+ const filteredAttributes = [];
111
+
112
+ if (attributeString) {
113
+ const attributePattern = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
114
+ let attributeMatch;
115
+
116
+ while ((attributeMatch = attributePattern.exec(attributeString)) !== null) {
117
+ const attributeName = attributeMatch[1].toLowerCase();
118
+ const attributeValue = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? '';
119
+
120
+ if (!tagAllowedAttributes.has(attributeName) && !globalAllowedAttributes.has(attributeName)) {
121
+ continue;
122
+ }
123
+
124
+ if ((attributeName === 'href' || attributeName === 'src') && !safeUriPattern.test(attributeValue)) {
125
+ continue;
126
+ }
127
+
128
+ filteredAttributes.push(`${attributeName}="${attributeValue}"`);
129
+ }
130
+ }
131
+
132
+ const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img';
133
+ const attributeSuffix = filteredAttributes.length > 0 ? ` ${filteredAttributes.join(' ')}` : '';
134
+ return isSelfClosing ? `<${normalizedTag}${attributeSuffix} />` : `<${normalizedTag}${attributeSuffix}>`;
135
+ });
136
+
137
+ return sanitized
138
+ .split(/(<[^>]+>)/g)
139
+ .map((part) => {
140
+ if (part.startsWith('<') && part.endsWith('>')) {
141
+ return part;
142
+ }
143
+
144
+ return part.replace(/&(?!(?:[a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;');
145
+ })
146
+ .join('');
147
+ }
148
+
149
+ function slugify(value) {
150
+ return String(value || '')
151
+ .toLowerCase()
152
+ .trim()
153
+ .replace(/[^\w\s-]/g, '')
154
+ .replace(/[\s_-]+/g, '-')
155
+ .replace(/^-+|-+$/g, '');
156
+ }