@zeropress/build-core 0.5.2 → 0.6.2

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/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PreviewDataV05 } from '@zeropress/preview-data-validator';
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: PreviewDataV05;
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: PreviewDataV05;
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 IF_EQ_EXPRESSION_REGEX = new RegExp(`^(${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*)\\s+("(?:[^"\\\\]|\\\\.)*")$`);
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
- if (token.startsWith('#else_if_eq ')) {
108
- if (!stopTags.has('else_if_eq')) {
109
- throw new Error('Unexpected else_if_eq tag');
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
- if (token.startsWith('#if_eq ')) {
129
- const expression = token.slice('#if_eq '.length).trim();
130
- const block = this.parseIfEqBlock(source, tokenEnd + 2, expression);
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 forResult = this.parseNodes(source, tokenEnd + 2, new Set(['for']));
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
- parseIfEqBlock(source, startIndex, expression) {
227
- const initialBranch = this.parseIfEqBranchExpression(expression, 'if_eq');
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', 'else_if_eq', 'if_eq']));
376
+ const branchResult = this.parseNodes(source, nextIndex, new Set(['else', elseIfTag, tagName]), partialArgScope);
235
377
  branches.push({
236
- path: currentBranch.path,
237
- literal: currentBranch.literal,
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(['if_eq']));
243
- if (elseResult.stopTag !== 'if_eq') {
244
- throw new Error('Unclosed if_eq block after else');
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('#else_if_eq ')) {
252
- currentBranch = this.parseIfEqBranchExpression(
253
- branchResult.stopTag.slice('#else_if_eq '.length).trim(),
254
- 'else_if_eq',
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 === 'if_eq') {
403
+ if (branchResult.stopTag === tagName) {
261
404
  nextIndex = branchResult.nextIndex;
262
405
  break;
263
406
  }
264
407
 
265
- throw new Error('Unclosed if_eq block');
408
+ throw new Error(`Unclosed ${tagName} block`);
266
409
  }
267
410
 
268
411
  return {
269
412
  node: {
270
- type: 'if_eq',
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
- parseIfEqBranchExpression(expression, tagName) {
279
- const match = IF_EQ_EXPRESSION_REGEX.exec(expression);
280
- if (!match) {
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 [, path, literalSource] = match;
432
+ const left = parsePathOperand(tokens[0], tagName, expression);
433
+ const operands = tokens.slice(1).map((token) => parseComparisonOperand(token, tagName, expression));
285
434
  return {
286
- path,
287
- literal: JSON.parse(literalSource),
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 'if_eq': {
306
- const branch = node.branches.find((entry) => this.resolvePath(data, entry.path) === entry.literal);
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: { ...node.args },
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.5') {
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