@zeropress/build-core 0.6.13 → 0.7.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.
@@ -1,19 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
3
  export class AssetProcessor {
4
- async processCSS(css) {
5
- return css
6
- .replace(/\/\*[\s\S]*?\*\//g, '')
7
- .replace(/\s+/g, ' ')
8
- .replace(/\s*([{}:;,>+~])\s*/g, '$1')
9
- .replace(/;}/g, '}')
10
- .trim();
11
- }
12
-
13
- async processJavaScript(js) {
14
- return js;
15
- }
16
-
17
4
  generateAssetHash(content) {
18
5
  const hash = createHash('sha256');
19
6
  hash.update(content);
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PreviewDataV06 } from '@zeropress/preview-data-validator';
1
+ import type { PreviewDataV07 } from '@zeropress/preview-data-validator';
2
2
  import type { ThemeManifest } from '@zeropress/theme-validator';
3
3
 
4
4
  export interface ThemePackage {
@@ -24,17 +24,55 @@ export interface BuildOptions {
24
24
  assetHashing?: boolean;
25
25
  favicon?: {
26
26
  icon?: string;
27
+ icon_dark?: string;
27
28
  svg?: string;
28
29
  png?: string;
29
30
  apple_touch_icon?: string;
30
31
  };
31
32
  sitemapStylesheetHref?: string;
32
- generateSpecialFiles?: boolean;
33
33
  generateFeed?: boolean;
34
34
  generateRobotsTxt?: boolean;
35
- writeManifest?: boolean;
35
+ reservedOutputPaths?: readonly string[];
36
36
  }
37
37
 
38
+ export interface DisabledThemeCommentsContext {
39
+ enabled: false;
40
+ }
41
+
42
+ export interface ThemeCommentsThreadingContext {
43
+ enabled: boolean;
44
+ max_depth: number;
45
+ }
46
+
47
+ export interface ZeroPressThemeCommentsContext {
48
+ enabled: true;
49
+ target_type: 'post' | 'page';
50
+ target_public_id: number;
51
+ provider: 'zeropress';
52
+ api_base_url: string;
53
+ per_page: number;
54
+ order: 'asc' | 'desc';
55
+ threading: ThemeCommentsThreadingContext;
56
+ request_token: string;
57
+ }
58
+
59
+ export interface WordPressThemeCommentsContext {
60
+ enabled: true;
61
+ target_type: 'post' | 'page';
62
+ target_public_id: number;
63
+ provider: 'wordpress';
64
+ api_base_url: string;
65
+ per_page: number;
66
+ order: 'asc' | 'desc';
67
+ threading: ThemeCommentsThreadingContext;
68
+ request_token?: never;
69
+ }
70
+
71
+ export type ThemeCommentsContext =
72
+ | DisabledThemeCommentsContext
73
+ | ZeroPressThemeCommentsContext
74
+ | WordPressThemeCommentsContext;
75
+
38
76
  export interface BuildSummaryFile {
39
77
  path: string;
40
78
  contentType: string;
@@ -42,25 +80,19 @@ export interface BuildSummaryFile {
42
80
  sha256: string;
43
81
  }
44
82
 
45
- export interface BuildManifest {
46
- generatedAt: string;
47
- files: BuildSummaryFile[];
48
- }
49
-
50
83
  export interface BuildSiteResult {
51
84
  files: BuildSummaryFile[];
52
- manifest?: BuildManifest;
53
85
  }
54
86
 
55
87
  export function buildSite(input: {
56
- previewData: PreviewDataV06;
88
+ previewData: PreviewDataV07;
57
89
  themePackage: ThemePackage;
58
90
  writer: BuildWriter;
59
91
  options?: BuildOptions;
60
92
  }): Promise<BuildSiteResult>;
61
93
 
62
94
  export function buildSiteFromThemeDir(input: {
63
- previewData: PreviewDataV06;
95
+ previewData: PreviewDataV07;
64
96
  themeDir: string;
65
97
  writer: BuildWriter;
66
98
  options?: BuildOptions;
@@ -1,6 +1,7 @@
1
1
  import MarkdownIt from 'markdown-it';
2
2
  import hljs from 'highlight.js';
3
3
  import anchor from 'markdown-it-anchor';
4
+ import sanitizeHtmlParser from 'sanitize-html';
4
5
 
5
6
  const TOC_LEVELS = new Set([2, 3, 4]);
6
7
  const ALERT_TYPES = new Map([
@@ -19,6 +20,57 @@ const ADMONITION_CONTAINER_TYPES = new Map([
19
20
  ['CAUTION', { className: 'caution', title: 'Caution' }],
20
21
  ['DANGER', { className: 'caution', title: 'Danger' }],
21
22
  ]);
23
+ const CONTENT_ALLOWED_TAGS = [
24
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
25
+ 'p', 'br', 'hr',
26
+ 'strong', 'b', 'em', 'u', 's', 'sup', 'sub', 'code', 'pre',
27
+ 'a', 'img',
28
+ 'ul', 'ol', 'li',
29
+ 'blockquote', 'aside',
30
+ 'figure', 'figcaption', 'picture', 'source',
31
+ 'video', 'audio', 'track',
32
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
33
+ 'div', 'span', 'nav',
34
+ 'iframe', 'input',
35
+ ];
36
+ const CONTENT_ALLOWED_ATTRIBUTES = {
37
+ a: ['href', 'title', 'class', 'id', 'target', 'rel'],
38
+ aside: ['role', 'class', 'id'],
39
+ img: ['src', 'srcset', 'sizes', 'alt', 'title', 'class', 'id', 'width', 'height', 'loading', 'decoding'],
40
+ iframe: ['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class', 'title'],
41
+ input: ['type', 'checked', 'disabled', 'class', 'id', 'aria-label'],
42
+ source: ['src', 'srcset', 'sizes', 'type', 'media', 'width', 'height', 'class', 'id'],
43
+ th: ['rowspan', 'colspan', 'align', 'class', 'id'],
44
+ td: ['rowspan', 'colspan', 'align', 'class', 'id'],
45
+ video: ['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'width', 'height', 'class', 'id', 'title'],
46
+ audio: ['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'preload', 'class', 'id', 'title'],
47
+ track: ['src', 'kind', 'srclang', 'label', 'default', 'class', 'id'],
48
+ '*': ['class', 'id'],
49
+ };
50
+ const CONTENT_SANITIZER_OPTIONS = {
51
+ allowedTags: CONTENT_ALLOWED_TAGS,
52
+ allowedAttributes: CONTENT_ALLOWED_ATTRIBUTES,
53
+ allowedSchemes: ['http', 'https'],
54
+ allowedSchemesByTag: {
55
+ a: ['http', 'https', 'mailto', 'tel'],
56
+ },
57
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'poster'],
58
+ allowProtocolRelative: false,
59
+ allowIframeRelativeUrls: true,
60
+ parseStyleAttributes: false,
61
+ selfClosing: ['img', 'br', 'hr', 'input', 'source', 'track'],
62
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'xmp'],
63
+ transformTags: {
64
+ a: transformAnchor,
65
+ th: transformTableCell,
66
+ td: transformTableCell,
67
+ video: transformNativeMedia,
68
+ audio: transformNativeMedia,
69
+ },
70
+ exclusiveFilter(frame) {
71
+ return frame.tag === 'input' && frame.attribs.type !== 'checkbox';
72
+ },
73
+ };
22
74
 
23
75
  export function renderDocumentContent(content, documentType = 'markdown') {
24
76
  return renderDocument(content, documentType).html;
@@ -429,178 +481,68 @@ function escapeHtml(value) {
429
481
  }
430
482
 
431
483
  function sanitizeHtml(html) {
432
- const allowedTags = new Set([
433
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
434
- 'p', 'br', 'hr',
435
- 'strong', 'b', 'em', 'u', 's', 'sup', 'sub', 'code', 'pre',
436
- 'a', 'img',
437
- 'ul', 'ol', 'li',
438
- 'blockquote', 'aside',
439
- 'figure', 'figcaption', 'picture', 'source',
440
- 'video', 'audio', 'track',
441
- 'table', 'thead', 'tbody', 'tr', 'th', 'td',
442
- 'div', 'span', 'nav',
443
- 'iframe', 'input',
444
- ]);
445
-
446
- const allowedAttributes = {
447
- a: new Set(['href', 'title', 'class', 'id', 'target', 'rel']),
448
- aside: new Set(['role', 'class', 'id']),
449
- img: new Set(['src', 'srcset', 'sizes', 'alt', 'title', 'class', 'id', 'width', 'height', 'loading', 'decoding']),
450
- iframe: new Set(['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class', 'title']),
451
- input: new Set(['type', 'checked', 'disabled', 'class', 'id', 'aria-label']),
452
- source: new Set(['src', 'srcset', 'sizes', 'type', 'media', 'width', 'height', 'class', 'id']),
453
- th: new Set(['rowspan', 'colspan', 'align', 'class', 'id']),
454
- td: new Set(['rowspan', 'colspan', 'align', 'class', 'id']),
455
- video: new Set(['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'width', 'height', 'class', 'id', 'title']),
456
- audio: new Set(['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'preload', 'class', 'id', 'title']),
457
- track: new Set(['src', 'kind', 'srclang', 'label', 'default', 'class', 'id']),
458
- '*': new Set(['class', 'id']),
459
- };
460
-
461
- const safeUriPattern = /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
462
-
463
- const stripped = html
464
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
465
- .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
466
-
467
- const sanitized = stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)?\/?>/g, (match, tag, attributeString) => {
468
- const normalizedTag = tag.toLowerCase();
469
-
470
- if (match.startsWith('</')) {
471
- return allowedTags.has(normalizedTag) ? `</${normalizedTag}>` : '';
472
- }
473
-
474
- if (!allowedTags.has(normalizedTag)) {
475
- return '';
476
- }
477
-
478
- const tagAllowedAttributes = allowedAttributes[normalizedTag] || new Set();
479
- const globalAllowedAttributes = allowedAttributes['*'];
480
- const filteredAttributes = [];
481
- let anchorTarget = '';
482
- let anchorRel = '';
483
- let tableCellAlignClass = '';
484
-
485
- if (attributeString) {
486
- const attributePattern = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
487
- let attributeMatch;
488
-
489
- while ((attributeMatch = attributePattern.exec(attributeString)) !== null) {
490
- const attributeName = attributeMatch[1].toLowerCase();
491
- const attributeValue = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? '';
492
-
493
- if (!tagAllowedAttributes.has(attributeName) && !globalAllowedAttributes.has(attributeName)) {
494
- continue;
495
- }
496
-
497
- if ((attributeName === 'href' || attributeName === 'src' || attributeName === 'poster') && !safeUriPattern.test(attributeValue)) {
498
- continue;
499
- }
500
-
501
- if (normalizedTag === 'a' && attributeName === 'target') {
502
- const target = sanitizeAnchorTarget(attributeValue);
503
- if (!target) {
504
- continue;
505
- }
506
- anchorTarget = target;
507
- continue;
508
- }
509
-
510
- if (normalizedTag === 'a' && attributeName === 'rel') {
511
- const rel = sanitizeRelList(attributeValue);
512
- if (!rel) {
513
- continue;
514
- }
515
- anchorRel = rel;
516
- continue;
517
- }
518
-
519
- if (attributeName === 'srcset' && !isSafeSrcset(attributeValue, safeUriPattern)) {
520
- continue;
521
- }
522
-
523
- if (attributeName === 'controlslist') {
524
- const controlsList = sanitizeControlsList(attributeValue);
525
- if (!controlsList) {
526
- continue;
527
- }
528
- filteredAttributes.push(`${attributeName}="${controlsList}"`);
529
- continue;
530
- }
484
+ return sanitizeHtmlParser(html, CONTENT_SANITIZER_OPTIONS);
485
+ }
531
486
 
532
- if ((normalizedTag === 'th' || normalizedTag === 'td') && (attributeName === 'rowspan' || attributeName === 'colspan')) {
533
- const span = sanitizePositiveIntegerAttribute(attributeValue);
534
- if (!span) {
535
- continue;
536
- }
537
- filteredAttributes.push(`${attributeName}="${span}"`);
538
- continue;
539
- }
487
+ function transformAnchor(tagName, attributes) {
488
+ const transformed = { ...attributes };
489
+ const target = sanitizeAnchorTarget(transformed.target);
490
+ const rel = sanitizeRelList(transformed.rel);
540
491
 
541
- if ((normalizedTag === 'th' || normalizedTag === 'td') && attributeName === 'align') {
542
- const alignment = sanitizeTableAlignment(attributeValue);
543
- if (!alignment) {
544
- continue;
545
- }
546
- tableCellAlignClass = `zp-align-${alignment}`;
547
- continue;
548
- }
492
+ if (target) {
493
+ transformed.target = target;
494
+ } else {
495
+ delete transformed.target;
496
+ }
549
497
 
550
- if (normalizedTag === 'input' && attributeName === 'type' && attributeValue !== 'checkbox') {
551
- continue;
552
- }
498
+ const safeRel = target === '_blank' ? ensureBlankTargetRel(rel) : rel;
499
+ if (safeRel) {
500
+ transformed.rel = safeRel;
501
+ } else {
502
+ delete transformed.rel;
503
+ }
553
504
 
554
- filteredAttributes.push(`${attributeName}="${attributeValue}"`);
555
- }
556
- }
505
+ return { tagName, attribs: transformed };
506
+ }
557
507
 
558
- if (tableCellAlignClass) {
559
- appendClassAttribute(filteredAttributes, tableCellAlignClass);
560
- }
508
+ function transformTableCell(tagName, attributes) {
509
+ const transformed = { ...attributes };
561
510
 
562
- if (normalizedTag === 'a') {
563
- if (anchorTarget) {
564
- filteredAttributes.push(`target="${anchorTarget}"`);
565
- }
566
- if (anchorTarget === '_blank') {
567
- anchorRel = ensureBlankTargetRel(anchorRel);
568
- }
569
- if (anchorRel) {
570
- filteredAttributes.push(`rel="${anchorRel}"`);
571
- }
511
+ for (const attributeName of ['rowspan', 'colspan']) {
512
+ const value = sanitizePositiveIntegerAttribute(transformed[attributeName]);
513
+ if (value) {
514
+ transformed[attributeName] = value;
515
+ } else {
516
+ delete transformed[attributeName];
572
517
  }
518
+ }
573
519
 
574
- const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img' || normalizedTag === 'input' || normalizedTag === 'source' || normalizedTag === 'track';
575
- const attributeSuffix = filteredAttributes.length > 0 ? ` ${filteredAttributes.join(' ')}` : '';
576
- return isSelfClosing ? `<${normalizedTag}${attributeSuffix} />` : `<${normalizedTag}${attributeSuffix}>`;
577
- });
578
-
579
- return sanitized
580
- .split(/(<[^>]+>)/g)
581
- .map((part) => {
582
- if (part.startsWith('<') && part.endsWith('>')) {
583
- return part;
584
- }
520
+ const alignment = sanitizeTableAlignment(transformed.align);
521
+ delete transformed.align;
522
+ if (alignment) {
523
+ transformed.class = appendClassName(transformed.class, `zp-align-${alignment}`);
524
+ }
585
525
 
586
- return part.replace(/&(?!(?:[a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;');
587
- })
588
- .join('');
526
+ return { tagName, attribs: transformed };
589
527
  }
590
528
 
591
- function appendClassAttribute(attributes, className) {
592
- const classIndex = attributes.findIndex((attribute) => attribute.startsWith('class="'));
593
- if (classIndex === -1) {
594
- attributes.push(`class="${className}"`);
595
- return;
529
+ function transformNativeMedia(tagName, attributes) {
530
+ const transformed = { ...attributes };
531
+ const controlsList = sanitizeControlsList(transformed.controlslist);
532
+ if (controlsList) {
533
+ transformed.controlslist = controlsList;
534
+ } else {
535
+ delete transformed.controlslist;
596
536
  }
537
+ return { tagName, attribs: transformed };
538
+ }
597
539
 
598
- const existingValue = attributes[classIndex].slice('class="'.length, -1);
599
- const classes = existingValue.split(/\s+/).filter(Boolean);
540
+ function appendClassName(value, className) {
541
+ const classes = String(value || '').split(/\s+/).filter(Boolean);
600
542
  if (!classes.includes(className)) {
601
543
  classes.push(className);
602
544
  }
603
- attributes[classIndex] = `class="${classes.join(' ')}"`;
545
+ return classes.join(' ');
604
546
  }
605
547
 
606
548
  function sanitizePositiveIntegerAttribute(value) {
@@ -668,18 +610,6 @@ function sanitizeControlsList(value) {
668
610
  return tokens.join(' ');
669
611
  }
670
612
 
671
- function isSafeSrcset(value, safeUriPattern) {
672
- const candidates = String(value)
673
- .split(',')
674
- .map((candidate) => candidate.trim())
675
- .filter(Boolean);
676
-
677
- return candidates.length > 0 && candidates.every((candidate) => {
678
- const [url] = candidate.split(/\s+/);
679
- return Boolean(url) && safeUriPattern.test(url);
680
- });
681
- }
682
-
683
613
  function slugify(value) {
684
614
  return String(value || '')
685
615
  .toLowerCase()
@@ -229,6 +229,9 @@ export class ControlFlowRenderer {
229
229
 
230
230
  if (token.startsWith('/')) {
231
231
  const tagName = token.slice(1).trim();
232
+ if (COMPARISON_BLOCK_TAGS.has(tagName)) {
233
+ throw new Error(`Named conditional closing tag {{/${tagName}}} is not supported; use {{/if}}`);
234
+ }
232
235
  if (!stopTags.has(tagName)) {
233
236
  throw new Error(`Unexpected closing tag /${tagName}`);
234
237
  }
@@ -370,8 +373,8 @@ export class ControlFlowRenderer {
370
373
  let alternate = [];
371
374
  let nextIndex = startIndex;
372
375
  let currentBranch = initialBranch;
373
- const comparisonStopTags = new Set(['else', 'if', tagName, ...COMPARISON_ELSE_IF_TAGS]);
374
- const comparisonCloseTags = new Set(['if', tagName]);
376
+ const comparisonStopTags = new Set(['else', 'if', ...COMPARISON_ELSE_IF_TAGS]);
377
+ const comparisonCloseTags = new Set(['if']);
375
378
 
376
379
  while (true) {
377
380
  const branchResult = this.parseNodes(source, nextIndex, comparisonStopTags, partialArgScope);
@@ -94,7 +94,12 @@ export class VariableResolver {
94
94
  }
95
95
  }
96
96
 
97
- if (variablePath === 'data' || variablePath.startsWith('data.') || variablePath.includes('.data.')) {
97
+ if (
98
+ variablePath === 'data'
99
+ || variablePath.startsWith('data.')
100
+ || variablePath.includes('.data.')
101
+ || variablePath.includes('.meta.')
102
+ ) {
98
103
  return false;
99
104
  }
100
105
 
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_RUNTIME } from '@zeropress/theme-validator';
1
2
  import { SlotResolver } from './slot-resolver.js';
2
3
  import { VariableResolver } from './variable-resolver.js';
3
4
  import { ControlFlowRenderer } from './control-flow-renderer.js';
@@ -46,11 +47,12 @@ export class ZeroPressEngine {
46
47
  site: context.site,
47
48
  currentUrl: context.currentUrl,
48
49
  language: context.language,
50
+ comments: context.comments,
49
51
  };
50
52
  }
51
53
 
52
54
  renderTemplate(template, data) {
53
- if (this.themePackage?.metadata?.runtime !== '0.6') {
55
+ if (this.themePackage?.metadata?.runtime !== DEFAULT_RUNTIME) {
54
56
  throw new Error(`Unsupported theme runtime: ${this.themePackage?.metadata?.runtime || 'unknown'}`);
55
57
  }
56
58
 
@@ -0,0 +1,54 @@
1
+ export function formatThemeValidationFailure(validation) {
2
+ const blocks = [
3
+ [
4
+ 'Theme validation failed',
5
+ `Errors: ${validation.errors.length}`,
6
+ `Checked files: ${validation.checkedFiles}`,
7
+ ].join('\n'),
8
+ ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
9
+ ];
10
+ return blocks.join('\n\n');
11
+ }
12
+
13
+ function formatThemeValidationIssue(issue) {
14
+ if (!issue) {
15
+ return 'Reason: Unknown error';
16
+ }
17
+
18
+ const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
19
+ const location = splitIssuePath(issue.path);
20
+ if (location.file) {
21
+ lines.push(`File: ${location.file}`);
22
+ }
23
+ if (location.path) {
24
+ lines.push(`Path: ${location.path}`);
25
+ }
26
+ if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
27
+ lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
28
+ }
29
+ if (issue.category) {
30
+ lines.push(`Category: ${issue.category}`);
31
+ }
32
+ lines.push(`Reason: ${issue.message || 'Unknown error'}`);
33
+ if (issue.snippet) {
34
+ const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
35
+ lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
36
+ }
37
+ if (issue.hint) {
38
+ lines.push('', 'Hint:', issue.hint);
39
+ }
40
+
41
+ return lines.join('\n');
42
+ }
43
+
44
+ function splitIssuePath(issuePath) {
45
+ const normalizedPath = String(issuePath || '');
46
+ if (normalizedPath.startsWith('theme.json.')) {
47
+ return {
48
+ file: 'theme.json',
49
+ path: normalizedPath.slice('theme.json.'.length),
50
+ };
51
+ }
52
+
53
+ return { file: normalizedPath, path: '' };
54
+ }