@zeropress/build-core 0.7.1 → 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
@@ -165,7 +165,7 @@ 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
- 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.
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
169
 
170
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.
171
171
 
@@ -182,8 +182,22 @@ Build-core derives:
182
182
  - localized fallback `published_at` / `updated_at` values plus unconditional `published_at_iso` / `updated_at_iso` values
183
183
  - datetime formatting from `site.locale`, `site.timezone`, `site.date_style`, and `site.time_style`
184
184
  - `reading_time`
185
+ - effective `post.summary` and `page.summary` values for theme listings and metadata
185
186
  - a route-root `comments` discriminated context
186
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
+
187
201
  Every ordinary rendered route receives effective feature state on `site`, regardless of whether the corresponding Preview Data preference was omitted:
188
202
 
189
203
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,15 +30,15 @@
30
30
  "test": "node --test"
31
31
  },
32
32
  "engines": {
33
- "node": ">=22.12.0"
33
+ "node": ">=22.22.0"
34
34
  },
35
35
  "dependencies": {
36
36
  "@zeropress/slug-policy": "0.2.1",
37
- "@zeropress/preview-data-validator": "0.7.1",
38
- "@zeropress/theme-validator": "0.7.1",
39
- "highlight.js": "11.11.1",
40
- "markdown-it": "14.3.0",
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
41
  "markdown-it-anchor": "9.2.1",
42
- "sanitize-html": "2.17.6"
42
+ "sanitize-html": "2.17.7"
43
43
  }
44
44
  }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { assertPreviewData } from '@zeropress/preview-data-validator';
3
3
  import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
4
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
5
+ import sanitizeHtml from 'sanitize-html';
5
6
  import { AssetProcessor } from '../assets/asset-processor.js';
6
7
  import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
7
8
  import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
@@ -52,6 +53,8 @@ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:']);
52
53
  const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
53
54
  const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
54
55
  const CUSTOM_HTML_SLOT_MAX_CODE_POINTS = 65_536;
56
+ const GENERATED_SUMMARY_MAX_CODE_POINTS = 160;
57
+ const SUMMARY_TEXT_BOUNDARY_TAG_PATTERN = /<\/?(?:h[1-6]|p|br|hr|pre|ul|ol|li|blockquote|aside|figure|figcaption|table|thead|tbody|tfoot|tr|th|td|div|nav)\b[^>]*>/gi;
55
58
  const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
56
59
  const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
57
60
  const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
@@ -261,6 +264,7 @@ async function renderFrontPage(state, route) {
261
264
  ...route.page,
262
265
  url: currentUrl,
263
266
  };
267
+ const description = page.summary || state.previewData.site.description;
264
268
  let html = await state.engine.render(
265
269
  'page',
266
270
  {
@@ -273,7 +277,7 @@ async function renderFrontPage(state, route) {
273
277
  meta: buildPageMeta(state.previewData.site, {
274
278
  currentUrl,
275
279
  title: buildFrontPageTitle(state.previewData.site),
276
- description: page.excerpt,
280
+ description,
277
281
  ogType: 'website',
278
282
  image: page.featured_image,
279
283
  robotsNoindex: shouldNoindexDocument(page),
@@ -294,7 +298,7 @@ async function renderFrontPage(state, route) {
294
298
  state.emitted.frontPage = {
295
299
  url: currentUrl,
296
300
  title: page.title,
297
- description: page.excerpt,
301
+ description,
298
302
  updatedAt: page.updated_at_iso,
299
303
  includeInFeed: false,
300
304
  includeInSitemap: !isDelistedDocument(page),
@@ -349,7 +353,7 @@ async function renderPost(state, post) {
349
353
  meta: buildPageMeta(state.previewData.site, {
350
354
  currentUrl,
351
355
  title: buildDocumentTitle(post.title, state.previewData.site.title),
352
- description: post.excerpt,
356
+ description: post.summary,
353
357
  ogType: 'article',
354
358
  image: post.featured_image,
355
359
  publishedTime: post.published_at_iso,
@@ -373,7 +377,7 @@ async function renderPost(state, post) {
373
377
  state.emitted.posts.push({
374
378
  url: currentUrl,
375
379
  title: post.title,
376
- description: post.excerpt,
380
+ description: post.summary,
377
381
  publishedAt: post.published_at_iso,
378
382
  updatedAt: post.updated_at_iso,
379
383
  status: post.status,
@@ -398,7 +402,7 @@ async function renderPage(state, page) {
398
402
  currentUrl,
399
403
  canonicalUrl,
400
404
  title: buildDocumentTitle(page.title, state.previewData.site.title),
401
- description: page.excerpt,
405
+ description: page.summary,
402
406
  ogType: 'website',
403
407
  image: page.featured_image,
404
408
  robotsNoindex: shouldNoindexDocument(page),
@@ -419,7 +423,7 @@ async function renderPage(state, page) {
419
423
  state.emitted.pages.push({
420
424
  url: currentUrl,
421
425
  title: page.title,
422
- description: page.excerpt,
426
+ description: page.summary,
423
427
  updatedAt: page.updated_at_iso,
424
428
  status: page.status,
425
429
  includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
@@ -1291,6 +1295,7 @@ function buildCollectionPageSummary(page, frontPage, pageReferencePath) {
1291
1295
  slug: page.slug,
1292
1296
  url: frontPage?.type === 'page' && frontPage.page_path === pageReferencePath ? '/' : page.url,
1293
1297
  excerpt: page.excerpt || '',
1298
+ summary: page.summary || '',
1294
1299
  featured_image: page.featured_image || '',
1295
1300
  updated_at: page.updated_at || '',
1296
1301
  updated_at_iso: page.updated_at_iso || '',
@@ -1348,6 +1353,7 @@ function buildCollectionCursorItemSummary(item) {
1348
1353
  slug: item.slug,
1349
1354
  url: item.url,
1350
1355
  excerpt: item.excerpt || '',
1356
+ summary: item.summary || '',
1351
1357
  featured_image: item.featured_image || '',
1352
1358
  updated_at: item.updated_at || '',
1353
1359
  updated_at_iso: item.updated_at_iso || '',
@@ -1681,12 +1687,15 @@ function preparePage(page, site) {
1681
1687
  const renderedDocument = renderDocument(page.content, documentType);
1682
1688
  const permalink = resolvePagePermalink(site, page);
1683
1689
  const pageFields = { ...page };
1690
+ const excerpt = typeof page.excerpt === 'string' ? page.excerpt : '';
1684
1691
  delete pageFields.comments;
1685
1692
 
1686
1693
  return {
1687
1694
  ...pageFields,
1688
1695
  url: permalink.url,
1689
1696
  document_type: documentType,
1697
+ excerpt,
1698
+ summary: buildDocumentSummary(excerpt, renderedDocument.html, page.title),
1690
1699
  html: renderedDocument.html,
1691
1700
  toc: renderedDocument.toc,
1692
1701
  updated_at: page.updated_at_iso ? formatTimestamp(page.updated_at_iso, site) : '',
@@ -1696,6 +1705,7 @@ function preparePage(page, site) {
1696
1705
  function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1697
1706
  const documentType = normalizeDocumentType(post.document_type);
1698
1707
  const renderedDocument = renderDocument(post.content, documentType);
1708
+ const excerpt = typeof post.excerpt === 'string' ? post.excerpt : '';
1699
1709
  const author = authorsById.get(post.author_id);
1700
1710
  const permalink = resolvePermalink(site, 'posts', post);
1701
1711
  const categories = post.category_slugs
@@ -1722,7 +1732,8 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
1722
1732
  url: permalink.url,
1723
1733
  content: post.content,
1724
1734
  document_type: documentType,
1725
- excerpt: post.excerpt,
1735
+ excerpt,
1736
+ summary: buildDocumentSummary(excerpt, renderedDocument.html, post.title),
1726
1737
  published_at_iso: post.published_at_iso,
1727
1738
  updated_at_iso: post.updated_at_iso,
1728
1739
  author_id: post.author_id,
@@ -1964,6 +1975,7 @@ function buildStructuredPostSummary(post) {
1964
1975
  slug: post.slug,
1965
1976
  url: post.url,
1966
1977
  excerpt: post.excerpt,
1978
+ summary: post.summary,
1967
1979
  published_at: post.published_at,
1968
1980
  published_at_iso: post.published_at_iso,
1969
1981
  reading_time: post.reading_time,
@@ -1991,6 +2003,7 @@ function buildAdjacentPostSummary(post) {
1991
2003
  slug: post.slug,
1992
2004
  url: post.url,
1993
2005
  excerpt: post.excerpt,
2006
+ summary: post.summary,
1994
2007
  published_at: post.published_at,
1995
2008
  published_at_iso: post.published_at_iso,
1996
2009
  data: post.data,
@@ -3187,6 +3200,63 @@ function buildSearchPageItem(page, url, pageReferencePath) {
3187
3200
  };
3188
3201
  }
3189
3202
 
3203
+ function buildDocumentSummary(excerpt, html, title) {
3204
+ const authoredExcerpt = String(excerpt || '').trim();
3205
+ if (authoredExcerpt) {
3206
+ return authoredExcerpt;
3207
+ }
3208
+
3209
+ const visibleText = htmlToSummaryText(html, title);
3210
+ const codePoints = [...visibleText];
3211
+ if (codePoints.length <= GENERATED_SUMMARY_MAX_CODE_POINTS) {
3212
+ return visibleText;
3213
+ }
3214
+
3215
+ const truncated = codePoints
3216
+ .slice(0, GENERATED_SUMMARY_MAX_CODE_POINTS - 1)
3217
+ .join('')
3218
+ .trimEnd();
3219
+ return `${truncated}…`;
3220
+ }
3221
+
3222
+ function htmlToSummaryText(html, title) {
3223
+ let source = removeNonVisibleSummaryHtml(html);
3224
+ const leadingHeading = source.match(/<h1\b[^>]*>([\s\S]*?)<\/h1\s*>/i);
3225
+ if (
3226
+ leadingHeading
3227
+ && htmlFragmentToSummaryText(source.slice(0, leadingHeading.index)) === ''
3228
+ && normalizeComparableSummaryText(htmlFragmentToSummaryText(leadingHeading[1]))
3229
+ === normalizeComparableSummaryText(title)
3230
+ ) {
3231
+ source = source.slice(0, leadingHeading.index)
3232
+ + source.slice(leadingHeading.index + leadingHeading[0].length);
3233
+ }
3234
+
3235
+ return htmlFragmentToSummaryText(source);
3236
+ }
3237
+
3238
+ function removeNonVisibleSummaryHtml(html) {
3239
+ return String(html || '')
3240
+ .replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
3241
+ .replace(/<!--[\s\S]*?-->/g, ' ');
3242
+ }
3243
+
3244
+ function htmlFragmentToSummaryText(html) {
3245
+ const textWithTagBoundaries = String(html || '').replace(SUMMARY_TEXT_BOUNDARY_TAG_PATTERN, ' ');
3246
+ const decodedText = sanitizeHtml(textWithTagBoundaries, {
3247
+ allowedTags: [],
3248
+ allowedAttributes: {},
3249
+ parser: {
3250
+ decodeEntities: true,
3251
+ },
3252
+ });
3253
+ return normalizeSearchText(decodeHtmlEntities(decodedText));
3254
+ }
3255
+
3256
+ function normalizeComparableSummaryText(value) {
3257
+ return normalizeSearchText(value).normalize('NFC');
3258
+ }
3259
+
3190
3260
  function buildSearchHeadings(toc) {
3191
3261
  return Array.isArray(toc)
3192
3262
  ? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
@@ -59,7 +59,7 @@ const CONTENT_SANITIZER_OPTIONS = {
59
59
  allowIframeRelativeUrls: true,
60
60
  parseStyleAttributes: false,
61
61
  selfClosing: ['img', 'br', 'hr', 'input', 'source', 'track'],
62
- nonTextTags: ['script', 'style', 'textarea', 'option', 'xmp'],
62
+ nonTextTags: ['script', 'style', 'template', 'noscript', 'textarea', 'option', 'xmp'],
63
63
  transformTags: {
64
64
  a: transformAnchor,
65
65
  th: transformTableCell,