@zeropress/build-core 0.1.9 → 0.1.11
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 +1 -43
- package/package.json +7 -4
- package/src/api/build-site.js +38 -78
- package/src/index.d.ts +8 -21
- package/src/index.js +0 -1
- package/src/render/content-renderer.js +156 -0
- package/src/render/variable-resolver.js +54 -9
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|

|
|
5
5
|

|
|
6
6
|
|
|
7
|
-
Shared build
|
|
7
|
+
Shared build core for ZeroPress.
|
|
8
8
|
|
|
9
9
|
This package is the deterministic rendering engine used by:
|
|
10
10
|
|
|
@@ -33,7 +33,6 @@ npm install @zeropress/build-core
|
|
|
33
33
|
```js
|
|
34
34
|
import {
|
|
35
35
|
buildSite,
|
|
36
|
-
buildSelectedRoutes,
|
|
37
36
|
buildSiteFromThemeDir,
|
|
38
37
|
MemoryWriter,
|
|
39
38
|
FilesystemWriter,
|
|
@@ -123,47 +122,6 @@ Notes:
|
|
|
123
122
|
- `sitemap.xml` and `feed.xml` are emitted only when `site.url` is a non-empty canonical URL
|
|
124
123
|
- `robots.txt` and `meta.json` are still emitted when `generateSpecialFiles` is enabled
|
|
125
124
|
|
|
126
|
-
### `buildSelectedRoutes(input)`
|
|
127
|
-
|
|
128
|
-
Renders only selected post and list routes while preserving full-build parity for those outputs.
|
|
129
|
-
|
|
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.
|
|
141
|
-
|
|
142
|
-
Optional route templates are respected during both full and partial renders:
|
|
143
|
-
|
|
144
|
-
- missing `archive.html` skips archive outputs
|
|
145
|
-
- missing `category.html` skips category outputs
|
|
146
|
-
- missing `tag.html` skips tag outputs
|
|
147
|
-
- missing `404.html` skips `404.html` emission
|
|
148
|
-
|
|
149
|
-
These cases do not fail the build.
|
|
150
|
-
|
|
151
|
-
```js
|
|
152
|
-
await buildSelectedRoutes({
|
|
153
|
-
previewData,
|
|
154
|
-
themePackage,
|
|
155
|
-
writer,
|
|
156
|
-
selection: {
|
|
157
|
-
posts: ['hello-zeropress'],
|
|
158
|
-
indexRoutes: ['/'],
|
|
159
|
-
archiveRoutes: ['/archive/'],
|
|
160
|
-
categoryRoutes: ['/categories/general/'],
|
|
161
|
-
tagRoutes: ['/tags/intro/'],
|
|
162
|
-
includeAssets: false,
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
```
|
|
166
|
-
|
|
167
125
|
### `buildSiteFromThemeDir(input)`
|
|
168
126
|
|
|
169
127
|
Loads a theme directory from disk and renders it using the same core pipeline.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Shared ZeroPress build
|
|
3
|
+
"version": "0.1.11",
|
|
4
|
+
"description": "Shared ZeroPress build core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"node": ">=18.18.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zeropress/preview-data-validator": "^0.
|
|
37
|
-
"@zeropress/theme-validator": "^0.
|
|
36
|
+
"@zeropress/preview-data-validator": "^0.4.1",
|
|
37
|
+
"@zeropress/theme-validator": "^0.3.0",
|
|
38
|
+
"highlight.js": "^11.11.1",
|
|
39
|
+
"markdown-it": "^14.1.0",
|
|
40
|
+
"markdown-it-anchor": "^9.2.0"
|
|
38
41
|
}
|
|
39
42
|
}
|
package/src/api/build-site.js
CHANGED
|
@@ -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.
|
|
32
|
+
for (const page of state.renderData.pages) {
|
|
32
33
|
await renderPage(state, page);
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -67,62 +68,6 @@ export async function buildSite(input) {
|
|
|
67
68
|
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
export async function buildSelectedRoutes(input) {
|
|
71
|
-
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
72
|
-
validateSelection(input.selection);
|
|
73
|
-
|
|
74
|
-
const state = await createBuildState(input, options);
|
|
75
|
-
const selectedPostSlugs = new Set((input.selection.posts || []).map((slug) => String(slug)));
|
|
76
|
-
const selectedIndexRoutes = new Set((input.selection.indexRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
77
|
-
const selectedArchiveRoutes = new Set((input.selection.archiveRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
78
|
-
const selectedCategoryRoutes = new Set((input.selection.categoryRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
79
|
-
const selectedTagRoutes = new Set((input.selection.tagRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
80
|
-
|
|
81
|
-
for (const post of state.renderData.posts) {
|
|
82
|
-
if (selectedPostSlugs.has(post.slug)) {
|
|
83
|
-
await renderPost(state, post);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
for (const route of state.renderData.indexRoutes) {
|
|
88
|
-
if (selectedIndexRoutes.has(normalizeRoutePath(route.path))) {
|
|
89
|
-
await renderRoute(state, 'index', route);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (hasTemplate(state, 'archive')) {
|
|
94
|
-
for (const route of state.renderData.archiveRoutes) {
|
|
95
|
-
if (selectedArchiveRoutes.has(normalizeRoutePath(route.path))) {
|
|
96
|
-
await renderRoute(state, 'archive', route);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (hasTemplate(state, 'category')) {
|
|
102
|
-
for (const route of state.renderData.categoryRoutes) {
|
|
103
|
-
if (selectedCategoryRoutes.has(normalizeRoutePath(route.path))) {
|
|
104
|
-
await renderRoute(state, 'category', route);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (hasTemplate(state, 'tag')) {
|
|
110
|
-
for (const route of state.renderData.tagRoutes) {
|
|
111
|
-
if (selectedTagRoutes.has(normalizeRoutePath(route.path))) {
|
|
112
|
-
await renderRoute(state, 'tag', route);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (input.selection.includeAssets) {
|
|
118
|
-
for (const assetOutput of state.assetOutputs) {
|
|
119
|
-
await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
71
|
async function createBuildState(input, options) {
|
|
127
72
|
if (!input?.writer || typeof input.writer.write !== 'function') {
|
|
128
73
|
throw new Error('buildSite requires a writer with an async write(file) method');
|
|
@@ -193,6 +138,7 @@ async function renderRoute(state, templateName, route) {
|
|
|
193
138
|
let html = await state.engine.render(
|
|
194
139
|
templateName,
|
|
195
140
|
{
|
|
141
|
+
menus: state.previewData.menus,
|
|
196
142
|
...route,
|
|
197
143
|
meta: buildPageMeta(state.previewData.site, {
|
|
198
144
|
currentUrl,
|
|
@@ -213,6 +159,7 @@ async function renderPost(state, post) {
|
|
|
213
159
|
let html = await state.engine.render(
|
|
214
160
|
'post',
|
|
215
161
|
{
|
|
162
|
+
menus: state.previewData.menus,
|
|
216
163
|
post,
|
|
217
164
|
meta: buildPageMeta(state.previewData.site, {
|
|
218
165
|
currentUrl,
|
|
@@ -246,6 +193,7 @@ async function renderPage(state, page) {
|
|
|
246
193
|
let html = await state.engine.render(
|
|
247
194
|
'page',
|
|
248
195
|
{
|
|
196
|
+
menus: state.previewData.menus,
|
|
249
197
|
page,
|
|
250
198
|
meta: buildPageMeta(state.previewData.site, {
|
|
251
199
|
currentUrl,
|
|
@@ -275,6 +223,7 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
275
223
|
let html = await state.engine.render(
|
|
276
224
|
'404',
|
|
277
225
|
{
|
|
226
|
+
menus: state.previewData.menus,
|
|
278
227
|
meta: buildPageMeta(state.previewData.site, {
|
|
279
228
|
currentUrl: '/404.html',
|
|
280
229
|
title: state.previewData.site.title,
|
|
@@ -288,23 +237,6 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
288
237
|
await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
|
|
289
238
|
}
|
|
290
239
|
|
|
291
|
-
function validateSelection(selection) {
|
|
292
|
-
if (!selection || typeof selection !== 'object') {
|
|
293
|
-
throw new Error('buildSelectedRoutes requires a selection object');
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const keys = ['posts', 'indexRoutes', 'archiveRoutes', 'categoryRoutes', 'tagRoutes'];
|
|
297
|
-
for (const key of keys) {
|
|
298
|
-
if (!Array.isArray(selection[key])) {
|
|
299
|
-
throw new Error(`buildSelectedRoutes selection.${key} must be an array`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (typeof selection.includeAssets !== 'boolean') {
|
|
304
|
-
throw new Error('buildSelectedRoutes selection.includeAssets must be a boolean');
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
240
|
function normalizePreviewData(previewData) {
|
|
309
241
|
const normalizedSite = {
|
|
310
242
|
...previewData.site,
|
|
@@ -324,12 +256,15 @@ function normalizePreviewData(previewData) {
|
|
|
324
256
|
site: normalizedSite,
|
|
325
257
|
content: {
|
|
326
258
|
...previewData.content,
|
|
259
|
+
authors: previewData.content.authors.map((author) => ({
|
|
260
|
+
...author,
|
|
261
|
+
avatar: normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl),
|
|
262
|
+
})),
|
|
327
263
|
posts: previewData.content.posts
|
|
328
264
|
.map((post) => ({
|
|
329
265
|
...post,
|
|
330
266
|
published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
|
|
331
267
|
updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
|
|
332
|
-
author_avatar: normalizeMediaField(post.author_avatar, normalizedSite.mediaBaseUrl),
|
|
333
268
|
featured_image: normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl),
|
|
334
269
|
}))
|
|
335
270
|
.sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
|
|
@@ -344,6 +279,7 @@ function normalizePreviewData(previewData) {
|
|
|
344
279
|
}
|
|
345
280
|
|
|
346
281
|
function createRenderData(previewData) {
|
|
282
|
+
const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
|
|
347
283
|
const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
|
|
348
284
|
const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
|
|
349
285
|
const categoryPostsBySlug = new Map();
|
|
@@ -363,13 +299,15 @@ function createRenderData(previewData) {
|
|
|
363
299
|
}
|
|
364
300
|
}
|
|
365
301
|
|
|
366
|
-
const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, categoriesBySlug, tagsBySlug));
|
|
302
|
+
const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
|
|
303
|
+
const pages = previewData.content.pages.map((page) => preparePage(page));
|
|
367
304
|
const postBySlug = new Map(posts.map((post) => [post.slug, post]));
|
|
368
305
|
const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
|
|
369
306
|
const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
|
|
370
307
|
|
|
371
308
|
return {
|
|
372
309
|
posts,
|
|
310
|
+
pages,
|
|
373
311
|
postBySlug,
|
|
374
312
|
indexRoutes: buildPaginatedCollection({
|
|
375
313
|
items: previewData.content.posts,
|
|
@@ -414,12 +352,30 @@ function createRenderData(previewData) {
|
|
|
414
352
|
};
|
|
415
353
|
}
|
|
416
354
|
|
|
417
|
-
function
|
|
355
|
+
function preparePage(page) {
|
|
356
|
+
const documentType = normalizeDocumentType(page.document_type);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
...page,
|
|
360
|
+
document_type: documentType,
|
|
361
|
+
html: renderDocumentContent(page.content, documentType),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
|
|
366
|
+
const documentType = normalizeDocumentType(post.document_type);
|
|
367
|
+
const html = renderDocumentContent(post.content, documentType);
|
|
368
|
+
const author = authorsById.get(post.author_id);
|
|
369
|
+
|
|
418
370
|
return {
|
|
419
371
|
...post,
|
|
372
|
+
document_type: documentType,
|
|
373
|
+
html,
|
|
374
|
+
author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
|
|
375
|
+
author_avatar: author?.avatar,
|
|
420
376
|
published_at: formatTimestamp(post.published_at_iso, site),
|
|
421
377
|
updated_at: formatTimestamp(post.updated_at_iso, site),
|
|
422
|
-
reading_time: calculateReadingTime(
|
|
378
|
+
reading_time: calculateReadingTime(html),
|
|
423
379
|
categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
|
|
424
380
|
tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
|
|
425
381
|
comments_html: renderCommentsContainer(site, post),
|
|
@@ -776,6 +732,10 @@ function normalizeLocale(value) {
|
|
|
776
732
|
}).join('-');
|
|
777
733
|
}
|
|
778
734
|
|
|
735
|
+
function normalizeDocumentType(value) {
|
|
736
|
+
return value === 'plaintext' || value === 'html' ? value : 'markdown';
|
|
737
|
+
}
|
|
738
|
+
|
|
779
739
|
function hasTemplate(state, templateName) {
|
|
780
740
|
return state.engine.themePackage?.templates?.has(templateName) === true;
|
|
781
741
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PreviewDataV05 } from '@zeropress/preview-data-validator';
|
|
2
2
|
|
|
3
3
|
export interface ThemePackage {
|
|
4
4
|
metadata: {
|
|
@@ -8,10 +8,14 @@ export interface ThemePackage {
|
|
|
8
8
|
description?: string;
|
|
9
9
|
thumbnail?: string;
|
|
10
10
|
settings?: Record<string, unknown>;
|
|
11
|
+
menuSlots?: Record<string, {
|
|
12
|
+
title: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}>;
|
|
11
15
|
namespace?: string;
|
|
12
16
|
slug?: string;
|
|
13
17
|
license?: 'MIT' | 'Apache-2.0' | 'BSD-3-Clause' | 'GPL-3.0-only' | 'GPL-3.0-or-later';
|
|
14
|
-
runtime?: '0.
|
|
18
|
+
runtime?: '0.3';
|
|
15
19
|
};
|
|
16
20
|
templates: Map<string, string>;
|
|
17
21
|
partials: Map<string, string>;
|
|
@@ -35,15 +39,6 @@ export interface BuildOptions {
|
|
|
35
39
|
writeManifest?: boolean;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
export interface BuildSelection {
|
|
39
|
-
posts: string[];
|
|
40
|
-
indexRoutes: string[];
|
|
41
|
-
archiveRoutes: string[];
|
|
42
|
-
categoryRoutes: string[];
|
|
43
|
-
tagRoutes: string[];
|
|
44
|
-
includeAssets: boolean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
42
|
export interface BuildSummaryFile {
|
|
48
43
|
path: string;
|
|
49
44
|
contentType: string;
|
|
@@ -62,22 +57,14 @@ export interface BuildSiteResult {
|
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
export function buildSite(input: {
|
|
65
|
-
previewData:
|
|
66
|
-
themePackage: ThemePackage;
|
|
67
|
-
writer: BuildWriter;
|
|
68
|
-
options?: BuildOptions;
|
|
69
|
-
}): Promise<BuildSiteResult>;
|
|
70
|
-
|
|
71
|
-
export function buildSelectedRoutes(input: {
|
|
72
|
-
previewData: PreviewDataV04;
|
|
60
|
+
previewData: PreviewDataV05;
|
|
73
61
|
themePackage: ThemePackage;
|
|
74
62
|
writer: BuildWriter;
|
|
75
|
-
selection: BuildSelection;
|
|
76
63
|
options?: BuildOptions;
|
|
77
64
|
}): Promise<BuildSiteResult>;
|
|
78
65
|
|
|
79
66
|
export function buildSiteFromThemeDir(input: {
|
|
80
|
-
previewData:
|
|
67
|
+
previewData: PreviewDataV05;
|
|
81
68
|
themeDir: string;
|
|
82
69
|
writer: BuildWriter;
|
|
83
70
|
options?: BuildOptions;
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export { buildSite } from './api/build-site.js';
|
|
2
|
-
export { buildSelectedRoutes } from './api/build-site.js';
|
|
3
2
|
export { buildSiteFromThemeDir } from './api/build-site-from-theme-dir.js';
|
|
4
3
|
export { MemoryWriter } from './output/memory-writer.js';
|
|
5
4
|
export { FilesystemWriter } from './output/filesystem-writer.js';
|
|
@@ -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, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"')
|
|
57
|
+
.replace(/'/g, ''');
|
|
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, '&');
|
|
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
|
+
}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
export class VariableResolver {
|
|
2
|
-
static
|
|
2
|
+
static MENU_PATTERN = /\{\{menu:([a-z][a-z0-9_-]{0,63})\}\}/g;
|
|
3
|
+
static VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+)\}\}/g;
|
|
3
4
|
static STANDALONE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
4
5
|
|
|
5
6
|
resolve(template, data) {
|
|
6
|
-
let result = template.replace(VariableResolver.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!prefixData || typeof prefixData !== 'object') {
|
|
11
|
-
return '';
|
|
12
|
-
}
|
|
7
|
+
let result = template.replace(VariableResolver.MENU_PATTERN, (_, menuId) => {
|
|
8
|
+
return this.renderMenu(data?.menus?.[menuId]);
|
|
9
|
+
});
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
result = result.replace(VariableResolver.VARIABLE_PATTERN, (_, variablePath) => {
|
|
12
|
+
const value = this.resolvePath(data, variablePath);
|
|
15
13
|
return value == null ? '' : String(value);
|
|
16
14
|
});
|
|
17
15
|
|
|
@@ -26,4 +24,51 @@ export class VariableResolver {
|
|
|
26
24
|
|
|
27
25
|
return result;
|
|
28
26
|
}
|
|
27
|
+
|
|
28
|
+
resolvePath(data, variablePath) {
|
|
29
|
+
const segments = variablePath.split('.');
|
|
30
|
+
let current = data;
|
|
31
|
+
|
|
32
|
+
for (const segment of segments) {
|
|
33
|
+
if (current == null || typeof current !== 'object') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
current = current[segment];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return current;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
renderMenu(menu) {
|
|
44
|
+
if (!menu || !Array.isArray(menu.items) || menu.items.length === 0) {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `<ul>${menu.items.map((item) => this.renderMenuItem(item)).join('')}</ul>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
renderMenuItem(item) {
|
|
52
|
+
if (!item || typeof item !== 'object') {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const title = this.escapeHtml(String(item.title || ''));
|
|
57
|
+
const url = this.escapeHtml(String(item.url || ''));
|
|
58
|
+
const target = item.target === '_blank' ? '_blank' : '_self';
|
|
59
|
+
const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
|
|
60
|
+
const children = Array.isArray(item.children) && item.children.length > 0
|
|
61
|
+
? `<ul>${item.children.map((child) => this.renderMenuItem(child)).join('')}</ul>`
|
|
62
|
+
: '';
|
|
63
|
+
|
|
64
|
+
return `<li><a href="${url}" target="${target}"${rel}>${title}</a>${children}</li>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
escapeHtml(value) {
|
|
68
|
+
return value
|
|
69
|
+
.replaceAll('&', '&')
|
|
70
|
+
.replaceAll('<', '<')
|
|
71
|
+
.replaceAll('>', '>')
|
|
72
|
+
.replaceAll('"', '"');
|
|
73
|
+
}
|
|
29
74
|
}
|