@zeropress/build-core 0.7.0 → 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.
package/README.md CHANGED
@@ -107,7 +107,7 @@ Notes:
107
107
  - `previewData` must already satisfy the canonical preview-data contract
108
108
  - `themePackage` must already be a validated in-memory theme package
109
109
  - `sitemap.xml` is emitted only when `site.url` is a non-empty canonical URL
110
- - `feed.xml` is emitted only when `site.feed.enabled` is not `false`, `site.url` is a non-empty canonical URL, and `generateFeed` is not `false`
110
+ - `feed.xml` is emitted only when `site.feed.enabled` is not `false`, `site.url` is a non-empty canonical URL, and `generateFeed` is not `false`; its `lastBuildDate` is derived from required `previewData.generated_at`, never the build machine clock
111
111
  - callers may pass `sitemapStylesheetHref` to add an XML stylesheet processing instruction to generated `sitemap.xml`
112
112
  - fallback `robots.txt` is emitted when `generateRobotsTxt` is not `false`
113
113
  - fallback `robots.txt` uses the effective `site.robots.allow_indexing` policy; omitted Preview Data defaults to `true`, `false` emits `Disallow: /`, and `true` emits `Allow: /`
@@ -165,10 +165,14 @@ Theme validation is enforced through:
165
165
 
166
166
  `buildSiteFromThemeDir()` is the convenience entry point that loads a theme directory and converts it into the required in-memory `themePackage`.
167
167
 
168
- JavaScript theme assets are emitted as provided. Build-core may hash output filenames, but it does not rewrite or minify JavaScript content.
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.
169
+
170
+ 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.
169
171
 
170
172
  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.
171
173
 
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.
175
+
172
176
  Build-core derives:
173
177
 
174
178
  - index/archive/category/tag routes
@@ -250,6 +254,10 @@ The active state requires all of the following:
250
254
  - the post or page has `allow_comments: true` and a positive `public_id`
251
255
  - the ZeroPress provider has a non-empty item `comments.request_token`
252
256
 
257
+ Post and Page `allow_comments` are optional Preview Data fields. Omission is
258
+ normalized to `false`; producers should emit `true` only for items that opt in
259
+ to comments.
260
+
253
261
  The theme-facing `request_token` key exists only for the `zeropress` provider. Preview-data items may retain ignored token metadata for `wordpress` or inactive comment states, but Build Core drops it. An active `wordpress` context omits that key entirely. Post/page objects, structured list items, collection items and cursors, adjacent-item summaries, search data, feeds, and non-detail route roots do not receive a copy of the item token. Themes must use the route-root `comments.enabled` discriminator.
254
262
 
255
263
  The canonical `preview-data v0.7` site contract uses:
@@ -304,7 +312,7 @@ For ordinary theme-rendered HTML routes, site customization order is determinist
304
312
 
305
313
  Closing `</head>` and `</body>` tags are matched case-insensitively. If a configured custom HTML slot has no matching closing tag, the build fails with the route, output path, missing tag, and remediation guidance instead of silently dropping the configured content. Themes that consume these slots must render the corresponding closing tag. A `site.front_page.type: "standalone_html"` document remains byte-for-byte independent and does not receive favicon, generator, custom CSS, or custom HTML injection.
306
314
 
307
- Native search artifacts are emitted only when preview-data does not set `site.search.enabled: false` and the active theme declares `features.search: true`. When that effective search state is disabled, search widgets are omitted from resolved widget items while their widget areas and non-search siblings remain available. `search_pagefind.js` is a Pagefind adapter that can replace `search.js` after a post-build Pagefind step.
315
+ Native search artifacts are emitted only when preview-data does not set `site.search.enabled: false` and the active theme declares `features.search: true`. When that effective search state is disabled, search widgets are omitted from resolved widget items while their widget areas and non-search siblings remain available. The native adapter tokenizes non-CJK text with `Intl.Segmenter(site.locale)` and falls back to Unicode word matching only when Segmenter is unavailable or fails. CJK runs contribute their full token and unique bigrams while repeated occurrences still contribute term frequency. `search_pagefind.js` is a Pagefind adapter that can replace `search.js` after a post-build Pagefind step.
308
316
 
309
317
  When archive is disabled or `archive.html` is missing, archive routes and archive widget items are omitted while authored menu items, widget areas, and non-archive siblings remain unchanged. No archive output path is claimed in that state, so content may use `/archive`. Feed behaves the same way for `feed.xml`: a disabled feed neither emits nor claims the file. Enabled feeds add one RSS autodiscovery link to `meta.head_tags` on ordinary theme-rendered routes; `404.html` and standalone front-page HTML do not receive it.
310
318
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,10 +34,11 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@zeropress/slug-policy": "0.2.1",
37
- "@zeropress/preview-data-validator": "0.7.0",
38
- "@zeropress/theme-validator": "0.7.0",
37
+ "@zeropress/preview-data-validator": "0.7.1",
38
+ "@zeropress/theme-validator": "0.7.1",
39
39
  "highlight.js": "11.11.1",
40
40
  "markdown-it": "14.3.0",
41
- "markdown-it-anchor": "9.2.1"
41
+ "markdown-it-anchor": "9.2.1",
42
+ "sanitize-html": "2.17.6"
42
43
  }
43
44
  }
@@ -4,6 +4,7 @@ import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
4
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
5
5
  import { AssetProcessor } from '../assets/asset-processor.js';
6
6
  import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
7
+ import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
7
8
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
8
9
 
9
10
  const DEFAULT_OPTIONS = {
@@ -64,6 +65,7 @@ const SEARCH_FIELD_WEIGHTS = Object.freeze({
64
65
  content_text: 1,
65
66
  });
66
67
  const SEARCH_RECENCY_BOOST_MAX = 0.15;
68
+ const RFC3339_TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:)(60|[0-5]\d)(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
67
69
  export async function buildSite(input) {
68
70
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
69
71
  const state = await createBuildState(input, options);
@@ -109,7 +111,7 @@ export async function buildSite(input) {
109
111
 
110
112
  if (shouldGenerateSearchArtifacts(state)) {
111
113
  await writeOutput(state.writer, state.summaries, SEARCH_INDEX_OUTPUT_PATH, buildSearchIndexJson(state), 'application/json');
112
- await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(), 'application/javascript');
114
+ await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(state.previewData.site.locale), 'application/javascript');
113
115
  await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
114
116
  }
115
117
 
@@ -119,11 +121,11 @@ export async function buildSite(input) {
119
121
  state.writer,
120
122
  state.summaries,
121
123
  'sitemap.xml',
122
- buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt, options.sitemapStylesheetHref),
124
+ buildSitemapXml(state.previewData.site, state.emitted, options.sitemapStylesheetHref),
123
125
  'application/xml',
124
126
  );
125
127
  if (shouldGenerateFeed(state)) {
126
- await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
128
+ await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.feedGeneratedAt), 'application/rss+xml');
127
129
  }
128
130
  }
129
131
  if (shouldGenerateRobotsTxt(options)) {
@@ -151,8 +153,8 @@ async function createBuildState(input, options) {
151
153
 
152
154
  engine.initialize(themePackage);
153
155
 
154
- const assetOutputs = await buildAssetOutputs(themePackage.assets, assetProcessor, options);
155
- const customCssAsset = await buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
156
+ const assetOutputs = buildAssetOutputs(themePackage.assets, assetProcessor, options);
157
+ const customCssAsset = buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
156
158
  if (customCssAsset) {
157
159
  assetOutputs.push(customCssAsset);
158
160
  }
@@ -175,7 +177,7 @@ async function createBuildState(input, options) {
175
177
  favicon: previewData.site.favicon,
176
178
  exposeGenerator: previewData.site.expose_generator !== false,
177
179
  options,
178
- generatedAt: new Date(),
180
+ feedGeneratedAt: toDate(previewData.generated_at),
179
181
  emitted: {
180
182
  frontPage: null,
181
183
  indexRoutes: [],
@@ -775,8 +777,8 @@ function normalizeWidgetItem(item, media_origin) {
775
777
  }
776
778
 
777
779
  function normalizeCustomCss(customCss) {
778
- const content = normalizeOptionalString(customCss?.content);
779
- return content ? { content } : undefined;
780
+ const content = customCss?.content;
781
+ return typeof content === 'string' && content.trim() ? { content } : undefined;
780
782
  }
781
783
 
782
784
  function normalizeCustomHtml(customHtml) {
@@ -2248,61 +2250,6 @@ async function normalizeAndValidateThemePackage(themePackage) {
2248
2250
  };
2249
2251
  }
2250
2252
 
2251
- function formatThemeValidationFailure(validation) {
2252
- const blocks = [
2253
- [
2254
- 'Theme validation failed',
2255
- `Errors: ${validation.errors.length}`,
2256
- `Checked files: ${validation.checkedFiles}`,
2257
- ].join('\n'),
2258
- ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
2259
- ];
2260
- return blocks.join('\n\n');
2261
- }
2262
-
2263
- function formatThemeValidationIssue(issue) {
2264
- if (!issue) {
2265
- return 'Reason: Unknown error';
2266
- }
2267
-
2268
- const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
2269
- const location = splitIssuePath(issue.path);
2270
- if (location.file) {
2271
- lines.push(`File: ${location.file}`);
2272
- }
2273
- if (location.path) {
2274
- lines.push(`Path: ${location.path}`);
2275
- }
2276
- if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
2277
- lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
2278
- }
2279
- if (issue.category) {
2280
- lines.push(`Category: ${issue.category}`);
2281
- }
2282
- lines.push(`Reason: ${issue.message || 'Unknown error'}`);
2283
- if (issue.snippet) {
2284
- const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
2285
- lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
2286
- }
2287
- if (issue.hint) {
2288
- lines.push('', 'Hint:', issue.hint);
2289
- }
2290
-
2291
- return lines.join('\n');
2292
- }
2293
-
2294
- function splitIssuePath(issuePath) {
2295
- const normalizedPath = String(issuePath || '');
2296
- if (normalizedPath.startsWith('theme.json.')) {
2297
- return {
2298
- file: 'theme.json',
2299
- path: normalizedPath.slice('theme.json.'.length),
2300
- };
2301
- }
2302
-
2303
- return { file: normalizedPath, path: '' };
2304
- }
2305
-
2306
2253
  function normalizeThemePackageMetadata(sourceMetadata, manifest) {
2307
2254
  return {
2308
2255
  ...manifest,
@@ -2310,50 +2257,37 @@ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
2310
2257
  };
2311
2258
  }
2312
2259
 
2313
- async function buildAssetOutputs(assets, assetProcessor, options) {
2260
+ function buildAssetOutputs(assets, assetProcessor, options) {
2314
2261
  const outputs = [];
2315
2262
 
2316
2263
  for (const [assetPath, content] of assets.entries()) {
2317
- const ext = assetPath.split('.').pop()?.toLowerCase();
2318
- let processedContent = content;
2319
- let contentType = getContentType(assetPath);
2320
-
2321
- if (ext === 'css') {
2322
- processedContent = await assetProcessor.processCSS(new TextDecoder().decode(content));
2323
- contentType = 'text/css';
2324
- } else if (ext === 'js') {
2325
- processedContent = await assetProcessor.processJavaScript(new TextDecoder().decode(content));
2326
- contentType = 'application/javascript';
2327
- }
2328
-
2329
2264
  const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(content)}` : '';
2330
2265
  const targetPath = `assets/${assetPath.replace(/(\.[^.]+)$/, `${hash}$1`)}`;
2331
2266
 
2332
2267
  outputs.push({
2333
2268
  originalPath: assetPath,
2334
2269
  path: targetPath,
2335
- content: processedContent,
2336
- contentType,
2270
+ content,
2271
+ contentType: getContentType(assetPath),
2337
2272
  });
2338
2273
  }
2339
2274
 
2340
2275
  return outputs;
2341
2276
  }
2342
2277
 
2343
- async function buildCustomCssAsset(customCss, assetProcessor, options) {
2344
- const content = normalizeOptionalString(customCss?.content);
2345
- if (!content) {
2278
+ function buildCustomCssAsset(customCss, assetProcessor, options) {
2279
+ const content = customCss?.content;
2280
+ if (typeof content !== 'string' || !content.trim()) {
2346
2281
  return null;
2347
2282
  }
2348
2283
 
2349
2284
  const sourceBuffer = new TextEncoder().encode(content);
2350
- const processedContent = await assetProcessor.processCSS(content);
2351
2285
  const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
2352
2286
 
2353
2287
  return {
2354
2288
  originalPath: '__zeropress_custom.css',
2355
2289
  path: `assets/zeropress-custom${hash}.css`,
2356
- content: processedContent,
2290
+ content,
2357
2291
  contentType: 'text/css',
2358
2292
  };
2359
2293
  }
@@ -3296,11 +3230,13 @@ function normalizeSearchText(value) {
3296
3230
  return String(value || '').replace(/\s+/g, ' ').trim();
3297
3231
  }
3298
3232
 
3299
- function buildSearchAdapterJs() {
3233
+ function buildSearchAdapterJs(locale) {
3300
3234
  const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
3235
+ const localeJson = JSON.stringify(locale);
3301
3236
  return `const FIELD_WEIGHTS = ${fieldWeightsJson};
3302
3237
  const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
3303
3238
  const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
3239
+ const SEGMENTER_LOCALE = ${localeJson};
3304
3240
  const DEFAULT_LIMIT = 20;
3305
3241
  const BM25_K1 = 1.2;
3306
3242
  const BM25_B = 0.75;
@@ -3540,44 +3476,59 @@ function tokenize(value) {
3540
3476
  }
3541
3477
 
3542
3478
  const tokens = [];
3479
+ const cjkRuns = Array.from(
3480
+ text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu),
3481
+ (match) => match[0],
3482
+ );
3483
+ const nonCjkText = text.replace(
3484
+ /[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu,
3485
+ ' ',
3486
+ );
3487
+ let segmented = false;
3488
+
3543
3489
  if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
3544
3490
  try {
3545
- const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
3546
- for (const part of segmenter.segment(text)) {
3491
+ const segmenter = new Intl.Segmenter(SEGMENTER_LOCALE, { granularity: 'word' });
3492
+ for (const part of segmenter.segment(nonCjkText)) {
3547
3493
  if (part.isWordLike && isUsefulToken(part.segment)) {
3548
3494
  tokens.push(part.segment);
3549
3495
  }
3550
3496
  }
3497
+ segmented = true;
3551
3498
  } catch {
3552
3499
  // Fall through to regex tokenization.
3553
3500
  }
3554
3501
  }
3555
3502
 
3556
- for (const match of text.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
3557
- if (isUsefulToken(match[0])) {
3558
- tokens.push(match[0]);
3503
+ if (!segmented) {
3504
+ for (const match of nonCjkText.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
3505
+ if (isUsefulToken(match[0])) {
3506
+ tokens.push(match[0]);
3507
+ }
3559
3508
  }
3560
3509
  }
3561
3510
 
3562
- for (const match of text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu)) {
3563
- tokens.push(...buildNgrams(match[0], 2));
3511
+ for (const run of cjkRuns) {
3512
+ tokens.push(...buildCjkTokens(run));
3564
3513
  }
3565
3514
 
3566
3515
  return tokens;
3567
3516
  }
3568
3517
 
3569
- function buildNgrams(value, size) {
3570
- const normalized = Array.from(value);
3571
- if (normalized.length <= size) {
3572
- return isUsefulToken(value) ? [value] : [];
3518
+ function buildCjkTokens(value) {
3519
+ const characters = Array.from(value);
3520
+ const tokens = new Set();
3521
+ if (isUsefulToken(value)) {
3522
+ tokens.add(value);
3573
3523
  }
3574
3524
 
3575
- const tokens = [];
3576
- for (let index = 0; index <= normalized.length - size; index += 1) {
3577
- tokens.push(normalized.slice(index, index + size).join(''));
3525
+ if (characters.length > 2) {
3526
+ for (let index = 0; index <= characters.length - 2; index += 1) {
3527
+ tokens.add(characters.slice(index, index + 2).join(''));
3528
+ }
3578
3529
  }
3579
- tokens.push(value);
3580
- return tokens;
3530
+
3531
+ return Array.from(tokens);
3581
3532
  }
3582
3533
 
3583
3534
  function isUsefulToken(value) {
@@ -3683,7 +3634,7 @@ function normalizeLimit(value) {
3683
3634
  `;
3684
3635
  }
3685
3636
 
3686
- function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
3637
+ function buildSitemapXml(site, emitted, stylesheetHref = '') {
3687
3638
  const entries = [
3688
3639
  ...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
3689
3640
  ? [{
@@ -3732,7 +3683,7 @@ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
3732
3683
  return `<?xml version="1.0" encoding="UTF-8"?>${stylesheet}\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
3733
3684
  }
3734
3685
 
3735
- function buildFeedXml(site, emitted, generatedAt) {
3686
+ function buildFeedXml(site, emitted, feedGeneratedAt) {
3736
3687
  const channelLink = resolveSiteUrl(site.url, '/');
3737
3688
  const selfLink = resolveSiteUrl(site.url, '/feed.xml');
3738
3689
  const items = [...emitted.posts]
@@ -3744,7 +3695,7 @@ function buildFeedXml(site, emitted, generatedAt) {
3744
3695
  })
3745
3696
  .join('\n');
3746
3697
 
3747
- return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${generatedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
3698
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${feedGeneratedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
3748
3699
  }
3749
3700
 
3750
3701
  function buildRobotsTxt(site) {
@@ -3779,6 +3730,7 @@ function getContentType(assetPath) {
3779
3730
  html: 'text/html',
3780
3731
  css: 'text/css',
3781
3732
  js: 'application/javascript',
3733
+ mjs: 'application/javascript',
3782
3734
  json: 'application/json',
3783
3735
  xml: 'application/xml',
3784
3736
  txt: 'text/plain',
@@ -3830,7 +3782,33 @@ function hasCanonicalSiteUrl(siteUrl) {
3830
3782
  }
3831
3783
 
3832
3784
  function toDate(value) {
3833
- return value ? new Date(value) : new Date();
3785
+ if (value instanceof Date) {
3786
+ const timestamp = value.getTime();
3787
+ if (!Number.isFinite(timestamp)) {
3788
+ throw new Error('Invalid RFC 3339 timestamp');
3789
+ }
3790
+ return new Date(timestamp);
3791
+ }
3792
+
3793
+ if (typeof value !== 'string' || !value) {
3794
+ throw new Error(`Invalid RFC 3339 timestamp: ${String(value)}`);
3795
+ }
3796
+
3797
+ const timestampMatch = value.match(RFC3339_TIMESTAMP_PATTERN);
3798
+ if (!timestampMatch) {
3799
+ throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
3800
+ }
3801
+
3802
+ const isLeapSecond = timestampMatch[2] === '60';
3803
+ const parseValue = isLeapSecond
3804
+ ? `${timestampMatch[1]}59${timestampMatch[3] || ''}${timestampMatch[4]}`
3805
+ : value;
3806
+ const timestamp = Date.parse(parseValue);
3807
+ if (!Number.isFinite(timestamp)) {
3808
+ throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
3809
+ }
3810
+
3811
+ return new Date(timestamp + (isLeapSecond ? 1000 : 0));
3834
3812
  }
3835
3813
 
3836
3814
  function formatUtcIsoSeconds(value) {
@@ -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);
@@ -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()
@@ -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
+ }
@@ -1,15 +1,51 @@
1
- import { validateThemeFiles } from '@zeropress/theme-validator';
1
+ import {
2
+ validateThemeFiles,
3
+ validateThemePackageLimits,
4
+ } from '@zeropress/theme-validator';
5
+ import { formatThemeValidationFailure } from './format-theme-validation.js';
2
6
 
3
7
  const TEXT_FILE_EXTENSIONS = new Set(['.html', '.json', '.css', '.js', '.txt', '.svg', '.xml']);
4
8
 
5
9
  export async function loadThemePackageFromDir(themeDir) {
6
10
  const fs = await import('node:fs/promises');
11
+ const { constants: fsConstants } = await import('node:fs');
7
12
  const path = await import('node:path');
13
+ if (typeof themeDir !== 'string' || !themeDir.trim()) {
14
+ throw new Error('Theme directory must be a non-empty path');
15
+ }
16
+ const requestedThemeDir = path.resolve(themeDir);
8
17
 
9
- const fileMap = new Map();
10
- await readThemeDir(fs, path, themeDir, themeDir, fileMap);
18
+ const rootStat = await fs.lstat(requestedThemeDir);
19
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
20
+ throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
21
+ }
22
+ const canonicalThemeDir = await fs.realpath(requestedThemeDir);
23
+ const confirmedRootStat = await fs.lstat(requestedThemeDir);
24
+ if (confirmedRootStat.isSymbolicLink() || !confirmedRootStat.isDirectory()) {
25
+ throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
26
+ }
11
27
 
12
- const validation = await validateThemeFiles(fileMap);
28
+ const fileMap = new Map();
29
+ const packageState = {
30
+ entryCount: 0,
31
+ fileSizes: new Map(),
32
+ pathEntries: [],
33
+ seenCanonicalPaths: new Map(),
34
+ };
35
+ await readThemeDir(
36
+ fs,
37
+ fsConstants,
38
+ path,
39
+ canonicalThemeDir,
40
+ canonicalThemeDir,
41
+ fileMap,
42
+ packageState,
43
+ );
44
+
45
+ const validation = await validateThemeFiles(fileMap, {
46
+ entryCount: packageState.entryCount,
47
+ pathEntries: packageState.pathEntries,
48
+ });
13
49
  if (!validation.ok) {
14
50
  throw new Error(formatThemeValidationFailure(validation));
15
51
  }
@@ -58,82 +94,112 @@ export async function loadThemePackageFromDir(themeDir) {
58
94
  };
59
95
  }
60
96
 
61
- function formatThemeValidationFailure(validation) {
62
- const blocks = [
63
- [
64
- 'Theme validation failed',
65
- `Errors: ${validation.errors.length}`,
66
- `Checked files: ${validation.checkedFiles}`,
67
- ].join('\n'),
68
- ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
69
- ];
70
- return blocks.join('\n\n');
71
- }
72
-
73
- function formatThemeValidationIssue(issue) {
74
- if (!issue) {
75
- return 'Reason: Unknown error';
76
- }
77
-
78
- const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
79
- const location = splitIssuePath(issue.path);
80
- if (location.file) {
81
- lines.push(`File: ${location.file}`);
82
- }
83
- if (location.path) {
84
- lines.push(`Path: ${location.path}`);
85
- }
86
- if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
87
- lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
88
- }
89
- if (issue.category) {
90
- lines.push(`Category: ${issue.category}`);
91
- }
92
- lines.push(`Reason: ${issue.message || 'Unknown error'}`);
93
- if (issue.snippet) {
94
- const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
95
- lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
96
- }
97
- if (issue.hint) {
98
- lines.push('', 'Hint:', issue.hint);
99
- }
100
-
101
- return lines.join('\n');
102
- }
103
-
104
- function splitIssuePath(issuePath) {
105
- const normalizedPath = String(issuePath || '');
106
- if (normalizedPath.startsWith('theme.json.')) {
107
- return {
108
- file: 'theme.json',
109
- path: normalizedPath.slice('theme.json.'.length),
110
- };
97
+ async function readThemeDir(fs, fsConstants, path, rootDir, currentDir, fileMap, packageState) {
98
+ const entries = [];
99
+ const directory = await fs.opendir(currentDir);
100
+ for await (const entry of directory) {
101
+ packageState.entryCount += 1;
102
+ assertThemePackageLimits(packageState);
103
+ entries.push(entry);
111
104
  }
112
-
113
- return { file: normalizedPath, path: '' };
114
- }
115
-
116
- async function readThemeDir(fs, path, rootDir, currentDir, fileMap) {
117
- const entries = await fs.readdir(currentDir, { withFileTypes: true });
105
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
118
106
 
119
107
  for (const entry of entries) {
120
108
  const fullPath = path.join(currentDir, entry.name);
121
- const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, '/');
109
+ const rawRelativePath = path.relative(rootDir, fullPath);
110
+ const relativePath = rawRelativePath.replace(/\\/g, '/');
111
+ if (entry.name.includes('\\')) {
112
+ throw createThemePathError(
113
+ 'PATH_ESCAPE',
114
+ rawRelativePath,
115
+ `Backslashes are not allowed in theme package paths: ${rawRelativePath}`,
116
+ packageState.fileSizes.size,
117
+ );
118
+ }
119
+ const collisionKey = relativePath.normalize('NFC').toLowerCase();
120
+ const existingPath = packageState.seenCanonicalPaths.get(collisionKey);
121
+ if (existingPath !== undefined) {
122
+ throw createThemePathError(
123
+ 'THEME_PATH_COLLISION',
124
+ relativePath,
125
+ `Theme package path collision: '${relativePath}' conflicts with '${existingPath}' after NFC and case normalization`,
126
+ packageState.fileSizes.size,
127
+ );
128
+ }
129
+ packageState.seenCanonicalPaths.set(collisionKey, relativePath);
130
+ const stat = await fs.lstat(fullPath);
131
+ const isSymlink = stat.isSymbolicLink();
132
+ packageState.pathEntries.push({ path: relativePath, isSymlink });
133
+
134
+ if (isSymlink) {
135
+ continue;
136
+ }
122
137
 
123
- if (entry.isDirectory()) {
124
- await readThemeDir(fs, path, rootDir, fullPath, fileMap);
138
+ if (stat.isDirectory()) {
139
+ await readThemeDir(fs, fsConstants, path, rootDir, fullPath, fileMap, packageState);
125
140
  continue;
126
141
  }
127
142
 
128
- const ext = path.extname(entry.name).toLowerCase();
129
- if (TEXT_FILE_EXTENSIONS.has(ext)) {
130
- fileMap.set(relativePath, await fs.readFile(fullPath, 'utf8'));
131
- } else {
132
- fileMap.set(relativePath, new Uint8Array(await fs.readFile(fullPath)));
143
+ if (!stat.isFile()) {
144
+ continue;
145
+ }
146
+
147
+ const noFollow = fsConstants.O_NOFOLLOW || 0;
148
+ let handle;
149
+ try {
150
+ handle = await fs.open(fullPath, fsConstants.O_RDONLY | noFollow);
151
+ const openedStat = await handle.stat();
152
+ if (!openedStat.isFile()) {
153
+ continue;
154
+ }
155
+ packageState.fileSizes.set(relativePath, openedStat.size);
156
+ assertThemePackageLimits(packageState);
157
+
158
+ const ext = path.extname(entry.name).toLowerCase();
159
+ if (TEXT_FILE_EXTENSIONS.has(ext)) {
160
+ fileMap.set(relativePath, await handle.readFile('utf8'));
161
+ } else {
162
+ fileMap.set(relativePath, new Uint8Array(await handle.readFile()));
163
+ }
164
+ } catch (error) {
165
+ if (error?.code === 'ELOOP') {
166
+ packageState.pathEntries[packageState.pathEntries.length - 1].isSymlink = true;
167
+ continue;
168
+ }
169
+ throw error;
170
+ } finally {
171
+ await handle?.close();
133
172
  }
134
173
  }
135
174
  }
136
175
 
176
+ function createThemePathError(code, filePath, message, checkedFiles) {
177
+ return new Error(formatThemeValidationFailure({
178
+ errors: [{
179
+ code,
180
+ path: filePath,
181
+ message,
182
+ severity: 'error',
183
+ category: 'theme_package_paths',
184
+ }],
185
+ checkedFiles,
186
+ }));
187
+ }
188
+
189
+ function assertThemePackageLimits(packageState) {
190
+ const errors = validateThemePackageLimits(packageState.fileSizes, {
191
+ entryCount: packageState.entryCount,
192
+ });
193
+ if (errors.length === 0) {
194
+ return;
195
+ }
196
+
197
+ throw new Error(formatThemeValidationFailure({
198
+ errors,
199
+ checkedFiles: packageState.fileSizes.size,
200
+ }));
201
+ }
202
+
137
203
  function toUint8Array(value) {
138
204
  if (value instanceof Uint8Array) {
139
205
  return value;