@zeropress/build-core 0.7.0 → 0.7.3

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 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
+
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
@@ -178,8 +182,22 @@ Build-core derives:
178
182
  - localized fallback `published_at` / `updated_at` values plus unconditional `published_at_iso` / `updated_at_iso` values
179
183
  - datetime formatting from `site.locale`, `site.timezone`, `site.date_style`, and `site.time_style`
180
184
  - `reading_time`
185
+ - effective `post.summary` and `page.summary` values for theme listings and metadata
181
186
  - a route-root `comments` discriminated context
182
187
 
188
+ ### Authored excerpts and runtime summaries
189
+
190
+ 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:
191
+
192
+ - a trim-nonempty authored excerpt becomes the summary after trimming only its outer whitespace, without a length limit
193
+ - 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
194
+ - a derived body summary is at most 160 Unicode code points, including a final `…` when truncated
195
+ - a document with no displayable text has `summary: ""`
196
+
197
+ 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.
198
+
199
+ 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.
200
+
183
201
  Every ordinary rendered route receives effective feature state on `site`, regardless of whether the corresponding Preview Data preference was omitted:
184
202
 
185
203
  ```js
@@ -250,6 +268,10 @@ The active state requires all of the following:
250
268
  - the post or page has `allow_comments: true` and a positive `public_id`
251
269
  - the ZeroPress provider has a non-empty item `comments.request_token`
252
270
 
271
+ Post and Page `allow_comments` are optional Preview Data fields. Omission is
272
+ normalized to `false`; producers should emit `true` only for items that opt in
273
+ to comments.
274
+
253
275
  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
276
 
255
277
  The canonical `preview-data v0.7` site contract uses:
@@ -304,7 +326,7 @@ For ordinary theme-rendered HTML routes, site customization order is determinist
304
326
 
305
327
  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
328
 
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.
329
+ 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
330
 
309
331
  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
332
 
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.3",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,14 +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
36
  "@zeropress/slug-policy": "0.2.1",
37
- "@zeropress/preview-data-validator": "0.7.0",
38
- "@zeropress/theme-validator": "0.7.0",
39
- "highlight.js": "11.11.1",
40
- "markdown-it": "14.3.0",
41
- "markdown-it-anchor": "9.2.1"
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.0",
41
+ "markdown-it-anchor": "9.2.1",
42
+ "sanitize-html": "2.17.7"
42
43
  }
43
44
  }
@@ -2,8 +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';
8
+ import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
7
9
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
8
10
 
9
11
  const DEFAULT_OPTIONS = {
@@ -51,6 +53,8 @@ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:']);
51
53
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
52
54
  const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
53
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;
54
58
  const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
55
59
  const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
56
60
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
@@ -64,6 +68,7 @@ const SEARCH_FIELD_WEIGHTS = Object.freeze({
64
68
  content_text: 1,
65
69
  });
66
70
  const SEARCH_RECENCY_BOOST_MAX = 0.15;
71
+ 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
72
  export async function buildSite(input) {
68
73
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
69
74
  const state = await createBuildState(input, options);
@@ -109,7 +114,7 @@ export async function buildSite(input) {
109
114
 
110
115
  if (shouldGenerateSearchArtifacts(state)) {
111
116
  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');
117
+ await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(state.previewData.site.locale), 'application/javascript');
113
118
  await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
114
119
  }
115
120
 
@@ -119,11 +124,11 @@ export async function buildSite(input) {
119
124
  state.writer,
120
125
  state.summaries,
121
126
  'sitemap.xml',
122
- buildSitemapXml(state.previewData.site, state.emitted, state.generatedAt, options.sitemapStylesheetHref),
127
+ buildSitemapXml(state.previewData.site, state.emitted, options.sitemapStylesheetHref),
123
128
  'application/xml',
124
129
  );
125
130
  if (shouldGenerateFeed(state)) {
126
- await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
131
+ await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.feedGeneratedAt), 'application/rss+xml');
127
132
  }
128
133
  }
129
134
  if (shouldGenerateRobotsTxt(options)) {
@@ -151,8 +156,8 @@ async function createBuildState(input, options) {
151
156
 
152
157
  engine.initialize(themePackage);
153
158
 
154
- const assetOutputs = await buildAssetOutputs(themePackage.assets, assetProcessor, options);
155
- const customCssAsset = await buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
159
+ const assetOutputs = buildAssetOutputs(themePackage.assets, assetProcessor, options);
160
+ const customCssAsset = buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
156
161
  if (customCssAsset) {
157
162
  assetOutputs.push(customCssAsset);
158
163
  }
@@ -175,7 +180,7 @@ async function createBuildState(input, options) {
175
180
  favicon: previewData.site.favicon,
176
181
  exposeGenerator: previewData.site.expose_generator !== false,
177
182
  options,
178
- generatedAt: new Date(),
183
+ feedGeneratedAt: toDate(previewData.generated_at),
179
184
  emitted: {
180
185
  frontPage: null,
181
186
  indexRoutes: [],
@@ -259,6 +264,7 @@ async function renderFrontPage(state, route) {
259
264
  ...route.page,
260
265
  url: currentUrl,
261
266
  };
267
+ const description = page.summary || state.previewData.site.description;
262
268
  let html = await state.engine.render(
263
269
  'page',
264
270
  {
@@ -271,7 +277,7 @@ async function renderFrontPage(state, route) {
271
277
  meta: buildPageMeta(state.previewData.site, {
272
278
  currentUrl,
273
279
  title: buildFrontPageTitle(state.previewData.site),
274
- description: page.excerpt,
280
+ description,
275
281
  ogType: 'website',
276
282
  image: page.featured_image,
277
283
  robotsNoindex: shouldNoindexDocument(page),
@@ -292,7 +298,7 @@ async function renderFrontPage(state, route) {
292
298
  state.emitted.frontPage = {
293
299
  url: currentUrl,
294
300
  title: page.title,
295
- description: page.excerpt,
301
+ description,
296
302
  updatedAt: page.updated_at_iso,
297
303
  includeInFeed: false,
298
304
  includeInSitemap: !isDelistedDocument(page),
@@ -347,7 +353,7 @@ async function renderPost(state, post) {
347
353
  meta: buildPageMeta(state.previewData.site, {
348
354
  currentUrl,
349
355
  title: buildDocumentTitle(post.title, state.previewData.site.title),
350
- description: post.excerpt,
356
+ description: post.summary,
351
357
  ogType: 'article',
352
358
  image: post.featured_image,
353
359
  publishedTime: post.published_at_iso,
@@ -371,7 +377,7 @@ async function renderPost(state, post) {
371
377
  state.emitted.posts.push({
372
378
  url: currentUrl,
373
379
  title: post.title,
374
- description: post.excerpt,
380
+ description: post.summary,
375
381
  publishedAt: post.published_at_iso,
376
382
  updatedAt: post.updated_at_iso,
377
383
  status: post.status,
@@ -396,7 +402,7 @@ async function renderPage(state, page) {
396
402
  currentUrl,
397
403
  canonicalUrl,
398
404
  title: buildDocumentTitle(page.title, state.previewData.site.title),
399
- description: page.excerpt,
405
+ description: page.summary,
400
406
  ogType: 'website',
401
407
  image: page.featured_image,
402
408
  robotsNoindex: shouldNoindexDocument(page),
@@ -417,7 +423,7 @@ async function renderPage(state, page) {
417
423
  state.emitted.pages.push({
418
424
  url: currentUrl,
419
425
  title: page.title,
420
- description: page.excerpt,
426
+ description: page.summary,
421
427
  updatedAt: page.updated_at_iso,
422
428
  status: page.status,
423
429
  includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
@@ -775,8 +781,8 @@ function normalizeWidgetItem(item, media_origin) {
775
781
  }
776
782
 
777
783
  function normalizeCustomCss(customCss) {
778
- const content = normalizeOptionalString(customCss?.content);
779
- return content ? { content } : undefined;
784
+ const content = customCss?.content;
785
+ return typeof content === 'string' && content.trim() ? { content } : undefined;
780
786
  }
781
787
 
782
788
  function normalizeCustomHtml(customHtml) {
@@ -1289,6 +1295,7 @@ function buildCollectionPageSummary(page, frontPage, pageReferencePath) {
1289
1295
  slug: page.slug,
1290
1296
  url: frontPage?.type === 'page' && frontPage.page_path === pageReferencePath ? '/' : page.url,
1291
1297
  excerpt: page.excerpt || '',
1298
+ summary: page.summary || '',
1292
1299
  featured_image: page.featured_image || '',
1293
1300
  updated_at: page.updated_at || '',
1294
1301
  updated_at_iso: page.updated_at_iso || '',
@@ -1346,6 +1353,7 @@ function buildCollectionCursorItemSummary(item) {
1346
1353
  slug: item.slug,
1347
1354
  url: item.url,
1348
1355
  excerpt: item.excerpt || '',
1356
+ summary: item.summary || '',
1349
1357
  featured_image: item.featured_image || '',
1350
1358
  updated_at: item.updated_at || '',
1351
1359
  updated_at_iso: item.updated_at_iso || '',
@@ -1679,12 +1687,15 @@ function preparePage(page, site) {
1679
1687
  const renderedDocument = renderDocument(page.content, documentType);
1680
1688
  const permalink = resolvePagePermalink(site, page);
1681
1689
  const pageFields = { ...page };
1690
+ const excerpt = typeof page.excerpt === 'string' ? page.excerpt : '';
1682
1691
  delete pageFields.comments;
1683
1692
 
1684
1693
  return {
1685
1694
  ...pageFields,
1686
1695
  url: permalink.url,
1687
1696
  document_type: documentType,
1697
+ excerpt,
1698
+ summary: buildDocumentSummary(excerpt, renderedDocument.html, page.title),
1688
1699
  html: renderedDocument.html,
1689
1700
  toc: renderedDocument.toc,
1690
1701
  updated_at: page.updated_at_iso ? formatTimestamp(page.updated_at_iso, site) : '',
@@ -1694,6 +1705,7 @@ function preparePage(page, site) {
1694
1705
  function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1695
1706
  const documentType = normalizeDocumentType(post.document_type);
1696
1707
  const renderedDocument = renderDocument(post.content, documentType);
1708
+ const excerpt = typeof post.excerpt === 'string' ? post.excerpt : '';
1697
1709
  const author = authorsById.get(post.author_id);
1698
1710
  const permalink = resolvePermalink(site, 'posts', post);
1699
1711
  const categories = post.category_slugs
@@ -1720,7 +1732,8 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1720
1732
  url: permalink.url,
1721
1733
  content: post.content,
1722
1734
  document_type: documentType,
1723
- excerpt: post.excerpt,
1735
+ excerpt,
1736
+ summary: buildDocumentSummary(excerpt, renderedDocument.html, post.title),
1724
1737
  published_at_iso: post.published_at_iso,
1725
1738
  updated_at_iso: post.updated_at_iso,
1726
1739
  author_id: post.author_id,
@@ -1962,6 +1975,7 @@ function buildStructuredPostSummary(post) {
1962
1975
  slug: post.slug,
1963
1976
  url: post.url,
1964
1977
  excerpt: post.excerpt,
1978
+ summary: post.summary,
1965
1979
  published_at: post.published_at,
1966
1980
  published_at_iso: post.published_at_iso,
1967
1981
  reading_time: post.reading_time,
@@ -1989,6 +2003,7 @@ function buildAdjacentPostSummary(post) {
1989
2003
  slug: post.slug,
1990
2004
  url: post.url,
1991
2005
  excerpt: post.excerpt,
2006
+ summary: post.summary,
1992
2007
  published_at: post.published_at,
1993
2008
  published_at_iso: post.published_at_iso,
1994
2009
  data: post.data,
@@ -2248,61 +2263,6 @@ async function normalizeAndValidateThemePackage(themePackage) {
2248
2263
  };
2249
2264
  }
2250
2265
 
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
2266
  function normalizeThemePackageMetadata(sourceMetadata, manifest) {
2307
2267
  return {
2308
2268
  ...manifest,
@@ -2310,50 +2270,37 @@ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
2310
2270
  };
2311
2271
  }
2312
2272
 
2313
- async function buildAssetOutputs(assets, assetProcessor, options) {
2273
+ function buildAssetOutputs(assets, assetProcessor, options) {
2314
2274
  const outputs = [];
2315
2275
 
2316
2276
  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
2277
  const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(content)}` : '';
2330
2278
  const targetPath = `assets/${assetPath.replace(/(\.[^.]+)$/, `${hash}$1`)}`;
2331
2279
 
2332
2280
  outputs.push({
2333
2281
  originalPath: assetPath,
2334
2282
  path: targetPath,
2335
- content: processedContent,
2336
- contentType,
2283
+ content,
2284
+ contentType: getContentType(assetPath),
2337
2285
  });
2338
2286
  }
2339
2287
 
2340
2288
  return outputs;
2341
2289
  }
2342
2290
 
2343
- async function buildCustomCssAsset(customCss, assetProcessor, options) {
2344
- const content = normalizeOptionalString(customCss?.content);
2345
- if (!content) {
2291
+ function buildCustomCssAsset(customCss, assetProcessor, options) {
2292
+ const content = customCss?.content;
2293
+ if (typeof content !== 'string' || !content.trim()) {
2346
2294
  return null;
2347
2295
  }
2348
2296
 
2349
2297
  const sourceBuffer = new TextEncoder().encode(content);
2350
- const processedContent = await assetProcessor.processCSS(content);
2351
2298
  const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
2352
2299
 
2353
2300
  return {
2354
2301
  originalPath: '__zeropress_custom.css',
2355
2302
  path: `assets/zeropress-custom${hash}.css`,
2356
- content: processedContent,
2303
+ content,
2357
2304
  contentType: 'text/css',
2358
2305
  };
2359
2306
  }
@@ -3253,6 +3200,63 @@ function buildSearchPageItem(page, url, pageReferencePath) {
3253
3200
  };
3254
3201
  }
3255
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
+
3256
3260
  function buildSearchHeadings(toc) {
3257
3261
  return Array.isArray(toc)
3258
3262
  ? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
@@ -3296,11 +3300,13 @@ function normalizeSearchText(value) {
3296
3300
  return String(value || '').replace(/\s+/g, ' ').trim();
3297
3301
  }
3298
3302
 
3299
- function buildSearchAdapterJs() {
3303
+ function buildSearchAdapterJs(locale) {
3300
3304
  const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
3305
+ const localeJson = JSON.stringify(locale);
3301
3306
  return `const FIELD_WEIGHTS = ${fieldWeightsJson};
3302
3307
  const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
3303
3308
  const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
3309
+ const SEGMENTER_LOCALE = ${localeJson};
3304
3310
  const DEFAULT_LIMIT = 20;
3305
3311
  const BM25_K1 = 1.2;
3306
3312
  const BM25_B = 0.75;
@@ -3540,44 +3546,59 @@ function tokenize(value) {
3540
3546
  }
3541
3547
 
3542
3548
  const tokens = [];
3549
+ const cjkRuns = Array.from(
3550
+ text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu),
3551
+ (match) => match[0],
3552
+ );
3553
+ const nonCjkText = text.replace(
3554
+ /[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu,
3555
+ ' ',
3556
+ );
3557
+ let segmented = false;
3558
+
3543
3559
  if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
3544
3560
  try {
3545
- const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
3546
- for (const part of segmenter.segment(text)) {
3561
+ const segmenter = new Intl.Segmenter(SEGMENTER_LOCALE, { granularity: 'word' });
3562
+ for (const part of segmenter.segment(nonCjkText)) {
3547
3563
  if (part.isWordLike && isUsefulToken(part.segment)) {
3548
3564
  tokens.push(part.segment);
3549
3565
  }
3550
3566
  }
3567
+ segmented = true;
3551
3568
  } catch {
3552
3569
  // Fall through to regex tokenization.
3553
3570
  }
3554
3571
  }
3555
3572
 
3556
- for (const match of text.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
3557
- if (isUsefulToken(match[0])) {
3558
- tokens.push(match[0]);
3573
+ if (!segmented) {
3574
+ for (const match of nonCjkText.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
3575
+ if (isUsefulToken(match[0])) {
3576
+ tokens.push(match[0]);
3577
+ }
3559
3578
  }
3560
3579
  }
3561
3580
 
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));
3581
+ for (const run of cjkRuns) {
3582
+ tokens.push(...buildCjkTokens(run));
3564
3583
  }
3565
3584
 
3566
3585
  return tokens;
3567
3586
  }
3568
3587
 
3569
- function buildNgrams(value, size) {
3570
- const normalized = Array.from(value);
3571
- if (normalized.length <= size) {
3572
- return isUsefulToken(value) ? [value] : [];
3588
+ function buildCjkTokens(value) {
3589
+ const characters = Array.from(value);
3590
+ const tokens = new Set();
3591
+ if (isUsefulToken(value)) {
3592
+ tokens.add(value);
3573
3593
  }
3574
3594
 
3575
- const tokens = [];
3576
- for (let index = 0; index <= normalized.length - size; index += 1) {
3577
- tokens.push(normalized.slice(index, index + size).join(''));
3595
+ if (characters.length > 2) {
3596
+ for (let index = 0; index <= characters.length - 2; index += 1) {
3597
+ tokens.add(characters.slice(index, index + 2).join(''));
3598
+ }
3578
3599
  }
3579
- tokens.push(value);
3580
- return tokens;
3600
+
3601
+ return Array.from(tokens);
3581
3602
  }
3582
3603
 
3583
3604
  function isUsefulToken(value) {
@@ -3683,7 +3704,7 @@ function normalizeLimit(value) {
3683
3704
  `;
3684
3705
  }
3685
3706
 
3686
- function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
3707
+ function buildSitemapXml(site, emitted, stylesheetHref = '') {
3687
3708
  const entries = [
3688
3709
  ...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
3689
3710
  ? [{
@@ -3732,7 +3753,7 @@ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
3732
3753
  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
3754
  }
3734
3755
 
3735
- function buildFeedXml(site, emitted, generatedAt) {
3756
+ function buildFeedXml(site, emitted, feedGeneratedAt) {
3736
3757
  const channelLink = resolveSiteUrl(site.url, '/');
3737
3758
  const selfLink = resolveSiteUrl(site.url, '/feed.xml');
3738
3759
  const items = [...emitted.posts]
@@ -3744,7 +3765,7 @@ function buildFeedXml(site, emitted, generatedAt) {
3744
3765
  })
3745
3766
  .join('\n');
3746
3767
 
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>`;
3768
+ 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
3769
  }
3749
3770
 
3750
3771
  function buildRobotsTxt(site) {
@@ -3779,6 +3800,7 @@ function getContentType(assetPath) {
3779
3800
  html: 'text/html',
3780
3801
  css: 'text/css',
3781
3802
  js: 'application/javascript',
3803
+ mjs: 'application/javascript',
3782
3804
  json: 'application/json',
3783
3805
  xml: 'application/xml',
3784
3806
  txt: 'text/plain',
@@ -3830,7 +3852,33 @@ function hasCanonicalSiteUrl(siteUrl) {
3830
3852
  }
3831
3853
 
3832
3854
  function toDate(value) {
3833
- return value ? new Date(value) : new Date();
3855
+ if (value instanceof Date) {
3856
+ const timestamp = value.getTime();
3857
+ if (!Number.isFinite(timestamp)) {
3858
+ throw new Error('Invalid RFC 3339 timestamp');
3859
+ }
3860
+ return new Date(timestamp);
3861
+ }
3862
+
3863
+ if (typeof value !== 'string' || !value) {
3864
+ throw new Error(`Invalid RFC 3339 timestamp: ${String(value)}`);
3865
+ }
3866
+
3867
+ const timestampMatch = value.match(RFC3339_TIMESTAMP_PATTERN);
3868
+ if (!timestampMatch) {
3869
+ throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
3870
+ }
3871
+
3872
+ const isLeapSecond = timestampMatch[2] === '60';
3873
+ const parseValue = isLeapSecond
3874
+ ? `${timestampMatch[1]}59${timestampMatch[3] || ''}${timestampMatch[4]}`
3875
+ : value;
3876
+ const timestamp = Date.parse(parseValue);
3877
+ if (!Number.isFinite(timestamp)) {
3878
+ throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
3879
+ }
3880
+
3881
+ return new Date(timestamp + (isLeapSecond ? 1000 : 0));
3834
3882
  }
3835
3883
 
3836
3884
  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', 'template', 'noscript', '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;