@zeropress/build-core 0.7.3 → 0.7.4

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 CHANGED
@@ -121,6 +121,9 @@ Loads a theme directory from disk and renders it using the same core pipeline.
121
121
 
122
122
  This is useful for local tooling that wants filesystem theme loading but still uses the same deterministic core renderer.
123
123
 
124
+ Theme validation failures from either build entry point carry `error.code === 'THEME_VALIDATION_FAILED'`.
125
+ Their messages preserve report and hint line breaks while escaping terminal control characters in diagnostic values such as paths, messages, and source snippets. CLI consumers can preserve the layout of these reports while escaping arbitrary errors as single-line text.
126
+
124
127
  ## Writers
125
128
 
126
129
  ### `MemoryWriter`
@@ -171,7 +174,7 @@ Theme CSS and JavaScript assets are emitted byte-for-byte as provided. `custom_c
171
174
 
172
175
  User-defined scalar metadata under `site.meta`, `post.meta`, `page.meta`, and item `meta` is always HTML-escaped during template interpolation. Names ending in `_html` or `_url` do not opt metadata into raw rendering; use the explicit content and custom HTML contracts for trusted raw markup.
173
176
 
174
- Post/Page HTML, Markdown raw HTML, and text-widget HTML pass through a parser-based explicit allowlist. Safe structural, media, table, task-list, and alert markup is retained; event handlers and unsafe URL schemes are removed. Links permit relative URLs, HTTP(S), `mailto:`, and `tel:`. Media and iframe URLs permit relative URLs or HTTP(S), and protocol-relative URLs are rejected. Each `srcset` candidate is checked independently. This sanitizer boundary does not include trusted `custom_html` slots or `standalone_html`, which remain intentionally unchanged.
177
+ Post/Page HTML, Markdown raw HTML, and text-widget HTML pass through a parser-based explicit allowlist. Safe structural, media, table, task-list, and alert markup is retained; event handlers and unsafe URL schemes are removed. Inline presentation is limited to `text-align: left | center | right | justify` on paragraphs/headings and bounded CSS `color` values on spans; every other inline style is removed. Links permit relative URLs, HTTP(S), `mailto:`, and `tel:`. Media and iframe URLs permit relative URLs or HTTP(S), and protocol-relative URLs are rejected. Each `srcset` candidate is checked independently. This sanitizer boundary does not include trusted `custom_html` slots or `standalone_html`, which remain intentionally unchanged.
175
178
 
176
179
  Build-core derives:
177
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,12 +33,12 @@
33
33
  "node": ">=22.22.0"
34
34
  },
35
35
  "dependencies": {
36
- "@zeropress/slug-policy": "0.2.1",
36
+ "@zeropress/slug-policy": "0.2.2",
37
37
  "@zeropress/preview-data-validator": "0.7.3",
38
38
  "@zeropress/theme-validator": "0.7.3",
39
39
  "highlight.js": "11.12.0",
40
- "markdown-it": "15.0.0",
41
- "markdown-it-anchor": "9.2.1",
40
+ "markdown-it": "15.0.2",
41
+ "markdown-it-anchor": "10.0.0",
42
42
  "sanitize-html": "2.17.7"
43
43
  }
44
44
  }
@@ -5,7 +5,7 @@ import { validateThemeFiles } from '@zeropress/theme-validator';
5
5
  import sanitizeHtml from 'sanitize-html';
6
6
  import { AssetProcessor } from '../assets/asset-processor.js';
7
7
  import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
8
- import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
8
+ import { createThemeValidationError } from '../theme/format-theme-validation.js';
9
9
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
10
10
 
11
11
  const DEFAULT_OPTIONS = {
@@ -2248,7 +2248,7 @@ async function normalizeAndValidateThemePackage(themePackage) {
2248
2248
 
2249
2249
  const validation = await validateThemeFiles(fileMap);
2250
2250
  if (!validation.ok) {
2251
- throw new Error(formatThemeValidationFailure(validation));
2251
+ throw createThemeValidationError(validation);
2252
2252
  }
2253
2253
 
2254
2254
  if (!validation.manifest) {
@@ -34,6 +34,14 @@ const CONTENT_ALLOWED_TAGS = [
34
34
  'iframe', 'input',
35
35
  ];
36
36
  const CONTENT_ALLOWED_ATTRIBUTES = {
37
+ h1: ['style'],
38
+ h2: ['style'],
39
+ h3: ['style'],
40
+ h4: ['style'],
41
+ h5: ['style'],
42
+ h6: ['style'],
43
+ p: ['style'],
44
+ span: ['style'],
37
45
  a: ['href', 'title', 'class', 'id', 'target', 'rel'],
38
46
  aside: ['role', 'class', 'id'],
39
47
  img: ['src', 'srcset', 'sizes', 'alt', 'title', 'class', 'id', 'width', 'height', 'loading', 'decoding'],
@@ -47,6 +55,8 @@ const CONTENT_ALLOWED_ATTRIBUTES = {
47
55
  track: ['src', 'kind', 'srclang', 'label', 'default', 'class', 'id'],
48
56
  '*': ['class', 'id'],
49
57
  };
58
+ const CONTENT_COLOR_STYLE_PATTERN = /^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|[a-z]{1,32}|(?:rgb|rgba|hsl|hsla)\((?:[-+.\d%,/\s]|deg|rad|grad|turn)+\))$/i;
59
+ const CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN = /^(?:left|center|right|justify)$/i;
50
60
  const CONTENT_SANITIZER_OPTIONS = {
51
61
  allowedTags: CONTENT_ALLOWED_TAGS,
52
62
  allowedAttributes: CONTENT_ALLOWED_ATTRIBUTES,
@@ -57,7 +67,17 @@ const CONTENT_SANITIZER_OPTIONS = {
57
67
  allowedSchemesAppliedToAttributes: ['href', 'src', 'poster'],
58
68
  allowProtocolRelative: false,
59
69
  allowIframeRelativeUrls: true,
60
- parseStyleAttributes: false,
70
+ allowedStyles: {
71
+ h1: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
72
+ h2: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
73
+ h3: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
74
+ h4: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
75
+ h5: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
76
+ h6: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
77
+ p: { 'text-align': [CONTENT_TEXT_ALIGNMENT_STYLE_PATTERN] },
78
+ span: { color: [CONTENT_COLOR_STYLE_PATTERN] },
79
+ },
80
+ parseStyleAttributes: true,
61
81
  selfClosing: ['img', 'br', 'hr', 'input', 'source', 'track'],
62
82
  nonTextTags: ['script', 'style', 'template', 'noscript', 'textarea', 'option', 'xmp'],
63
83
  transformTags: {
@@ -1,9 +1,17 @@
1
+ const UNSAFE_TERMINAL_CHARACTER_REGEX = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu;
2
+
3
+ export function createThemeValidationError(validation) {
4
+ return Object.assign(new Error(formatThemeValidationFailure(validation)), {
5
+ code: 'THEME_VALIDATION_FAILED',
6
+ });
7
+ }
8
+
1
9
  export function formatThemeValidationFailure(validation) {
2
10
  const blocks = [
3
11
  [
4
12
  'Theme validation failed',
5
13
  `Errors: ${validation.errors.length}`,
6
- `Checked files: ${validation.checkedFiles}`,
14
+ `Checked files: ${toTerminalSafeText(validation.checkedFiles)}`,
7
15
  ].join('\n'),
8
16
  ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
9
17
  ];
@@ -15,32 +23,38 @@ function formatThemeValidationIssue(issue) {
15
23
  return 'Reason: Unknown error';
16
24
  }
17
25
 
18
- const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
26
+ const lines = [`ERROR ${toTerminalSafeText(issue.code || 'THEME_VALIDATION_ERROR')}`];
19
27
  const location = splitIssuePath(issue.path);
20
28
  if (location.file) {
21
- lines.push(`File: ${location.file}`);
29
+ lines.push(`File: ${toTerminalSafeText(location.file)}`);
22
30
  }
23
31
  if (location.path) {
24
- lines.push(`Path: ${location.path}`);
32
+ lines.push(`Path: ${toTerminalSafeText(location.path)}`);
25
33
  }
26
34
  if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
27
35
  lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
28
36
  }
29
37
  if (issue.category) {
30
- lines.push(`Category: ${issue.category}`);
38
+ lines.push(`Category: ${toTerminalSafeText(issue.category)}`);
31
39
  }
32
- lines.push(`Reason: ${issue.message || 'Unknown error'}`);
40
+ lines.push(`Reason: ${toTerminalSafeText(issue.message || 'Unknown error')}`);
33
41
  if (issue.snippet) {
34
42
  const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
35
- lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
43
+ lines.push('', `${lineLabel} | ${toTerminalSafeText(issue.snippet.line)}`, `${' '.repeat(lineLabel.length)} | ${toTerminalSafeText(issue.snippet.pointer)}`);
36
44
  }
37
45
  if (issue.hint) {
38
- lines.push('', 'Hint:', issue.hint);
46
+ lines.push('', 'Hint:', String(issue.hint).split('\n').map(toTerminalSafeText).join('\n'));
39
47
  }
40
48
 
41
49
  return lines.join('\n');
42
50
  }
43
51
 
52
+ function toTerminalSafeText(value) {
53
+ return String(value ?? '').replace(UNSAFE_TERMINAL_CHARACTER_REGEX, (character) => (
54
+ `\\u${character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`
55
+ ));
56
+ }
57
+
44
58
  function splitIssuePath(issuePath) {
45
59
  const normalizedPath = String(issuePath || '');
46
60
  if (normalizedPath.startsWith('theme.json.')) {
@@ -2,7 +2,7 @@ import {
2
2
  validateThemeFiles,
3
3
  validateThemePackageLimits,
4
4
  } from '@zeropress/theme-validator';
5
- import { formatThemeValidationFailure } from './format-theme-validation.js';
5
+ import { createThemeValidationError } from './format-theme-validation.js';
6
6
 
7
7
  const TEXT_FILE_EXTENSIONS = new Set(['.html', '.json', '.css', '.js', '.txt', '.svg', '.xml']);
8
8
 
@@ -47,7 +47,7 @@ export async function loadThemePackageFromDir(themeDir) {
47
47
  pathEntries: packageState.pathEntries,
48
48
  });
49
49
  if (!validation.ok) {
50
- throw new Error(formatThemeValidationFailure(validation));
50
+ throw createThemeValidationError(validation);
51
51
  }
52
52
 
53
53
  const rawThemeJson = String(fileMap.get('theme.json'));
@@ -174,7 +174,7 @@ async function readThemeDir(fs, fsConstants, path, rootDir, currentDir, fileMap,
174
174
  }
175
175
 
176
176
  function createThemePathError(code, filePath, message, checkedFiles) {
177
- return new Error(formatThemeValidationFailure({
177
+ return createThemeValidationError({
178
178
  errors: [{
179
179
  code,
180
180
  path: filePath,
@@ -183,7 +183,7 @@ function createThemePathError(code, filePath, message, checkedFiles) {
183
183
  category: 'theme_package_paths',
184
184
  }],
185
185
  checkedFiles,
186
- }));
186
+ });
187
187
  }
188
188
 
189
189
  function assertThemePackageLimits(packageState) {
@@ -194,10 +194,10 @@ function assertThemePackageLimits(packageState) {
194
194
  return;
195
195
  }
196
196
 
197
- throw new Error(formatThemeValidationFailure({
197
+ throw createThemeValidationError({
198
198
  errors,
199
199
  checkedFiles: packageState.fileSizes.size,
200
- }));
200
+ });
201
201
  }
202
202
 
203
203
  function toUint8Array(value) {