@zeropress/build-core 0.1.10 → 0.1.12
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 +5 -4
- package/src/api/build-site.js +99 -91
- package/src/index.d.ts +5 -18
- package/src/index.js +0 -1
- 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.12",
|
|
4
|
+
"description": "Shared ZeroPress build core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"node": ">=18.18.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zeropress/
|
|
37
|
-
"@zeropress/
|
|
36
|
+
"@zeropress/slug-policy": "^0.1.0",
|
|
37
|
+
"@zeropress/preview-data-validator": "^0.4.1",
|
|
38
|
+
"@zeropress/theme-validator": "^0.3.0",
|
|
38
39
|
"highlight.js": "^11.11.1",
|
|
39
40
|
"markdown-it": "^14.1.0",
|
|
40
41
|
"markdown-it-anchor": "^9.2.0"
|
package/src/api/build-site.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { assertPreviewData } from '@zeropress/preview-data-validator';
|
|
3
|
+
import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
|
|
3
4
|
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
4
5
|
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
5
6
|
import { renderDocumentContent } from '../render/content-renderer.js';
|
|
@@ -17,9 +18,11 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
|
|
17
18
|
const DEFAULT_TIME_FORMAT = 'HH:mm';
|
|
18
19
|
const DEFAULT_TIMEZONE = 'UTC';
|
|
19
20
|
const DEFAULT_LOCALE = 'en-US';
|
|
21
|
+
const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
|
|
20
22
|
export async function buildSite(input) {
|
|
21
23
|
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
22
24
|
const state = await createBuildState(input, options);
|
|
25
|
+
assertPlannedOutputPathsSafe(state);
|
|
23
26
|
|
|
24
27
|
for (const route of state.renderData.indexRoutes) {
|
|
25
28
|
await renderRoute(state, 'index', route);
|
|
@@ -68,62 +71,6 @@ export async function buildSite(input) {
|
|
|
68
71
|
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
export async function buildSelectedRoutes(input) {
|
|
72
|
-
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
73
|
-
validateSelection(input.selection);
|
|
74
|
-
|
|
75
|
-
const state = await createBuildState(input, options);
|
|
76
|
-
const selectedPostSlugs = new Set((input.selection.posts || []).map((slug) => String(slug)));
|
|
77
|
-
const selectedIndexRoutes = new Set((input.selection.indexRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
78
|
-
const selectedArchiveRoutes = new Set((input.selection.archiveRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
79
|
-
const selectedCategoryRoutes = new Set((input.selection.categoryRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
80
|
-
const selectedTagRoutes = new Set((input.selection.tagRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
81
|
-
|
|
82
|
-
for (const post of state.renderData.posts) {
|
|
83
|
-
if (selectedPostSlugs.has(post.slug)) {
|
|
84
|
-
await renderPost(state, post);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
for (const route of state.renderData.indexRoutes) {
|
|
89
|
-
if (selectedIndexRoutes.has(normalizeRoutePath(route.path))) {
|
|
90
|
-
await renderRoute(state, 'index', route);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (hasTemplate(state, 'archive')) {
|
|
95
|
-
for (const route of state.renderData.archiveRoutes) {
|
|
96
|
-
if (selectedArchiveRoutes.has(normalizeRoutePath(route.path))) {
|
|
97
|
-
await renderRoute(state, 'archive', route);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (hasTemplate(state, 'category')) {
|
|
103
|
-
for (const route of state.renderData.categoryRoutes) {
|
|
104
|
-
if (selectedCategoryRoutes.has(normalizeRoutePath(route.path))) {
|
|
105
|
-
await renderRoute(state, 'category', route);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (hasTemplate(state, 'tag')) {
|
|
111
|
-
for (const route of state.renderData.tagRoutes) {
|
|
112
|
-
if (selectedTagRoutes.has(normalizeRoutePath(route.path))) {
|
|
113
|
-
await renderRoute(state, 'tag', route);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (input.selection.includeAssets) {
|
|
119
|
-
for (const assetOutput of state.assetOutputs) {
|
|
120
|
-
await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
74
|
async function createBuildState(input, options) {
|
|
128
75
|
if (!input?.writer || typeof input.writer.write !== 'function') {
|
|
129
76
|
throw new Error('buildSite requires a writer with an async write(file) method');
|
|
@@ -194,6 +141,7 @@ async function renderRoute(state, templateName, route) {
|
|
|
194
141
|
let html = await state.engine.render(
|
|
195
142
|
templateName,
|
|
196
143
|
{
|
|
144
|
+
menus: state.previewData.menus,
|
|
197
145
|
...route,
|
|
198
146
|
meta: buildPageMeta(state.previewData.site, {
|
|
199
147
|
currentUrl,
|
|
@@ -214,6 +162,7 @@ async function renderPost(state, post) {
|
|
|
214
162
|
let html = await state.engine.render(
|
|
215
163
|
'post',
|
|
216
164
|
{
|
|
165
|
+
menus: state.previewData.menus,
|
|
217
166
|
post,
|
|
218
167
|
meta: buildPageMeta(state.previewData.site, {
|
|
219
168
|
currentUrl,
|
|
@@ -247,6 +196,7 @@ async function renderPage(state, page) {
|
|
|
247
196
|
let html = await state.engine.render(
|
|
248
197
|
'page',
|
|
249
198
|
{
|
|
199
|
+
menus: state.previewData.menus,
|
|
250
200
|
page,
|
|
251
201
|
meta: buildPageMeta(state.previewData.site, {
|
|
252
202
|
currentUrl,
|
|
@@ -276,6 +226,7 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
276
226
|
let html = await state.engine.render(
|
|
277
227
|
'404',
|
|
278
228
|
{
|
|
229
|
+
menus: state.previewData.menus,
|
|
279
230
|
meta: buildPageMeta(state.previewData.site, {
|
|
280
231
|
currentUrl: '/404.html',
|
|
281
232
|
title: state.previewData.site.title,
|
|
@@ -289,23 +240,6 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
289
240
|
await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
|
|
290
241
|
}
|
|
291
242
|
|
|
292
|
-
function validateSelection(selection) {
|
|
293
|
-
if (!selection || typeof selection !== 'object') {
|
|
294
|
-
throw new Error('buildSelectedRoutes requires a selection object');
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const keys = ['posts', 'indexRoutes', 'archiveRoutes', 'categoryRoutes', 'tagRoutes'];
|
|
298
|
-
for (const key of keys) {
|
|
299
|
-
if (!Array.isArray(selection[key])) {
|
|
300
|
-
throw new Error(`buildSelectedRoutes selection.${key} must be an array`);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (typeof selection.includeAssets !== 'boolean') {
|
|
305
|
-
throw new Error('buildSelectedRoutes selection.includeAssets must be a boolean');
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
243
|
function normalizePreviewData(previewData) {
|
|
310
244
|
const normalizedSite = {
|
|
311
245
|
...previewData.site,
|
|
@@ -1028,7 +962,9 @@ function isAbsoluteUrl(value) {
|
|
|
1028
962
|
}
|
|
1029
963
|
|
|
1030
964
|
async function writeOutput(writer, summaries, path, content, contentType) {
|
|
1031
|
-
const
|
|
965
|
+
const rawPath = String(path || '');
|
|
966
|
+
const normalizedPath = normalizeOutputPath(rawPath);
|
|
967
|
+
assertSafeRelativeOutputPath(rawPath, normalizedPath);
|
|
1032
968
|
await writer.write({ path: normalizedPath, content, contentType });
|
|
1033
969
|
summaries.push({
|
|
1034
970
|
path: normalizedPath,
|
|
@@ -1058,6 +994,95 @@ function normalizeOutputPath(filePath) {
|
|
|
1058
994
|
return String(filePath || '').replace(/^\/+/, '');
|
|
1059
995
|
}
|
|
1060
996
|
|
|
997
|
+
function assertPlannedOutputPathsSafe(state) {
|
|
998
|
+
for (const post of state.renderData.posts) {
|
|
999
|
+
assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
for (const page of state.renderData.pages) {
|
|
1003
|
+
assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
for (const category of state.previewData.content.categories) {
|
|
1007
|
+
assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
for (const tag of state.previewData.content.tags) {
|
|
1011
|
+
assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const plannedPaths = [
|
|
1015
|
+
...state.renderData.indexRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1016
|
+
...state.renderData.archiveRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1017
|
+
...state.renderData.categoryRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1018
|
+
...state.renderData.tagRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1019
|
+
...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
|
|
1020
|
+
...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
|
|
1021
|
+
...state.assetOutputs.map((assetOutput) => assetOutput.path),
|
|
1022
|
+
];
|
|
1023
|
+
|
|
1024
|
+
if (state.options.generateSpecialFiles) {
|
|
1025
|
+
plannedPaths.push('404.html', 'robots.txt', 'meta.json');
|
|
1026
|
+
if (hasCanonicalSiteUrl(state.previewData.site.url)) {
|
|
1027
|
+
plannedPaths.push('sitemap.xml', 'feed.xml');
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
for (const plannedPath of plannedPaths) {
|
|
1032
|
+
const rawPath = String(plannedPath || '');
|
|
1033
|
+
const normalizedPath = normalizeOutputPath(rawPath);
|
|
1034
|
+
assertSafeRelativeOutputPath(rawPath, normalizedPath);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
|
|
1039
|
+
const originalSlug = typeof rawSlug === 'string' ? rawSlug : '';
|
|
1040
|
+
const decodedSlug = encodeSlugSegment(rawSlug);
|
|
1041
|
+
|
|
1042
|
+
if (
|
|
1043
|
+
!isSafeSlugPathSegment(originalSlug) ||
|
|
1044
|
+
!isSafeSlugPathSegment(decodedSlug)
|
|
1045
|
+
) {
|
|
1046
|
+
throw new Error(`Unsafe output path detected: ${outputPath}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function isSafeSlugPathSegment(value) {
|
|
1051
|
+
return isSafeSlugSegment(value);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputPath(rawPath)) {
|
|
1055
|
+
const originalPath = String(rawPath || '');
|
|
1056
|
+
const candidatePath = String(normalizedPath || '');
|
|
1057
|
+
const normalizedSeparators = candidatePath.replace(/\\/g, '/');
|
|
1058
|
+
|
|
1059
|
+
if (originalPath.trim() === '' || candidatePath.trim() === '') {
|
|
1060
|
+
throw new Error(`Unsafe output path detected: ${originalPath || candidatePath || '<empty>'}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(originalPath) || OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(candidatePath)) {
|
|
1064
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (candidatePath.includes('%')) {
|
|
1068
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (
|
|
1072
|
+
originalPath.startsWith('/') ||
|
|
1073
|
+
originalPath.startsWith('\\') ||
|
|
1074
|
+
/^[A-Za-z]:[\\/]/.test(originalPath) ||
|
|
1075
|
+
normalizedSeparators.startsWith('//')
|
|
1076
|
+
) {
|
|
1077
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const segments = normalizedSeparators.split('/');
|
|
1081
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
1082
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1061
1086
|
function getContentSize(content) {
|
|
1062
1087
|
return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
1063
1088
|
}
|
|
@@ -1189,23 +1214,6 @@ function encodeSlugSegment(slug) {
|
|
|
1189
1214
|
return normalizeStoredSlug(slug);
|
|
1190
1215
|
}
|
|
1191
1216
|
|
|
1192
|
-
function normalizeStoredSlug(slug) {
|
|
1193
|
-
if (typeof slug !== 'string') {
|
|
1194
|
-
return '';
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
const trimmed = slug.trim();
|
|
1198
|
-
if (!trimmed.includes('%')) {
|
|
1199
|
-
return trimmed;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
try {
|
|
1203
|
-
return decodeURIComponent(trimmed);
|
|
1204
|
-
} catch {
|
|
1205
|
-
return trimmed;
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
1217
|
function decodeRoutePath(routePath) {
|
|
1210
1218
|
try {
|
|
1211
1219
|
return decodeURI(routePath);
|
package/src/index.d.ts
CHANGED
|
@@ -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;
|
|
@@ -68,14 +63,6 @@ export function buildSite(input: {
|
|
|
68
63
|
options?: BuildOptions;
|
|
69
64
|
}): Promise<BuildSiteResult>;
|
|
70
65
|
|
|
71
|
-
export function buildSelectedRoutes(input: {
|
|
72
|
-
previewData: PreviewDataV05;
|
|
73
|
-
themePackage: ThemePackage;
|
|
74
|
-
writer: BuildWriter;
|
|
75
|
-
selection: BuildSelection;
|
|
76
|
-
options?: BuildOptions;
|
|
77
|
-
}): Promise<BuildSiteResult>;
|
|
78
|
-
|
|
79
66
|
export function buildSiteFromThemeDir(input: {
|
|
80
67
|
previewData: PreviewDataV05;
|
|
81
68
|
themeDir: string;
|
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';
|
|
@@ -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
|
}
|