@zeropress/build-core 0.7.1 → 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`
@@ -165,13 +168,13 @@ Theme validation is enforced through:
165
168
 
166
169
  `buildSiteFromThemeDir()` is the convenience entry point that loads a theme directory and converts it into the required in-memory `themePackage`.
167
170
 
168
- Theme packages use fixed safety limits shared with `@zeropress/theme-validator`: at most 128 entries, 1 MiB per file, and 4 MiB in total expanded content. `buildSiteFromThemeDir()` normalizes equivalent spellings such as `theme`, `theme/`, and `theme/.` before checking the final entry, requires that final entry to be real rather than a symbolic link, pins an accepted root to its canonical path, checks filesystem sizes before reading file bodies, and rejects every symbolic link inside the root, including links that stay inside the theme directory and dangling links. Symbolic links in ancestor path components are allowed. Package paths reject literal backslashes and exact empty, `.` or `..` segments while ordinary filenames such as `name..txt` remain valid. Directory entries that collide after NFC and case normalization are also rejected so directory and ZIP adapters use the same cross-platform identity. `buildSite()` validates the same limits for an already-loaded in-memory package. Packaged ZIP input is handled by `@zeropress/theme`, which additionally limits the archive itself to 2 MiB.
171
+ Theme packages use fixed safety limits shared with `@zeropress/theme-validator`: at most 128 entries, 1 MiB per file, and 4 MiB in total content. `buildSiteFromThemeDir()` normalizes equivalent spellings such as `theme`, `theme/`, and `theme/.` before checking the final entry, requires that final entry to be real rather than a symbolic link, pins an accepted root to its canonical path, checks filesystem sizes before reading file bodies, and rejects every symbolic link inside the root, including links that stay inside the theme directory and dangling links. Symbolic links in ancestor path components are allowed. Package paths reject literal backslashes and exact empty, `.` or `..` segments while ordinary filenames such as `name..txt` remain valid. Directory entries that collide after NFC and case normalization are rejected so every package has one cross-platform path identity. `buildSite()` validates the same limits for an already-loaded in-memory package.
169
172
 
170
173
  Theme CSS and JavaScript assets are emitted byte-for-byte as provided. `custom_css.content` is checked for nonblank content but otherwise retains its original whitespace, comments, strings, and syntax. Build Core may hash output filenames; every hash is computed from the bytes actually emitted, and Build Core does not rewrite or minify CSS or JavaScript. Both `.js` and `.mjs` outputs use a JavaScript MIME type.
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
 
@@ -182,8 +185,22 @@ Build-core derives:
182
185
  - localized fallback `published_at` / `updated_at` values plus unconditional `published_at_iso` / `updated_at_iso` values
183
186
  - datetime formatting from `site.locale`, `site.timezone`, `site.date_style`, and `site.time_style`
184
187
  - `reading_time`
188
+ - effective `post.summary` and `page.summary` values for theme listings and metadata
185
189
  - a route-root `comments` discriminated context
186
190
 
191
+ ### Authored excerpts and runtime summaries
192
+
193
+ Preview Data `post.excerpt` and `page.excerpt` are authored values. Build Core does not replace them with body text; an omitted Page excerpt becomes `""` in the runtime context. It derives a separate `summary` once from each prepared Post or Page:
194
+
195
+ - a trim-nonempty authored excerpt becomes the summary after trimming only its outer whitespace, without a length limit
196
+ - otherwise, Build Core uses visible text from the rendered HTML, removes comments and non-visible `script`, `style`, `template`, and `noscript` content, removes a leading H1 when it matches the document title, decodes entities, and collapses whitespace
197
+ - a derived body summary is at most 160 Unicode code points, including a final `…` when truncated
198
+ - a document with no displayable text has `summary: ""`
199
+
200
+ Both fields are available on detail Post/Page contexts. The same precomputed summary is propagated to `posts.items[]`, archive and taxonomy items, Post/Page collection items, collection cursor `prev`/`next` items, and `post.prev`/`post.next`. Themes should use `summary` for optional list/card copy and retain `excerpt` for an explicitly authored detail lede.
201
+
202
+ Post/Page metadata descriptions use `summary`; ordinary detail routes do not fall back to `site.description`. A Page used as the front page uses `site.description` only when neither an authored nor body-derived summary exists. RSS item descriptions use Post summaries. Native/Pagefind search continues to index the authored excerpt separately and creates a query-specific body excerpt when needed; it does not substitute the general runtime summary. User-defined `meta.description` keys remain ordinary generator/theme metadata and receive no special Build Core interpretation.
203
+
187
204
  Every ordinary rendered route receives effective feature state on `site`, regardless of whether the corresponding Preview Data preference was omitted:
188
205
 
189
206
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,15 +30,15 @@
30
30
  "test": "node --test"
31
31
  },
32
32
  "engines": {
33
- "node": ">=22.12.0"
33
+ "node": ">=22.22.0"
34
34
  },
35
35
  "dependencies": {
36
- "@zeropress/slug-policy": "0.2.1",
37
- "@zeropress/preview-data-validator": "0.7.1",
38
- "@zeropress/theme-validator": "0.7.1",
39
- "highlight.js": "11.11.1",
40
- "markdown-it": "14.3.0",
41
- "markdown-it-anchor": "9.2.1",
42
- "sanitize-html": "2.17.6"
36
+ "@zeropress/slug-policy": "0.2.2",
37
+ "@zeropress/preview-data-validator": "0.7.3",
38
+ "@zeropress/theme-validator": "0.7.3",
39
+ "highlight.js": "11.12.0",
40
+ "markdown-it": "15.0.2",
41
+ "markdown-it-anchor": "10.0.0",
42
+ "sanitize-html": "2.17.7"
43
43
  }
44
44
  }
@@ -2,9 +2,10 @@ import { createHash } from 'node:crypto';
2
2
  import { assertPreviewData } from '@zeropress/preview-data-validator';
3
3
  import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
4
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
5
+ import sanitizeHtml from 'sanitize-html';
5
6
  import { AssetProcessor } from '../assets/asset-processor.js';
6
7
  import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
7
- import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
8
+ import { createThemeValidationError } from '../theme/format-theme-validation.js';
8
9
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
9
10
 
10
11
  const DEFAULT_OPTIONS = {
@@ -52,6 +53,8 @@ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:']);
52
53
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
53
54
  const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
54
55
  const CUSTOM_HTML_SLOT_MAX_CODE_POINTS = 65_536;
56
+ const GENERATED_SUMMARY_MAX_CODE_POINTS = 160;
57
+ const SUMMARY_TEXT_BOUNDARY_TAG_PATTERN = /<\/?(?:h[1-6]|p|br|hr|pre|ul|ol|li|blockquote|aside|figure|figcaption|table|thead|tbody|tfoot|tr|th|td|div|nav)\b[^>]*>/gi;
55
58
  const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
56
59
  const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
57
60
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
@@ -261,6 +264,7 @@ async function renderFrontPage(state, route) {
261
264
  ...route.page,
262
265
  url: currentUrl,
263
266
  };
267
+ const description = page.summary || state.previewData.site.description;
264
268
  let html = await state.engine.render(
265
269
  'page',
266
270
  {
@@ -273,7 +277,7 @@ async function renderFrontPage(state, route) {
273
277
  meta: buildPageMeta(state.previewData.site, {
274
278
  currentUrl,
275
279
  title: buildFrontPageTitle(state.previewData.site),
276
- description: page.excerpt,
280
+ description,
277
281
  ogType: 'website',
278
282
  image: page.featured_image,
279
283
  robotsNoindex: shouldNoindexDocument(page),
@@ -294,7 +298,7 @@ async function renderFrontPage(state, route) {
294
298
  state.emitted.frontPage = {
295
299
  url: currentUrl,
296
300
  title: page.title,
297
- description: page.excerpt,
301
+ description,
298
302
  updatedAt: page.updated_at_iso,
299
303
  includeInFeed: false,
300
304
  includeInSitemap: !isDelistedDocument(page),
@@ -349,7 +353,7 @@ async function renderPost(state, post) {
349
353
  meta: buildPageMeta(state.previewData.site, {
350
354
  currentUrl,
351
355
  title: buildDocumentTitle(post.title, state.previewData.site.title),
352
- description: post.excerpt,
356
+ description: post.summary,
353
357
  ogType: 'article',
354
358
  image: post.featured_image,
355
359
  publishedTime: post.published_at_iso,
@@ -373,7 +377,7 @@ async function renderPost(state, post) {
373
377
  state.emitted.posts.push({
374
378
  url: currentUrl,
375
379
  title: post.title,
376
- description: post.excerpt,
380
+ description: post.summary,
377
381
  publishedAt: post.published_at_iso,
378
382
  updatedAt: post.updated_at_iso,
379
383
  status: post.status,
@@ -398,7 +402,7 @@ async function renderPage(state, page) {
398
402
  currentUrl,
399
403
  canonicalUrl,
400
404
  title: buildDocumentTitle(page.title, state.previewData.site.title),
401
- description: page.excerpt,
405
+ description: page.summary,
402
406
  ogType: 'website',
403
407
  image: page.featured_image,
404
408
  robotsNoindex: shouldNoindexDocument(page),
@@ -419,7 +423,7 @@ async function renderPage(state, page) {
419
423
  state.emitted.pages.push({
420
424
  url: currentUrl,
421
425
  title: page.title,
422
- description: page.excerpt,
426
+ description: page.summary,
423
427
  updatedAt: page.updated_at_iso,
424
428
  status: page.status,
425
429
  includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
@@ -1291,6 +1295,7 @@ function buildCollectionPageSummary(page, frontPage, pageReferencePath) {
1291
1295
  slug: page.slug,
1292
1296
  url: frontPage?.type === 'page' && frontPage.page_path === pageReferencePath ? '/' : page.url,
1293
1297
  excerpt: page.excerpt || '',
1298
+ summary: page.summary || '',
1294
1299
  featured_image: page.featured_image || '',
1295
1300
  updated_at: page.updated_at || '',
1296
1301
  updated_at_iso: page.updated_at_iso || '',
@@ -1348,6 +1353,7 @@ function buildCollectionCursorItemSummary(item) {
1348
1353
  slug: item.slug,
1349
1354
  url: item.url,
1350
1355
  excerpt: item.excerpt || '',
1356
+ summary: item.summary || '',
1351
1357
  featured_image: item.featured_image || '',
1352
1358
  updated_at: item.updated_at || '',
1353
1359
  updated_at_iso: item.updated_at_iso || '',
@@ -1681,12 +1687,15 @@ function preparePage(page, site) {
1681
1687
  const renderedDocument = renderDocument(page.content, documentType);
1682
1688
  const permalink = resolvePagePermalink(site, page);
1683
1689
  const pageFields = { ...page };
1690
+ const excerpt = typeof page.excerpt === 'string' ? page.excerpt : '';
1684
1691
  delete pageFields.comments;
1685
1692
 
1686
1693
  return {
1687
1694
  ...pageFields,
1688
1695
  url: permalink.url,
1689
1696
  document_type: documentType,
1697
+ excerpt,
1698
+ summary: buildDocumentSummary(excerpt, renderedDocument.html, page.title),
1690
1699
  html: renderedDocument.html,
1691
1700
  toc: renderedDocument.toc,
1692
1701
  updated_at: page.updated_at_iso ? formatTimestamp(page.updated_at_iso, site) : '',
@@ -1696,6 +1705,7 @@ function preparePage(page, site) {
1696
1705
  function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1697
1706
  const documentType = normalizeDocumentType(post.document_type);
1698
1707
  const renderedDocument = renderDocument(post.content, documentType);
1708
+ const excerpt = typeof post.excerpt === 'string' ? post.excerpt : '';
1699
1709
  const author = authorsById.get(post.author_id);
1700
1710
  const permalink = resolvePermalink(site, 'posts', post);
1701
1711
  const categories = post.category_slugs
@@ -1722,7 +1732,8 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1722
1732
  url: permalink.url,
1723
1733
  content: post.content,
1724
1734
  document_type: documentType,
1725
- excerpt: post.excerpt,
1735
+ excerpt,
1736
+ summary: buildDocumentSummary(excerpt, renderedDocument.html, post.title),
1726
1737
  published_at_iso: post.published_at_iso,
1727
1738
  updated_at_iso: post.updated_at_iso,
1728
1739
  author_id: post.author_id,
@@ -1964,6 +1975,7 @@ function buildStructuredPostSummary(post) {
1964
1975
  slug: post.slug,
1965
1976
  url: post.url,
1966
1977
  excerpt: post.excerpt,
1978
+ summary: post.summary,
1967
1979
  published_at: post.published_at,
1968
1980
  published_at_iso: post.published_at_iso,
1969
1981
  reading_time: post.reading_time,
@@ -1991,6 +2003,7 @@ function buildAdjacentPostSummary(post) {
1991
2003
  slug: post.slug,
1992
2004
  url: post.url,
1993
2005
  excerpt: post.excerpt,
2006
+ summary: post.summary,
1994
2007
  published_at: post.published_at,
1995
2008
  published_at_iso: post.published_at_iso,
1996
2009
  data: post.data,
@@ -2235,7 +2248,7 @@ async function normalizeAndValidateThemePackage(themePackage) {
2235
2248
 
2236
2249
  const validation = await validateThemeFiles(fileMap);
2237
2250
  if (!validation.ok) {
2238
- throw new Error(formatThemeValidationFailure(validation));
2251
+ throw createThemeValidationError(validation);
2239
2252
  }
2240
2253
 
2241
2254
  if (!validation.manifest) {
@@ -3187,6 +3200,63 @@ function buildSearchPageItem(page, url, pageReferencePath) {
3187
3200
  };
3188
3201
  }
3189
3202
 
3203
+ function buildDocumentSummary(excerpt, html, title) {
3204
+ const authoredExcerpt = String(excerpt || '').trim();
3205
+ if (authoredExcerpt) {
3206
+ return authoredExcerpt;
3207
+ }
3208
+
3209
+ const visibleText = htmlToSummaryText(html, title);
3210
+ const codePoints = [...visibleText];
3211
+ if (codePoints.length <= GENERATED_SUMMARY_MAX_CODE_POINTS) {
3212
+ return visibleText;
3213
+ }
3214
+
3215
+ const truncated = codePoints
3216
+ .slice(0, GENERATED_SUMMARY_MAX_CODE_POINTS - 1)
3217
+ .join('')
3218
+ .trimEnd();
3219
+ return `${truncated}…`;
3220
+ }
3221
+
3222
+ function htmlToSummaryText(html, title) {
3223
+ let source = removeNonVisibleSummaryHtml(html);
3224
+ const leadingHeading = source.match(/<h1\b[^>]*>([\s\S]*?)<\/h1\s*>/i);
3225
+ if (
3226
+ leadingHeading
3227
+ && htmlFragmentToSummaryText(source.slice(0, leadingHeading.index)) === ''
3228
+ && normalizeComparableSummaryText(htmlFragmentToSummaryText(leadingHeading[1]))
3229
+ === normalizeComparableSummaryText(title)
3230
+ ) {
3231
+ source = source.slice(0, leadingHeading.index)
3232
+ + source.slice(leadingHeading.index + leadingHeading[0].length);
3233
+ }
3234
+
3235
+ return htmlFragmentToSummaryText(source);
3236
+ }
3237
+
3238
+ function removeNonVisibleSummaryHtml(html) {
3239
+ return String(html || '')
3240
+ .replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
3241
+ .replace(/<!--[\s\S]*?-->/g, ' ');
3242
+ }
3243
+
3244
+ function htmlFragmentToSummaryText(html) {
3245
+ const textWithTagBoundaries = String(html || '').replace(SUMMARY_TEXT_BOUNDARY_TAG_PATTERN, ' ');
3246
+ const decodedText = sanitizeHtml(textWithTagBoundaries, {
3247
+ allowedTags: [],
3248
+ allowedAttributes: {},
3249
+ parser: {
3250
+ decodeEntities: true,
3251
+ },
3252
+ });
3253
+ return normalizeSearchText(decodeHtmlEntities(decodedText));
3254
+ }
3255
+
3256
+ function normalizeComparableSummaryText(value) {
3257
+ return normalizeSearchText(value).normalize('NFC');
3258
+ }
3259
+
3190
3260
  function buildSearchHeadings(toc) {
3191
3261
  return Array.isArray(toc)
3192
3262
  ? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
@@ -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,9 +67,19 @@ 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
- nonTextTags: ['script', 'style', 'textarea', 'option', 'xmp'],
82
+ nonTextTags: ['script', 'style', 'template', 'noscript', 'textarea', 'option', 'xmp'],
63
83
  transformTags: {
64
84
  a: transformAnchor,
65
85
  th: transformTableCell,
@@ -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) {