@zeropress/build-core 0.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.9",
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
 
@@ -324,12 +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
335
  published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
331
336
  updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
332
- author_avatar: normalizeMediaField(post.author_avatar, normalizedSite.mediaBaseUrl),
333
337
  featured_image: normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl),
334
338
  }))
335
339
  .sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
@@ -344,6 +348,7 @@ function normalizePreviewData(previewData) {
344
348
  }
345
349
 
346
350
  function createRenderData(previewData) {
351
+ const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
347
352
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
348
353
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
349
354
  const categoryPostsBySlug = new Map();
@@ -363,13 +368,15 @@ function createRenderData(previewData) {
363
368
  }
364
369
  }
365
370
 
366
- 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));
367
373
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
368
374
  const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
369
375
  const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
370
376
 
371
377
  return {
372
378
  posts,
379
+ pages,
373
380
  postBySlug,
374
381
  indexRoutes: buildPaginatedCollection({
375
382
  items: previewData.content.posts,
@@ -414,12 +421,30 @@ function createRenderData(previewData) {
414
421
  };
415
422
  }
416
423
 
417
- 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
+
418
439
  return {
419
440
  ...post,
441
+ document_type: documentType,
442
+ html,
443
+ author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
444
+ author_avatar: author?.avatar,
420
445
  published_at: formatTimestamp(post.published_at_iso, site),
421
446
  updated_at: formatTimestamp(post.updated_at_iso, site),
422
- reading_time: calculateReadingTime(post.html),
447
+ reading_time: calculateReadingTime(html),
423
448
  categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
424
449
  tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
425
450
  comments_html: renderCommentsContainer(site, post),
@@ -776,6 +801,10 @@ function normalizeLocale(value) {
776
801
  }).join('-');
777
802
  }
778
803
 
804
+ function normalizeDocumentType(value) {
805
+ return value === 'plaintext' || value === 'html' ? value : 'markdown';
806
+ }
807
+
779
808
  function hasTemplate(state, templateName) {
780
809
  return state.engine.themePackage?.templates?.has(templateName) === true;
781
810
  }
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
+ }