@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 +15 -1
- package/package.json +7 -7
- package/src/api/build-site.js +77 -7
- package/src/render/content-renderer.js +1 -1
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
|
|
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.
|
|
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.
|
|
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.
|
|
38
|
-
"@zeropress/theme-validator": "0.7.
|
|
39
|
-
"highlight.js": "11.
|
|
40
|
-
"markdown-it": "
|
|
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.
|
|
42
|
+
"sanitize-html": "2.17.7"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/api/build-site.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,
|