@zeropress/build-core 0.5.2 → 0.6.1
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 +30 -40
- package/package.json +3 -3
- package/src/api/build-site.js +708 -135
- package/src/index.d.ts +4 -3
- package/src/render/content-renderer.js +20 -2
- package/src/render/control-flow-renderer.js +251 -39
- package/src/render/partial-resolver.js +42 -6
- package/src/render/variable-resolver.js +4 -0
- package/src/render/zeropress-engine.js +2 -2
- package/src/theme/load-theme-dir.js +56 -1
package/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PreviewDataV06 } from '@zeropress/preview-data-validator';
|
|
2
2
|
import type { ThemeManifest } from '@zeropress/theme-validator';
|
|
3
3
|
|
|
4
4
|
export interface ThemePackage {
|
|
@@ -28,6 +28,7 @@ export interface BuildOptions {
|
|
|
28
28
|
png?: string;
|
|
29
29
|
apple_touch_icon?: string;
|
|
30
30
|
};
|
|
31
|
+
sitemapStylesheetHref?: string;
|
|
31
32
|
generateSpecialFiles?: boolean;
|
|
32
33
|
generateRobotsTxt?: boolean;
|
|
33
34
|
writeManifest?: boolean;
|
|
@@ -51,14 +52,14 @@ export interface BuildSiteResult {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export function buildSite(input: {
|
|
54
|
-
previewData:
|
|
55
|
+
previewData: PreviewDataV06;
|
|
55
56
|
themePackage: ThemePackage;
|
|
56
57
|
writer: BuildWriter;
|
|
57
58
|
options?: BuildOptions;
|
|
58
59
|
}): Promise<BuildSiteResult>;
|
|
59
60
|
|
|
60
61
|
export function buildSiteFromThemeDir(input: {
|
|
61
|
-
previewData:
|
|
62
|
+
previewData: PreviewDataV06;
|
|
62
63
|
themeDir: string;
|
|
63
64
|
writer: BuildWriter;
|
|
64
65
|
options?: BuildOptions;
|
|
@@ -306,6 +306,7 @@ function sanitizeHtml(html) {
|
|
|
306
306
|
'a', 'img',
|
|
307
307
|
'ul', 'ol', 'li',
|
|
308
308
|
'blockquote', 'aside',
|
|
309
|
+
'figure', 'figcaption', 'picture', 'source',
|
|
309
310
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
310
311
|
'div', 'span', 'nav',
|
|
311
312
|
'iframe', 'input',
|
|
@@ -314,9 +315,10 @@ function sanitizeHtml(html) {
|
|
|
314
315
|
const allowedAttributes = {
|
|
315
316
|
a: new Set(['href', 'title', 'class', 'id']),
|
|
316
317
|
aside: new Set(['role', 'class', 'id']),
|
|
317
|
-
img: new Set(['src', 'alt', 'title', 'class', 'id', 'width', 'height']),
|
|
318
|
+
img: new Set(['src', 'srcset', 'sizes', 'alt', 'title', 'class', 'id', 'width', 'height', 'loading', 'decoding']),
|
|
318
319
|
iframe: new Set(['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class']),
|
|
319
320
|
input: new Set(['type', 'checked', 'disabled', 'class', 'id']),
|
|
321
|
+
source: new Set(['src', 'srcset', 'sizes', 'type', 'media', 'width', 'height', 'class', 'id']),
|
|
320
322
|
'*': new Set(['class', 'id']),
|
|
321
323
|
};
|
|
322
324
|
|
|
@@ -357,6 +359,10 @@ function sanitizeHtml(html) {
|
|
|
357
359
|
continue;
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
if (attributeName === 'srcset' && !isSafeSrcset(attributeValue, safeUriPattern)) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
360
366
|
if (normalizedTag === 'input' && attributeName === 'type' && attributeValue !== 'checkbox') {
|
|
361
367
|
continue;
|
|
362
368
|
}
|
|
@@ -365,7 +371,7 @@ function sanitizeHtml(html) {
|
|
|
365
371
|
}
|
|
366
372
|
}
|
|
367
373
|
|
|
368
|
-
const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img' || normalizedTag === 'input';
|
|
374
|
+
const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img' || normalizedTag === 'input' || normalizedTag === 'source';
|
|
369
375
|
const attributeSuffix = filteredAttributes.length > 0 ? ` ${filteredAttributes.join(' ')}` : '';
|
|
370
376
|
return isSelfClosing ? `<${normalizedTag}${attributeSuffix} />` : `<${normalizedTag}${attributeSuffix}>`;
|
|
371
377
|
});
|
|
@@ -382,6 +388,18 @@ function sanitizeHtml(html) {
|
|
|
382
388
|
.join('');
|
|
383
389
|
}
|
|
384
390
|
|
|
391
|
+
function isSafeSrcset(value, safeUriPattern) {
|
|
392
|
+
const candidates = String(value)
|
|
393
|
+
.split(',')
|
|
394
|
+
.map((candidate) => candidate.trim())
|
|
395
|
+
.filter(Boolean);
|
|
396
|
+
|
|
397
|
+
return candidates.length > 0 && candidates.every((candidate) => {
|
|
398
|
+
const [url] = candidate.split(/\s+/);
|
|
399
|
+
return Boolean(url) && safeUriPattern.test(url);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
385
403
|
function slugify(value) {
|
|
386
404
|
return String(value || '')
|
|
387
405
|
.toLowerCase()
|
|
@@ -3,7 +3,145 @@ import { parsePartialToken } from './partial-resolver.js';
|
|
|
3
3
|
const PATH_SEGMENT_SOURCE = '[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*';
|
|
4
4
|
const PATH_REGEX = new RegExp(`^${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*$`);
|
|
5
5
|
const FOR_EXPRESSION_REGEX = new RegExp(`^([a-zA-Z_][a-zA-Z0-9_]*)\\s+in\\s+(${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*)$`);
|
|
6
|
-
const
|
|
6
|
+
const NUMBER_LITERAL_REGEX = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
7
|
+
const COMPARISON_BLOCK_TAGS = new Set(['if_eq', 'if_neq', 'if_in', 'if_starts_with']);
|
|
8
|
+
const COMPARISON_ELSE_IF_TAGS = new Set(['else_if_eq', 'else_if_neq', 'else_if_in', 'else_if_starts_with']);
|
|
9
|
+
const COMPARISON_TAG_OPERATORS = {
|
|
10
|
+
if_eq: 'eq',
|
|
11
|
+
else_if_eq: 'eq',
|
|
12
|
+
if_neq: 'neq',
|
|
13
|
+
else_if_neq: 'neq',
|
|
14
|
+
if_in: 'in',
|
|
15
|
+
else_if_in: 'in',
|
|
16
|
+
if_starts_with: 'starts_with',
|
|
17
|
+
else_if_starts_with: 'starts_with',
|
|
18
|
+
};
|
|
19
|
+
const PARTIAL_ARG_ROOT_PATHS = new Set([
|
|
20
|
+
'archive',
|
|
21
|
+
'author',
|
|
22
|
+
'category',
|
|
23
|
+
'collection',
|
|
24
|
+
'collections',
|
|
25
|
+
'currentUrl',
|
|
26
|
+
'language',
|
|
27
|
+
'menu',
|
|
28
|
+
'menus',
|
|
29
|
+
'meta',
|
|
30
|
+
'page',
|
|
31
|
+
'pagination',
|
|
32
|
+
'partial',
|
|
33
|
+
'post',
|
|
34
|
+
'posts',
|
|
35
|
+
'route',
|
|
36
|
+
'site',
|
|
37
|
+
'tag',
|
|
38
|
+
'taxonomies',
|
|
39
|
+
'taxonomy',
|
|
40
|
+
'widget',
|
|
41
|
+
'widgets',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function getComparisonBlockTag(token) {
|
|
45
|
+
for (const tagName of COMPARISON_BLOCK_TAGS) {
|
|
46
|
+
if (token.startsWith(`#${tagName} `)) {
|
|
47
|
+
return tagName;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getComparisonElseIfTag(token) {
|
|
54
|
+
for (const tagName of COMPARISON_ELSE_IF_TAGS) {
|
|
55
|
+
if (token.startsWith(`#${tagName} `)) {
|
|
56
|
+
return tagName;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tokenizeExpression(expression) {
|
|
63
|
+
const tokens = [];
|
|
64
|
+
let index = 0;
|
|
65
|
+
|
|
66
|
+
while (index < expression.length) {
|
|
67
|
+
while (/\s/.test(expression[index] || '')) {
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
if (index >= expression.length) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (expression[index] === '"') {
|
|
75
|
+
const start = index;
|
|
76
|
+
index += 1;
|
|
77
|
+
let escaped = false;
|
|
78
|
+
while (index < expression.length) {
|
|
79
|
+
const char = expression[index];
|
|
80
|
+
if (escaped) {
|
|
81
|
+
escaped = false;
|
|
82
|
+
} else if (char === '\\') {
|
|
83
|
+
escaped = true;
|
|
84
|
+
} else if (char === '"') {
|
|
85
|
+
index += 1;
|
|
86
|
+
tokens.push(expression.slice(start, index));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
index += 1;
|
|
90
|
+
}
|
|
91
|
+
if (tokens[tokens.length - 1] !== expression.slice(start, index)) {
|
|
92
|
+
throw new Error(`Unclosed string literal in expression: ${expression}`);
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const start = index;
|
|
98
|
+
while (index < expression.length && !/\s/.test(expression[index])) {
|
|
99
|
+
index += 1;
|
|
100
|
+
}
|
|
101
|
+
tokens.push(expression.slice(start, index));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return tokens;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parsePathOperand(token, tagName, expression) {
|
|
108
|
+
if (!PATH_REGEX.test(token)) {
|
|
109
|
+
throw new Error(`Invalid ${tagName} expression: ${expression}`);
|
|
110
|
+
}
|
|
111
|
+
return { kind: 'path', path: token };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseComparisonOperand(token, tagName, expression) {
|
|
115
|
+
if (token.startsWith('"')) {
|
|
116
|
+
try {
|
|
117
|
+
const value = JSON.parse(token);
|
|
118
|
+
if (typeof value !== 'string') {
|
|
119
|
+
throw new Error('Expected string literal');
|
|
120
|
+
}
|
|
121
|
+
return { kind: 'literal', value };
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error(`Invalid ${tagName} expression: ${expression}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (token === 'true') {
|
|
128
|
+
return { kind: 'literal', value: true };
|
|
129
|
+
}
|
|
130
|
+
if (token === 'false') {
|
|
131
|
+
return { kind: 'literal', value: false };
|
|
132
|
+
}
|
|
133
|
+
if (token === 'null') {
|
|
134
|
+
return { kind: 'literal', value: null };
|
|
135
|
+
}
|
|
136
|
+
if (NUMBER_LITERAL_REGEX.test(token)) {
|
|
137
|
+
return { kind: 'literal', value: Number(token) };
|
|
138
|
+
}
|
|
139
|
+
if (PATH_REGEX.test(token)) {
|
|
140
|
+
return { kind: 'path', path: token };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new Error(`Invalid ${tagName} expression: ${expression}`);
|
|
144
|
+
}
|
|
7
145
|
|
|
8
146
|
export class ControlFlowRenderer {
|
|
9
147
|
constructor(options = {}) {
|
|
@@ -34,7 +172,7 @@ export class ControlFlowRenderer {
|
|
|
34
172
|
|
|
35
173
|
parse(template) {
|
|
36
174
|
const source = String(template || '');
|
|
37
|
-
const { nodes, nextIndex, stopTag } = this.parseNodes(source, 0, new Set());
|
|
175
|
+
const { nodes, nextIndex, stopTag } = this.parseNodes(source, 0, new Set(), PARTIAL_ARG_ROOT_PATHS);
|
|
38
176
|
if (stopTag) {
|
|
39
177
|
throw new Error(`Unexpected closing tag ${stopTag}`);
|
|
40
178
|
}
|
|
@@ -44,7 +182,7 @@ export class ControlFlowRenderer {
|
|
|
44
182
|
return nodes;
|
|
45
183
|
}
|
|
46
184
|
|
|
47
|
-
parseNodes(source, startIndex, stopTags) {
|
|
185
|
+
parseNodes(source, startIndex, stopTags, partialArgScope) {
|
|
48
186
|
const nodes = [];
|
|
49
187
|
let index = startIndex;
|
|
50
188
|
|
|
@@ -104,9 +242,10 @@ export class ControlFlowRenderer {
|
|
|
104
242
|
return { nodes, nextIndex: tokenEnd + 2, stopTag: 'else' };
|
|
105
243
|
}
|
|
106
244
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
245
|
+
const comparisonElseIfTag = getComparisonElseIfTag(token);
|
|
246
|
+
if (comparisonElseIfTag) {
|
|
247
|
+
if (!stopTags.has(comparisonElseIfTag)) {
|
|
248
|
+
throw new Error(`Unexpected ${comparisonElseIfTag} tag`);
|
|
110
249
|
}
|
|
111
250
|
return { nodes, nextIndex: tokenEnd + 2, stopTag: token };
|
|
112
251
|
}
|
|
@@ -119,15 +258,16 @@ export class ControlFlowRenderer {
|
|
|
119
258
|
}
|
|
120
259
|
|
|
121
260
|
if (token.startsWith('partial:')) {
|
|
122
|
-
const { name, args } = parsePartialToken(token);
|
|
261
|
+
const { name, args } = parsePartialToken(token, { allowedSingleSegmentPaths: partialArgScope });
|
|
123
262
|
nodes.push({ type: 'partial', name, args });
|
|
124
263
|
index = tokenEnd + 2;
|
|
125
264
|
continue;
|
|
126
265
|
}
|
|
127
266
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
267
|
+
const comparisonBlockTag = getComparisonBlockTag(token);
|
|
268
|
+
if (comparisonBlockTag) {
|
|
269
|
+
const expression = token.slice(`#${comparisonBlockTag} `.length).trim();
|
|
270
|
+
const block = this.parseComparisonBlock(source, tokenEnd + 2, comparisonBlockTag, expression, partialArgScope);
|
|
131
271
|
nodes.push(block.node);
|
|
132
272
|
index = block.nextIndex;
|
|
133
273
|
continue;
|
|
@@ -135,7 +275,7 @@ export class ControlFlowRenderer {
|
|
|
135
275
|
|
|
136
276
|
if (token.startsWith('#if ')) {
|
|
137
277
|
const expression = token.slice('#if '.length).trim();
|
|
138
|
-
const block = this.parseIfBlock(source, tokenEnd + 2, expression);
|
|
278
|
+
const block = this.parseIfBlock(source, tokenEnd + 2, expression, partialArgScope);
|
|
139
279
|
nodes.push(block.node);
|
|
140
280
|
index = block.nextIndex;
|
|
141
281
|
continue;
|
|
@@ -148,7 +288,8 @@ export class ControlFlowRenderer {
|
|
|
148
288
|
throw new Error(`Invalid for expression: ${expression}`);
|
|
149
289
|
}
|
|
150
290
|
const [, itemName, path] = match;
|
|
151
|
-
const
|
|
291
|
+
const forScope = new Set([...partialArgScope, itemName, 'loop']);
|
|
292
|
+
const forResult = this.parseNodes(source, tokenEnd + 2, new Set(['for']), forScope);
|
|
152
293
|
if (forResult.stopTag !== 'for') {
|
|
153
294
|
throw new Error('Unclosed for block');
|
|
154
295
|
}
|
|
@@ -169,7 +310,7 @@ export class ControlFlowRenderer {
|
|
|
169
310
|
return { nodes, nextIndex: source.length, stopTag: null };
|
|
170
311
|
}
|
|
171
312
|
|
|
172
|
-
parseIfBlock(source, startIndex, initialPath) {
|
|
313
|
+
parseIfBlock(source, startIndex, initialPath, partialArgScope) {
|
|
173
314
|
if (!PATH_REGEX.test(initialPath)) {
|
|
174
315
|
throw new Error(`Invalid if expression: ${initialPath}`);
|
|
175
316
|
}
|
|
@@ -180,14 +321,14 @@ export class ControlFlowRenderer {
|
|
|
180
321
|
let currentPath = initialPath;
|
|
181
322
|
|
|
182
323
|
while (true) {
|
|
183
|
-
const branchResult = this.parseNodes(source, nextIndex, new Set(['else', 'else_if', 'if']));
|
|
324
|
+
const branchResult = this.parseNodes(source, nextIndex, new Set(['else', 'else_if', 'if']), partialArgScope);
|
|
184
325
|
branches.push({
|
|
185
326
|
path: currentPath,
|
|
186
327
|
consequent: branchResult.nodes,
|
|
187
328
|
});
|
|
188
329
|
|
|
189
330
|
if (branchResult.stopTag === 'else') {
|
|
190
|
-
const elseResult = this.parseNodes(source, branchResult.nextIndex, new Set(['if']));
|
|
331
|
+
const elseResult = this.parseNodes(source, branchResult.nextIndex, new Set(['if']), partialArgScope);
|
|
191
332
|
if (elseResult.stopTag !== 'if') {
|
|
192
333
|
throw new Error('Unclosed if block after else');
|
|
193
334
|
}
|
|
@@ -223,51 +364,53 @@ export class ControlFlowRenderer {
|
|
|
223
364
|
};
|
|
224
365
|
}
|
|
225
366
|
|
|
226
|
-
|
|
227
|
-
const initialBranch = this.
|
|
367
|
+
parseComparisonBlock(source, startIndex, tagName, expression, partialArgScope) {
|
|
368
|
+
const initialBranch = this.parseComparisonBranchExpression(expression, tagName);
|
|
228
369
|
const branches = [];
|
|
229
370
|
let alternate = [];
|
|
230
371
|
let nextIndex = startIndex;
|
|
231
372
|
let currentBranch = initialBranch;
|
|
373
|
+
const elseIfTag = `else_${tagName}`;
|
|
232
374
|
|
|
233
375
|
while (true) {
|
|
234
|
-
const branchResult = this.parseNodes(source, nextIndex, new Set(['else',
|
|
376
|
+
const branchResult = this.parseNodes(source, nextIndex, new Set(['else', elseIfTag, tagName]), partialArgScope);
|
|
235
377
|
branches.push({
|
|
236
|
-
|
|
237
|
-
|
|
378
|
+
operator: currentBranch.operator,
|
|
379
|
+
left: currentBranch.left,
|
|
380
|
+
operands: currentBranch.operands,
|
|
238
381
|
consequent: branchResult.nodes,
|
|
239
382
|
});
|
|
240
383
|
|
|
241
384
|
if (branchResult.stopTag === 'else') {
|
|
242
|
-
const elseResult = this.parseNodes(source, branchResult.nextIndex, new Set([
|
|
243
|
-
if (elseResult.stopTag !==
|
|
244
|
-
throw new Error(
|
|
385
|
+
const elseResult = this.parseNodes(source, branchResult.nextIndex, new Set([tagName]), partialArgScope);
|
|
386
|
+
if (elseResult.stopTag !== tagName) {
|
|
387
|
+
throw new Error(`Unclosed ${tagName} block after else`);
|
|
245
388
|
}
|
|
246
389
|
alternate = elseResult.nodes;
|
|
247
390
|
nextIndex = elseResult.nextIndex;
|
|
248
391
|
break;
|
|
249
392
|
}
|
|
250
393
|
|
|
251
|
-
if (typeof branchResult.stopTag === 'string' && branchResult.stopTag.startsWith(
|
|
252
|
-
currentBranch = this.
|
|
253
|
-
branchResult.stopTag.slice(
|
|
254
|
-
|
|
394
|
+
if (typeof branchResult.stopTag === 'string' && branchResult.stopTag.startsWith(`#${elseIfTag} `)) {
|
|
395
|
+
currentBranch = this.parseComparisonBranchExpression(
|
|
396
|
+
branchResult.stopTag.slice(`#${elseIfTag} `.length).trim(),
|
|
397
|
+
elseIfTag,
|
|
255
398
|
);
|
|
256
399
|
nextIndex = branchResult.nextIndex;
|
|
257
400
|
continue;
|
|
258
401
|
}
|
|
259
402
|
|
|
260
|
-
if (branchResult.stopTag ===
|
|
403
|
+
if (branchResult.stopTag === tagName) {
|
|
261
404
|
nextIndex = branchResult.nextIndex;
|
|
262
405
|
break;
|
|
263
406
|
}
|
|
264
407
|
|
|
265
|
-
throw new Error(
|
|
408
|
+
throw new Error(`Unclosed ${tagName} block`);
|
|
266
409
|
}
|
|
267
410
|
|
|
268
411
|
return {
|
|
269
412
|
node: {
|
|
270
|
-
type: '
|
|
413
|
+
type: 'comparison',
|
|
271
414
|
branches,
|
|
272
415
|
alternate,
|
|
273
416
|
},
|
|
@@ -275,16 +418,23 @@ export class ControlFlowRenderer {
|
|
|
275
418
|
};
|
|
276
419
|
}
|
|
277
420
|
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
421
|
+
parseComparisonBranchExpression(expression, tagName) {
|
|
422
|
+
const operator = COMPARISON_TAG_OPERATORS[tagName];
|
|
423
|
+
const tokens = tokenizeExpression(expression);
|
|
424
|
+
if (
|
|
425
|
+
!operator
|
|
426
|
+
|| (operator === 'in' && tokens.length < 2)
|
|
427
|
+
|| (operator !== 'in' && tokens.length !== 2)
|
|
428
|
+
) {
|
|
281
429
|
throw new Error(`Invalid ${tagName} expression: ${expression}`);
|
|
282
430
|
}
|
|
283
431
|
|
|
284
|
-
const [,
|
|
432
|
+
const left = parsePathOperand(tokens[0], tagName, expression);
|
|
433
|
+
const operands = tokens.slice(1).map((token) => parseComparisonOperand(token, tagName, expression));
|
|
285
434
|
return {
|
|
286
|
-
|
|
287
|
-
|
|
435
|
+
operator,
|
|
436
|
+
left,
|
|
437
|
+
operands,
|
|
288
438
|
};
|
|
289
439
|
}
|
|
290
440
|
|
|
@@ -302,8 +452,8 @@ export class ControlFlowRenderer {
|
|
|
302
452
|
? this.renderNodes(branch.consequent, data, renderOptions)
|
|
303
453
|
: this.renderNodes(node.alternate, data, renderOptions);
|
|
304
454
|
}
|
|
305
|
-
case '
|
|
306
|
-
const branch = node.branches.find((entry) => this.
|
|
455
|
+
case 'comparison': {
|
|
456
|
+
const branch = node.branches.find((entry) => this.evaluateComparison(entry, data));
|
|
307
457
|
return branch
|
|
308
458
|
? this.renderNodes(branch.consequent, data, renderOptions)
|
|
309
459
|
: this.renderNodes(node.alternate, data, renderOptions);
|
|
@@ -345,9 +495,13 @@ export class ControlFlowRenderer {
|
|
|
345
495
|
}
|
|
346
496
|
|
|
347
497
|
const partialTemplate = String(partials.get(node.name) || '');
|
|
498
|
+
const partialArgs = this.resolvePartialArgs(node.args, data);
|
|
348
499
|
const partialData = {
|
|
349
500
|
...data,
|
|
350
|
-
partial: {
|
|
501
|
+
partial: {
|
|
502
|
+
...(data?.partial && typeof data.partial === 'object' ? data.partial : {}),
|
|
503
|
+
...partialArgs,
|
|
504
|
+
},
|
|
351
505
|
};
|
|
352
506
|
|
|
353
507
|
return this.renderTemplate(partialTemplate, partialData, {
|
|
@@ -356,6 +510,64 @@ export class ControlFlowRenderer {
|
|
|
356
510
|
});
|
|
357
511
|
}
|
|
358
512
|
|
|
513
|
+
resolvePartialArgs(args, data) {
|
|
514
|
+
const resolved = {};
|
|
515
|
+
for (const [key, operand] of Object.entries(args || {})) {
|
|
516
|
+
if (operand?.kind === 'literal') {
|
|
517
|
+
resolved[key] = operand.value;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (operand?.kind === 'path') {
|
|
521
|
+
resolved[key] = this.resolvePath(data, operand.path);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return resolved;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
evaluateComparison(entry, data) {
|
|
528
|
+
const left = this.evaluateOperand(entry.left, data);
|
|
529
|
+
const operands = entry.operands.map((operand) => this.evaluateOperand(operand, data));
|
|
530
|
+
|
|
531
|
+
if (entry.operator === 'eq') {
|
|
532
|
+
const right = operands[0];
|
|
533
|
+
return !left.missing && !right.missing && left.value === right.value;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (entry.operator === 'neq') {
|
|
537
|
+
const right = operands[0];
|
|
538
|
+
return left.missing || right.missing || left.value !== right.value;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (entry.operator === 'in') {
|
|
542
|
+
if (left.missing) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
return operands.some((operand) => !operand.missing && left.value === operand.value);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (entry.operator === 'starts_with') {
|
|
549
|
+
const right = operands[0];
|
|
550
|
+
return (
|
|
551
|
+
!left.missing
|
|
552
|
+
&& !right.missing
|
|
553
|
+
&& typeof left.value === 'string'
|
|
554
|
+
&& typeof right.value === 'string'
|
|
555
|
+
&& left.value.startsWith(right.value)
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
evaluateOperand(operand, data) {
|
|
563
|
+
if (operand.kind === 'literal') {
|
|
564
|
+
return { value: operand.value, missing: false };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const value = this.resolvePath(data, operand.path);
|
|
568
|
+
return { value, missing: value === undefined };
|
|
569
|
+
}
|
|
570
|
+
|
|
359
571
|
isTruthy(value) {
|
|
360
572
|
if (Array.isArray(value)) {
|
|
361
573
|
return value.length > 0;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const PARTIAL_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\/[a-zA-Z_][a-zA-Z0-9_-]*)*$/;
|
|
2
2
|
const IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
3
|
+
const PATH_SEGMENT_SOURCE = '[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*';
|
|
4
|
+
const PATH_REGEX = new RegExp(`^${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*$`);
|
|
5
|
+
const NUMBER_LITERAL_REGEX = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
3
6
|
|
|
4
|
-
export function parsePartialToken(token) {
|
|
7
|
+
export function parsePartialToken(token, options = {}) {
|
|
5
8
|
const source = String(token || '').trim();
|
|
6
9
|
if (!source.startsWith('partial:')) {
|
|
7
10
|
throw new Error(`Invalid partial token: ${source}`);
|
|
@@ -19,12 +22,12 @@ export function parsePartialToken(token) {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const argsSource = expression.slice(name.length).trim();
|
|
22
|
-
const args = parsePartialArgs(argsSource);
|
|
25
|
+
const args = parsePartialArgs(argsSource, options);
|
|
23
26
|
|
|
24
27
|
return { name, args };
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
function parsePartialArgs(source) {
|
|
30
|
+
function parsePartialArgs(source, options = {}) {
|
|
28
31
|
if (!source) {
|
|
29
32
|
return {};
|
|
30
33
|
}
|
|
@@ -86,23 +89,47 @@ function parsePartialArgs(source) {
|
|
|
86
89
|
throw new Error(`Unclosed string literal for partial argument "${key}"`);
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
args[key] = JSON.parse(source.slice(index, cursor + 1));
|
|
92
|
+
args[key] = { kind: 'literal', value: JSON.parse(source.slice(index, cursor + 1)) };
|
|
90
93
|
index = cursor + 1;
|
|
91
94
|
continue;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
if (source.startsWith('true', index) && isValueBoundary(source, index + 4)) {
|
|
95
|
-
args[key] = true;
|
|
98
|
+
args[key] = { kind: 'literal', value: true };
|
|
96
99
|
index += 4;
|
|
97
100
|
continue;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
if (source.startsWith('false', index) && isValueBoundary(source, index + 5)) {
|
|
101
|
-
args[key] = false;
|
|
104
|
+
args[key] = { kind: 'literal', value: false };
|
|
102
105
|
index += 5;
|
|
103
106
|
continue;
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
if (source.startsWith('null', index) && isValueBoundary(source, index + 4)) {
|
|
110
|
+
args[key] = { kind: 'literal', value: null };
|
|
111
|
+
index += 4;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const valueMatch = /^\S+/.exec(source.slice(index));
|
|
116
|
+
if (!valueMatch) {
|
|
117
|
+
throw new Error(`Missing partial argument value for "${key}"`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const valueToken = valueMatch[0];
|
|
121
|
+
if (NUMBER_LITERAL_REGEX.test(valueToken)) {
|
|
122
|
+
args[key] = { kind: 'literal', value: Number(valueToken) };
|
|
123
|
+
index += valueToken.length;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (PATH_REGEX.test(valueToken) && isAllowedPathArgument(valueToken, options)) {
|
|
128
|
+
args[key] = { kind: 'path', path: valueToken };
|
|
129
|
+
index += valueToken.length;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
throw new Error(`Unsupported partial argument value for "${key}"`);
|
|
107
134
|
}
|
|
108
135
|
|
|
@@ -112,3 +139,12 @@ function parsePartialArgs(source) {
|
|
|
112
139
|
function isValueBoundary(source, index) {
|
|
113
140
|
return index >= source.length || /\s/.test(source[index]);
|
|
114
141
|
}
|
|
142
|
+
|
|
143
|
+
function isAllowedPathArgument(valueToken, options) {
|
|
144
|
+
if (valueToken.includes('.')) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const allowedSingleSegmentPaths = options?.allowedSingleSegmentPaths;
|
|
149
|
+
return allowedSingleSegmentPaths instanceof Set && allowedSingleSegmentPaths.has(valueToken);
|
|
150
|
+
}
|
|
@@ -94,6 +94,10 @@ export class VariableResolver {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
if (variablePath === 'data' || variablePath.startsWith('data.') || variablePath.includes('.data.')) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
97
101
|
const lastSegment = variablePath.split('.').pop();
|
|
98
102
|
if (lastSegment === 'html' || lastSegment?.endsWith('_html')) {
|
|
99
103
|
return true;
|
|
@@ -37,7 +37,7 @@ export class ZeroPressEngine {
|
|
|
37
37
|
const renderData = this.combineRenderData(data, context);
|
|
38
38
|
const renderedContent = this.renderTemplate(template, renderData);
|
|
39
39
|
const layoutWithSlots = this.slotResolver.resolve(layout, this.themePackage.partials, CONTENT_SLOT_PLACEHOLDER);
|
|
40
|
-
return this.renderTemplate(layoutWithSlots, renderData).replaceAll(CONTENT_SLOT_PLACEHOLDER, renderedContent);
|
|
40
|
+
return this.renderTemplate(layoutWithSlots, renderData).replaceAll(CONTENT_SLOT_PLACEHOLDER, () => renderedContent);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
combineRenderData(data, context) {
|
|
@@ -50,7 +50,7 @@ export class ZeroPressEngine {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
renderTemplate(template, data) {
|
|
53
|
-
if (this.themePackage?.metadata?.runtime !== '0.
|
|
53
|
+
if (this.themePackage?.metadata?.runtime !== '0.6') {
|
|
54
54
|
throw new Error(`Unsupported theme runtime: ${this.themePackage?.metadata?.runtime || 'unknown'}`);
|
|
55
55
|
}
|
|
56
56
|
|